diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx index 0f0884127..b6d853d01 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -113,6 +113,9 @@ function mapAPIKeyExtraSettingToField( case "GITHUB": if (settingKey === "organization") return "githubOrganization"; break; + case "GRAFANA": + if (settingKey === "baseUrl") return "grafanaBaseUrl"; + break; case "ONE_PASSWORD": if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; break; diff --git a/pkg/accessreview/drivers/grafana.go b/pkg/accessreview/drivers/grafana.go new file mode 100644 index 000000000..6ad84fb04 --- /dev/null +++ b/pkg/accessreview/drivers/grafana.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +const grafanaUsersPageSize = 100 + +// GrafanaDriver fetches organization users from the Grafana HTTP API using +// Bearer-token authenticated REST requests against a configured Grafana base +// URL (Grafana Cloud stack URL or self-hosted Grafana URL). +type GrafanaDriver struct { + httpClient *http.Client + baseURL string +} + +var _ Driver = (*GrafanaDriver)(nil) + +type grafanaOrgUser struct { + UserID int `json:"userId"` + Email string `json:"email"` + Login string `json:"login"` + Name string `json:"name"` + Role string `json:"role"` + LastSeenAt string `json:"lastSeenAt"` + IsDisabled *bool `json:"isDisabled"` +} + +func NewGrafanaDriver(httpClient *http.Client, baseURL string) *GrafanaDriver { + return &GrafanaDriver{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +func (d *GrafanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + records := make([]AccountRecord, 0) + + for page := 1; page <= maxPaginationPages; page++ { + users, err := d.queryOrgUsers(ctx, page) + if err != nil { + return nil, err + } + + for _, u := range users { + email := strings.TrimSpace(u.Email) + if email == "" { + email = strings.TrimSpace(u.Login) + } + if email == "" { + continue + } + + record := AccountRecord{ + Email: email, + FullName: strings.TrimSpace(u.Name), + Role: strings.TrimSpace(u.Role), + IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: strconv.Itoa(u.UserID), + } + + if u.IsDisabled != nil { + active := !*u.IsDisabled + record.Active = &active + } + + if u.LastSeenAt != "" { + if t, err := time.Parse(time.RFC3339, u.LastSeenAt); err == nil { + record.LastLogin = &t + } else if t, err := time.Parse(time.RFC3339Nano, u.LastSeenAt); err == nil { + record.LastLogin = &t + } + } + + records = append(records, record) + } + + if len(users) < grafanaUsersPageSize { + return records, nil + } + } + + return nil, fmt.Errorf("cannot list all grafana accounts: %w", ErrPaginationLimitReached) +} + +func (d *GrafanaDriver) queryOrgUsers(ctx context.Context, page int) ([]grafanaOrgUser, error) { + u, err := url.Parse(d.baseURL) + if err != nil { + return nil, fmt.Errorf("cannot parse grafana base URL: %w", err) + } + + u = u.JoinPath("api", "org", "users") + q := u.Query() + q.Set("perpage", strconv.Itoa(grafanaUsersPageSize)) + q.Set("page", strconv.Itoa(page)) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create grafana users request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute grafana users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch grafana users: unexpected status %d", httpResp.StatusCode) + } + + var users []grafanaOrgUser + if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("cannot decode grafana users response: %w", err) + } + + return users, nil +} + +// grafanaNameResolver resolves the Grafana organization display name by +// querying /api/org on the configured Grafana instance. +type grafanaNameResolver struct { + httpClient *http.Client + baseURL string +} + +var _ NameResolver = (*grafanaNameResolver)(nil) + +type grafanaOrg struct { + Name string `json:"name"` +} + +func NewGrafanaNameResolver(httpClient *http.Client, baseURL string) NameResolver { + return &grafanaNameResolver{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +func (r *grafanaNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + u, err := url.Parse(r.baseURL) + if err != nil { + return "", fmt.Errorf("cannot parse grafana base URL: %w", err) + } + + u = u.JoinPath("api", "org") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("cannot create grafana organization request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute grafana organization request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", fmt.Errorf("cannot fetch grafana organization: unexpected status %d", httpResp.StatusCode) + } + + var org grafanaOrg + if err := json.NewDecoder(httpResp.Body).Decode(&org); err != nil { + return "", fmt.Errorf("cannot decode grafana organization response: %w", err) + } + + return strings.TrimSpace(org.Name), nil +} diff --git a/pkg/accessreview/drivers/grafana_test.go b/pkg/accessreview/drivers/grafana_test.go new file mode 100644 index 000000000..e14f31f7e --- /dev/null +++ b/pkg/accessreview/drivers/grafana_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestGrafanaDriverListAccounts(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Helper() + require.Equal(t, "/api/org/users", r.URL.Path) + require.Equal(t, "100", r.URL.Query().Get("perpage")) + + page, err := strconv.Atoi(r.URL.Query().Get("page")) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + + switch page { + case 1: + users := make([]map[string]any, 0, grafanaUsersPageSize) + users = append(users, map[string]any{ + "userId": 1, + "email": "admin@example.com", + "name": "Admin User", + "role": "Admin", + "isDisabled": false, + "lastSeenAt": "2026-05-20T10:00:00Z", + }) + for i := 1; i < grafanaUsersPageSize; i++ { + users = append(users, map[string]any{ + "userId": i + 100, + "name": "Ignored User", + "role": "Viewer", + }) + } + + _ = json.NewEncoder(w).Encode(users) + case 2: + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "userId": 2, + "login": "viewer@example.com", + "name": "Viewer User", + "role": "Viewer", + "isDisabled": true, + }, + }) + default: + t.Fatalf("unexpected page %d", page) + } + })) + t.Cleanup(ts.Close) + + driver := NewGrafanaDriver(ts.Client(), ts.URL) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + assert.Equal(t, "admin@example.com", records[0].Email) + assert.Equal(t, "Admin User", records[0].FullName) + assert.Equal(t, "Admin", records[0].Role) + assert.True(t, records[0].IsAdmin) + assert.Equal(t, "1", records[0].ExternalID) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + assert.Equal(t, coredata.AccessEntryAccountTypeUser, records[0].AccountType) + assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, records[0].AuthMethod) + assert.Equal(t, coredata.MFAStatusUnknown, records[0].MFAStatus) + require.NotNil(t, records[0].LastLogin) + assert.Equal(t, time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC), *records[0].LastLogin) + + assert.Equal(t, "viewer@example.com", records[1].Email) + assert.Equal(t, "Viewer User", records[1].FullName) + assert.Equal(t, "Viewer", records[1].Role) + assert.False(t, records[1].IsAdmin) + assert.Equal(t, "2", records[1].ExternalID) + require.NotNil(t, records[1].Active) + assert.False(t, *records[1].Active) +} + +func TestGrafanaNameResolver(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Helper() + require.Equal(t, "/api/org", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "Acme Grafana", + }) + })) + t.Cleanup(ts.Close) + + resolver := NewGrafanaNameResolver(ts.Client(), ts.URL) + name, err := resolver.ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Acme Grafana", name) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 45130eac0..6b76336a4 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -30,6 +30,7 @@ func NewBuiltinRegistry() *Registry { cloudflareRegistration(), cursorRegistration(), docusignRegistration(), + grafanaRegistration(), githubRegistration(), gitlabRegistration(), googleWorkspaceRegistration(), diff --git a/pkg/connector/provider/grafana.go b/pkg/connector/provider/grafana.go new file mode 100644 index 000000000..d67dcadad --- /dev/null +++ b/pkg/connector/provider/grafana.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func grafanaRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderGrafana, + DisplayName: "Grafana", + SupportsAPIKey: true, + ExtraSettings: []ExtraSetting{ + {Key: "baseUrl", Label: "Base URL", Required: true}, + }, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.GrafanaConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read grafana connector settings: %w", err) + } + + baseURL, err := normalizeGrafanaBaseURL(s.BaseURL) + if err != nil { + return nil, fmt.Errorf("cannot create grafana driver: %w", err) + } + + return drivers.NewGrafanaDriver(c, baseURL), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.GrafanaConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read grafana connector settings", log.Error(err)) + return nil + } + + baseURL, err := normalizeGrafanaBaseURL(s.BaseURL) + if err != nil { + logger.ErrorCtx(ctx, "invalid grafana base url in connector settings", log.Error(err)) + return nil + } + + return drivers.NewGrafanaNameResolver(c, baseURL) + }, + } +} + +func normalizeGrafanaBaseURL(raw string) (string, error) { + baseURL := strings.TrimSpace(raw) + if baseURL == "" { + return "", fmt.Errorf("base_url is required") + } + + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("base_url must be a valid URL: %w", err) + } + + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", fmt.Errorf("base_url must be an http(s) URL") + } + + u.Path = strings.TrimRight(u.Path, "/") + u.RawQuery = "" + u.Fragment = "" + + return u.String(), nil +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 1d19f66a0..3ab4370c3 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -33,6 +33,7 @@ const ( ConnectorProviderBrex ConnectorProvider = "BREX" ConnectorProviderTally ConnectorProvider = "TALLY" ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" + ConnectorProviderGrafana ConnectorProvider = "GRAFANA" ConnectorProviderOpenAI ConnectorProvider = "OPENAI" ConnectorProviderSentry ConnectorProvider = "SENTRY" ConnectorProviderSupabase ConnectorProvider = "SUPABASE" @@ -72,6 +73,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderBrex, ConnectorProviderTally, ConnectorProviderCloudflare, + ConnectorProviderGrafana, ConnectorProviderOpenAI, ConnectorProviderSentry, ConnectorProviderSupabase, @@ -107,6 +109,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderBrex, ConnectorProviderTally, ConnectorProviderCloudflare, + ConnectorProviderGrafana, ConnectorProviderOpenAI, ConnectorProviderSentry, ConnectorProviderSupabase, diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 9480b148e..57dae55ea 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -39,6 +39,10 @@ type ( OrganizationSlug string `json:"organization_slug"` } + GrafanaConnectorSettings struct { + BaseURL string `json:"base_url"` + } + SupabaseConnectorSettings struct { OrganizationSlug string `json:"organization_slug"` } diff --git a/pkg/coredata/migrations/20260528T214500Z.sql b/pkg/coredata/migrations/20260528T214500Z.sql new file mode 100644 index 000000000..5b34828b5 --- /dev/null +++ b/pkg/coredata/migrations/20260528T214500Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'GRAFANA'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 577616df5..76f7c9c9f 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -60,6 +60,17 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.GitHubConnectorSettings{Organization: *input.GithubOrganization}) + case coredata.ConnectorProviderGrafana: + if input.GrafanaBaseURL == nil || *input.GrafanaBaseURL == "" { + return nil, fmt.Errorf("cannot create grafana connector: grafanaBaseUrl is required") + } + + u, err := url.Parse(*input.GrafanaBaseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("cannot create grafana connector: grafanaBaseUrl must be an http(s) URL") + } + + return json.Marshal(&coredata.GrafanaConnectorSettings{BaseURL: *input.GrafanaBaseURL}) case coredata.ConnectorProviderOnePassword: if input.OnePasswordScimBridgeURL == nil || *input.OnePasswordScimBridgeURL == "" { return nil, fmt.Errorf("cannot create 1password connector: onePasswordScimBridgeURL is required") diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 3bae6abba..21b1179d2 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -14,6 +14,8 @@ enum ConnectorProvider @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderHubSpot") DOCUSIGN @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDocuSign") + GRAFANA + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGrafana") NOTION @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNotion") BREX @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrex") TALLY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderTally") @@ -121,6 +123,7 @@ input CreateAPIKeyConnectorInput { sentryOrganizationSlug: String supabaseOrganizationSlug: String githubOrganization: String + grafanaBaseUrl: String onePasswordScimBridgeUrl: String }