From 4df0e528108b462b258d5d574465131213cabe32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:15:50 +0200 Subject: [PATCH] Add SigNoz access review driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a SigNoz connector so its organization members can be pulled into access-review campaign snapshots. SigNoz authenticates with a SIGNOZ-API-KEY admin service-account key and a customer-supplied base URL (a SigNoz Cloud region/tenant host or a self-hosted instance). The driver lists users via GET /api/v1/user, which returns the role (ADMIN/EDITOR/VIEWER) inline so admin detection works in a single call, and maps the SigNoz user status (active / pending_invite / deleted) to the account active flag. The name resolver reads the organization display name from GET /api/v2/orgs/me to title the access source. Wire the provider through the coredata enum and settings, the connector-provider registry (driver and name-resolver factories), the console API-key input schema and validation, the access-review source label, and the SigNoz brand logo. Co-authored-by: Bryan FRIMIN Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../_components/AccessSourceRow.tsx | 2 + .../dialogs/AddAccessSourceDialog.tsx | 3 + packages/ui/src/Atoms/ThirdParties/SigNoz.tsx | 18 ++ .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 + packages/ui/src/Atoms/ThirdParties/index.ts | 1 + pkg/accessreview/drivers/signoz.go | 267 ++++++++++++++++++ pkg/accessreview/drivers/signoz_test.go | 188 ++++++++++++ pkg/accessreview/drivers/testdata/signoz.yaml | 53 ++++ pkg/accessreview/drivers/vcr_test.go | 6 +- pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/signoz.go | 89 ++++++ pkg/connector/provider/signoz_test.go | 130 +++++++++ pkg/coredata/connector_provider.go | 3 + pkg/coredata/connector_settings.go | 4 + pkg/coredata/migrations/20260529T062100Z.sql | 15 + .../api/console/v1/connector_settings.go | 11 + .../api/console/v1/graphql/connector.graphql | 3 + 17 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/Atoms/ThirdParties/SigNoz.tsx create mode 100644 pkg/accessreview/drivers/signoz.go create mode 100644 pkg/accessreview/drivers/signoz_test.go create mode 100644 pkg/accessreview/drivers/testdata/signoz.yaml create mode 100644 pkg/connector/provider/signoz.go create mode 100644 pkg/connector/provider/signoz_test.go create mode 100644 pkg/coredata/migrations/20260529T062100Z.sql diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx index 919bb7f7b..bd8e44af5 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx @@ -114,6 +114,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string { return "Slack"; case "METABASE": return "Metabase"; + case "SIGNOZ": + return "SigNoz"; default: return connectorProvider; } 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 2f2c1bfa1..3a96b3251 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -132,6 +132,9 @@ function mapAPIKeyExtraSettingToField( case "GRAFANA": if (settingKey === "baseUrl") return "grafanaBaseUrl"; break; + case "SIGNOZ": + if (settingKey === "baseUrl") return "signozBaseUrl"; + break; case "ONE_PASSWORD": if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; break; diff --git a/packages/ui/src/Atoms/ThirdParties/SigNoz.tsx b/packages/ui/src/Atoms/ThirdParties/SigNoz.tsx new file mode 100644 index 000000000..e1ef8f147 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/SigNoz.tsx @@ -0,0 +1,18 @@ +import type { ComponentProps } from "react"; + +export function SigNoz(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 761dc82e4..7303d63c9 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -46,6 +46,7 @@ import { PostHog } from "./PostHog"; import { Resend } from "./Resend"; import { SendGrid } from "./SendGrid"; import { Sentry } from "./Sentry"; +import { SigNoz } from "./SigNoz"; import { Slack } from "./Slack"; import { Supabase } from "./Supabase"; import { Tailscale } from "./Tailscale"; @@ -89,6 +90,7 @@ const thirdParties: Record>> = { RESEND: Resend, SENDGRID: SendGrid, SENTRY: Sentry, + SIGNOZ: SigNoz, SLACK: Slack, SUPABASE: Supabase, TAILSCALE: Tailscale, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index a88dc1a61..5a11c3007 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -30,6 +30,7 @@ export { PostHog } from "./PostHog"; export { Resend } from "./Resend"; export { SendGrid } from "./SendGrid"; export { Sentry } from "./Sentry"; +export { SigNoz } from "./SigNoz"; export { Slack } from "./Slack"; export { Supabase } from "./Supabase"; export { Tally } from "./Tally"; diff --git a/pkg/accessreview/drivers/signoz.go b/pkg/accessreview/drivers/signoz.go new file mode 100644 index 000000000..5ac7f8134 --- /dev/null +++ b/pkg/accessreview/drivers/signoz.go @@ -0,0 +1,267 @@ +// 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" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +// SigNozDriver fetches organization members from the SigNoz API. The API key +// is injected by the connector's API-key HTTP client via the SIGNOZ-API-KEY +// header. The same base URL serves SigNoz Cloud (region/tenant host) and +// self-hosted instances. +type SigNozDriver struct { + httpClient *http.Client + baseURL string +} + +var _ Driver = (*SigNozDriver)(nil) + +// sigNozEnvelope is the standard SigNoz REST response wrapper: +// {"status":"success","data": }. +type sigNozEnvelope struct { + Data json.RawMessage `json:"data"` +} + +// sigNozUser models a user from GET /api/v1/user. That ("v1") list endpoint +// returns role inline; the v2 endpoint omits role entirely, which would +// silently disable admin detection. +type sigNozUser struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Status string `json:"status"` + IsRoot bool `json:"isRoot"` + CreatedAt string `json:"createdAt"` +} + +func NewSigNozDriver(httpClient *http.Client, baseURL string) *SigNozDriver { + return &SigNozDriver{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +func (d *SigNozDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + users, err := d.queryUsers(ctx) + if err != nil { + return nil, err + } + + records := make([]AccountRecord, 0, len(users)) + + for _, u := range users { + email := strings.TrimSpace(u.Email) + if email == "" { + continue + } + + role := sigNozRole(u.Role) + + record := AccountRecord{ + Email: email, + FullName: strings.TrimSpace(u.DisplayName), + Role: role, + Active: sigNozActiveStatus(u.Status), + IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: strings.TrimSpace(u.ID), + } + + if t, ok := parseSigNozTimestamp(u.CreatedAt); ok { + record.CreatedAt = &t + } + + records = append(records, record) + } + + return records, nil +} + +func (d *SigNozDriver) queryUsers(ctx context.Context) ([]sigNozUser, error) { + baseURL, err := url.Parse(d.baseURL) + if err != nil { + return nil, fmt.Errorf("cannot parse signoz base URL: %w", err) + } + + endpoint := baseURL.JoinPath("api", "v1", "user") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create signoz 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 signoz users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch signoz users: unexpected status %d", httpResp.StatusCode) + } + + var envelope sigNozEnvelope + if err := json.NewDecoder(httpResp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("cannot decode signoz users response: %w", err) + } + + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return []sigNozUser{}, nil + } + + var users []sigNozUser + if err := json.Unmarshal(envelope.Data, &users); err != nil { + return nil, fmt.Errorf("cannot decode signoz users data: %w", err) + } + + return users, nil +} + +// sigNozRole normalizes a SigNoz role string (ADMIN / EDITOR / VIEWER, or the +// managed-role display names signoz-admin / signoz-editor / signoz-viewer) +// into a stable label, preserving unknown custom roles verbatim. Matching is +// exact (not substring) so a custom role merely containing "admin" is not +// silently promoted to Admin. +func sigNozRole(raw string) string { + role := strings.TrimSpace(raw) + if role == "" { + return "User" + } + + switch strings.ToLower(role) { + case "admin", "signoz-admin": + return "Admin" + case "editor", "signoz-editor": + return "Editor" + case "viewer", "signoz-viewer": + return "Viewer" + default: + return role + } +} + +// sigNozActiveStatus maps the SigNoz user status. SigNoz emits exactly +// "active", "pending_invite" and "deleted"; anything else is treated as an +// unknown signal (nil) rather than fabricated. +func sigNozActiveStatus(status string) *bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "active": + return new(true) + case "pending_invite", "deleted": + return new(false) + default: + return nil + } +} + +func parseSigNozTimestamp(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + } { + t, err := time.Parse(layout, value) + if err == nil { + return t, true + } + } + + return time.Time{}, false +} + +// signozNameResolver resolves the SigNoz organization display name via +// GET /api/v2/orgs/me on the configured instance. The organization is derived +// from the API key's claims, so no identifier is needed in the path. +type signozNameResolver struct { + httpClient *http.Client + baseURL string +} + +var _ NameResolver = (*signozNameResolver)(nil) + +func NewSigNozNameResolver(httpClient *http.Client, baseURL string) NameResolver { + return &signozNameResolver{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +func (r *signozNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + baseURL, err := url.Parse(r.baseURL) + if err != nil { + return "", fmt.Errorf("cannot parse signoz base URL: %w", err) + } + + endpoint := baseURL.JoinPath("api", "v2", "orgs", "me") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return "", fmt.Errorf("cannot create signoz organization request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute signoz organization request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + // Best-effort: a non-2xx (revoked key, or an older SigNoz without this + // route) must not make the source-name worker retry forever. Keep the + // generic source name; a dead key surfaces on the next ListAccounts. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var envelope struct { + Data struct { + DisplayName string `json:"displayName"` + Name string `json:"name"` + } `json:"data"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&envelope); err != nil { + return "", fmt.Errorf("cannot decode signoz organization response: %w", err) + } + + if name := strings.TrimSpace(envelope.Data.DisplayName); name != "" { + return name, nil + } + + return strings.TrimSpace(envelope.Data.Name), nil +} diff --git a/pkg/accessreview/drivers/signoz_test.go b/pkg/accessreview/drivers/signoz_test.go new file mode 100644 index 000000000..186295500 --- /dev/null +++ b/pkg/accessreview/drivers/signoz_test.go @@ -0,0 +1,188 @@ +// 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" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +// TestSigNozDriver replays a cassette (recorded against SigNoz Cloud, then +// anonymized) covering the role/status matrix and exercises both the driver +// (GET /api/v1/user) and the name resolver (GET /api/v2/orgs/me). +func TestSigNozDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/signoz", "SIGNOZ_API_KEY") + client := newVCRClientWithHeader(rec, "SIGNOZ-API-KEY", os.Getenv("SIGNOZ_API_KEY")) + + baseURL := os.Getenv("SIGNOZ_BASE_URL") + if baseURL == "" { + baseURL = "https://signoz.example.com" + } + + records, err := NewSigNozDriver(client, baseURL).ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 5) // the no-email user is skipped + + // ADMIN role -> admin. + 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, "00000000-0000-4000-8000-000000000001", records[0].ExternalID) + assert.Equal(t, coredata.MFAStatusUnknown, records[0].MFAStatus) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + require.NotNil(t, records[0].CreatedAt) + + // isRoot -> admin even with a non-admin role. + assert.Equal(t, "owner@example.com", records[1].Email) + assert.Equal(t, "Viewer", records[1].Role) + assert.True(t, records[1].IsAdmin) + + // Managed-role display name -> Editor; not admin. + assert.Equal(t, "editor@example.com", records[2].Email) + assert.Equal(t, "Editor", records[2].Role) + assert.False(t, records[2].IsAdmin) + require.NotNil(t, records[2].Active) + assert.True(t, *records[2].Active) + + // pending_invite -> inactive. + assert.Equal(t, "invited@example.com", records[3].Email) + assert.Equal(t, "Viewer", records[3].Role) + require.NotNil(t, records[3].Active) + assert.False(t, *records[3].Active) + + // deleted -> inactive. + assert.Equal(t, "removed@example.com", records[4].Email) + assert.Equal(t, "Editor", records[4].Role) + require.NotNil(t, records[4].Active) + assert.False(t, *records[4].Active) + + name, err := NewSigNozNameResolver(client, baseURL).ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Example Org", name) +} + +func TestSigNozDriverListAccountsEmptyData(t *testing.T) { + t.Parallel() + + for name, payload := range map[string]string{ + "null data": `{"status":"success","data":null}`, + "empty array": `{"status":"success","data":[]}`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + defer srv.Close() + + records, err := NewSigNozDriver(srv.Client(), srv.URL).ListAccounts(context.Background()) + require.NoError(t, err) + assert.Empty(t, records) + }) + } +} + +func TestSigNozDriverListAccountsErrorStatus(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"status":"error"}`)) + })) + defer srv.Close() + + _, err := NewSigNozDriver(srv.Client(), srv.URL).ListAccounts(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 403") +} + +func TestSigNozRole(t *testing.T) { + t.Parallel() + + for in, want := range map[string]string{ + "ADMIN": "Admin", + "signoz-admin": "Admin", + "EDITOR": "Editor", + "signoz-editor": "Editor", + "VIEWER": "Viewer", + "signoz-viewer": "Viewer", + "": "User", + " ": "User", + "custom-role": "custom-role", // unknown role preserved verbatim + "superadmin": "superadmin", // contains "admin" but must NOT be promoted + } { + assert.Equalf(t, want, sigNozRole(in), "role %q", in) + } +} + +func TestSigNozActiveStatus(t *testing.T) { + t.Parallel() + + active := sigNozActiveStatus("active") + require.NotNil(t, active) + assert.True(t, *active) + + for _, status := range []string{"pending_invite", "deleted"} { + v := sigNozActiveStatus(status) + require.NotNilf(t, v, "status %q", status) + assert.Falsef(t, *v, "status %q", status) + } + + assert.Nil(t, sigNozActiveStatus("something_unexpected")) +} + +func TestSigNozNameResolver(t *testing.T) { + t.Parallel() + + t.Run("falls back to name when displayName is empty", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","data":{"displayName":"","name":"acme"}}`)) + })) + defer srv.Close() + + name, err := NewSigNozNameResolver(srv.Client(), srv.URL).ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, "acme", name) + }) + + t.Run("returns empty without error on terminal failure", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + name, err := NewSigNozNameResolver(srv.Client(), srv.URL).ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Empty(t, name) + }) +} diff --git a/pkg/accessreview/drivers/testdata/signoz.yaml b/pkg/accessreview/drivers/testdata/signoz.yaml new file mode 100644 index 000000000..b3e54b380 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/signoz.yaml @@ -0,0 +1,53 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: signoz.example.com + headers: + Accept: + - application/json + url: https://signoz.example.com/api/v1/user + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"status":"success","data":[{"id":"00000000-0000-4000-8000-000000000001","displayName":"Admin User","email":"admin@example.com","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":false,"status":"active","createdAt":"2026-05-01T10:20:30Z","updatedAt":"2026-05-01T10:20:30Z","role":"ADMIN"},{"id":"00000000-0000-4000-8000-000000000002","displayName":"Owner User","email":"owner@example.com","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":true,"status":"active","createdAt":"2026-05-02T10:20:30Z","updatedAt":"2026-05-02T10:20:30Z","role":"VIEWER"},{"id":"00000000-0000-4000-8000-000000000003","displayName":"Editor User","email":"editor@example.com","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":false,"status":"active","createdAt":"2026-05-03T10:20:30Z","updatedAt":"2026-05-03T10:20:30Z","role":"signoz-editor"},{"id":"00000000-0000-4000-8000-000000000004","displayName":"Invited User","email":"invited@example.com","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":false,"status":"pending_invite","createdAt":"2026-05-04T10:20:30Z","updatedAt":"2026-05-04T10:20:30Z","role":"VIEWER"},{"id":"00000000-0000-4000-8000-000000000005","displayName":"Removed User","email":"removed@example.com","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":false,"status":"deleted","createdAt":"2026-05-05T10:20:30Z","updatedAt":"2026-05-05T10:20:30Z","role":"EDITOR"},{"id":"00000000-0000-4000-8000-000000000006","displayName":"No Email","email":"","orgId":"00000000-0000-4000-8000-0000000000aa","isRoot":false,"status":"active","createdAt":"2026-05-06T10:20:30Z","updatedAt":"2026-05-06T10:20:30Z","role":"ADMIN"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: signoz.example.com + headers: + Accept: + - application/json + url: https://signoz.example.com/api/v2/orgs/me + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"status":"success","data":{"createdAt":"2026-05-01T08:00:00Z","updatedAt":"2026-05-01T08:00:00Z","id":"00000000-0000-4000-8000-0000000000aa","name":"example-org","alias":"","key":12345678,"displayName":"Example Org"}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index cb79f4815..be5f07a56 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -51,10 +51,12 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec )), recorder.WithHook(func(i *cassette.Interaction) error { i.Request.Headers.Del("Authorization") - // Providers like Anthropic authenticate via x-api-key rather - // than Authorization; strip it too so a re-record never + // Providers like Anthropic (x-api-key) and SigNoz + // (SIGNOZ-API-KEY) authenticate via a custom header rather + // than Authorization; strip those too so a re-record never // persists a raw key. i.Request.Headers.Del("X-Api-Key") + i.Request.Headers.Del("Signoz-Api-Key") return nil }, recorder.BeforeSaveHook), diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index ec33a42c3..55fdd422e 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -53,6 +53,7 @@ func NewBuiltinRegistry() *Registry { resendRegistration(), sendgridRegistration(), sentryRegistration(), + signozRegistration(), slackRegistration(), supabaseRegistration(), tailscaleRegistration(), diff --git a/pkg/connector/provider/signoz.go b/pkg/connector/provider/signoz.go new file mode 100644 index 000000000..b72f1a532 --- /dev/null +++ b/pkg/connector/provider/signoz.go @@ -0,0 +1,89 @@ +// 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 signozRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSigNoz, + DisplayName: "SigNoz", + SupportsAPIKey: true, + APIKeyHeader: "SIGNOZ-API-KEY", + ExtraSettings: []ExtraSetting{ + {Key: "baseUrl", Label: "Base URL", Required: true}, + }, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + settings, err := coredata.ConnectorSettings[coredata.SigNozConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read signoz connector settings: %w", err) + } + + baseURL, err := normalizeSigNozBaseURL(settings.BaseURL) + if err != nil { + return nil, fmt.Errorf("cannot create signoz driver: %w", err) + } + + return drivers.NewSigNozDriver(c, baseURL), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + settings, err := coredata.ConnectorSettings[coredata.SigNozConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read signoz connector settings", log.Error(err)) + return nil + } + + baseURL, err := normalizeSigNozBaseURL(settings.BaseURL) + if err != nil { + logger.ErrorCtx(ctx, "invalid signoz base url in connector settings", log.Error(err)) + return nil + } + + return drivers.NewSigNozNameResolver(c, baseURL) + }, + } +} + +func normalizeSigNozBaseURL(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/connector/provider/signoz_test.go b/pkg/connector/provider/signoz_test.go new file mode 100644 index 000000000..c691e262d --- /dev/null +++ b/pkg/connector/provider/signoz_test.go @@ -0,0 +1,130 @@ +// 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_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +func TestSigNozRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderSigNoz) + require.True(t, ok, "signoz provider must be registered") + + assert.Equal(t, "SigNoz", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + assert.Equal(t, "SIGNOZ-API-KEY", reg.APIKeyHeader) + require.Len(t, reg.ExtraSettings, 1) + assert.Equal(t, "baseUrl", reg.ExtraSettings[0].Key) + assert.Equal(t, "Base URL", reg.ExtraSettings[0].Label) + assert.True(t, reg.ExtraSettings[0].Required) + require.NotNil(t, reg.NewNameResolver, "signoz NewNameResolver closure must be wired") +} + +func TestSigNozNewNameResolver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderSigNoz) + require.True(t, ok, "signoz provider must be registered") + require.NotNil(t, reg.NewNameResolver, "signoz NewNameResolver closure must be wired") + + raw, err := json.Marshal(&coredata.SigNozConnectorSettings{ + BaseURL: "https://acme.us.signoz.cloud", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderSigNoz, + RawSettings: raw, + } + + resolver := reg.NewNameResolver( + context.Background(), + httpclient.DefaultClient(httpclient.WithSSRFProtection()), + conn, + nil, + ) + require.NotNil(t, resolver) +} + +func TestSigNozNewDriver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderSigNoz) + require.True(t, ok, "signoz provider must be registered") + require.NotNil(t, reg.NewDriver, "signoz NewDriver closure must be wired") + + t.Run("creates driver with valid base_url", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.SigNozConnectorSettings{ + BaseURL: "https://cloud.signoz.io", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderSigNoz, + RawSettings: raw, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.SigNozDriver{}, drv) + }) + + t.Run("errors when base_url is missing", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderSigNoz, + RawSettings: []byte(`{}`), + } + + _, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "base_url is required") + }) + + t.Run("errors when base_url is invalid", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.SigNozConnectorSettings{ + BaseURL: "ftp://cloud.signoz.io", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderSigNoz, + RawSettings: raw, + } + + _, err = reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "base_url must be an http(s) URL") + }) +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 58691b93d..117806ecf 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -37,6 +37,7 @@ const ( ConnectorProviderOpenAI ConnectorProvider = "OPENAI" ConnectorProviderPostHog ConnectorProvider = "POSTHOG" ConnectorProviderSentry ConnectorProvider = "SENTRY" + ConnectorProviderSigNoz ConnectorProvider = "SIGNOZ" ConnectorProviderSupabase ConnectorProvider = "SUPABASE" ConnectorProviderGitHub ConnectorProvider = "GITHUB" ConnectorProviderIntercom ConnectorProvider = "INTERCOM" @@ -84,6 +85,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderOpenAI, ConnectorProviderPostHog, ConnectorProviderSentry, + ConnectorProviderSigNoz, ConnectorProviderSupabase, ConnectorProviderGitHub, ConnectorProviderIntercom, @@ -127,6 +129,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderOpenAI, ConnectorProviderPostHog, ConnectorProviderSentry, + ConnectorProviderSigNoz, ConnectorProviderSupabase, ConnectorProviderGitHub, ConnectorProviderIntercom, diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 423acfa88..ffc690a35 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -39,6 +39,10 @@ type ( OrganizationSlug string `json:"organization_slug"` } + SigNozConnectorSettings struct { + BaseURL string `json:"base_url"` + } + GrafanaConnectorSettings struct { BaseURL string `json:"base_url"` } diff --git a/pkg/coredata/migrations/20260529T062100Z.sql b/pkg/coredata/migrations/20260529T062100Z.sql new file mode 100644 index 000000000..aed683d45 --- /dev/null +++ b/pkg/coredata/migrations/20260529T062100Z.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 'SIGNOZ'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index a7fe21ed7..cba71273c 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -73,6 +73,17 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.GrafanaConnectorSettings{BaseURL: *input.GrafanaBaseURL}) + case coredata.ConnectorProviderSigNoz: + if input.SignozBaseURL == nil || *input.SignozBaseURL == "" { + return nil, fmt.Errorf("cannot create signoz connector: signozBaseUrl is required") + } + + u, err := url.Parse(*input.SignozBaseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("cannot create signoz connector: signozBaseUrl must be an http(s) URL") + } + + return json.Marshal(&coredata.SigNozConnectorSettings{BaseURL: *input.SignozBaseURL}) 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 ac97f4630..4b574a57b 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -24,6 +24,8 @@ enum ConnectorProvider OPENAI @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOpenAI") POSTHOG @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderPostHog") SENTRY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSentry") + SIGNOZ + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSigNoz") SUPABASE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSupabase") GITHUB @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGitHub") @@ -133,6 +135,7 @@ input CreateAPIKeyConnectorInput { supabaseOrganizationSlug: String githubOrganization: String grafanaBaseUrl: String + signozBaseUrl: String onePasswordScimBridgeUrl: String metabaseInstanceUrl: String posthogRegion: String