Add SigNoz access review driver

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 <bryan@frimin.fr>
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-06-08 23:15:50 +02:00
parent dbf915047d
commit 4df0e52810
17 changed files with 794 additions and 2 deletions

View File

@@ -114,6 +114,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string {
return "Slack";
case "METABASE":
return "Metabase";
case "SIGNOZ":
return "SigNoz";
default:
return connectorProvider;
}

View File

@@ -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;

View File

@@ -0,0 +1,18 @@
import type { ComponentProps } from "react";
export function SigNoz(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="#DD431F"
d="M20.0557 39.9666C8.91365 39.9666 0 31.2241 0 20.2958C0 9.47687 8.91365 0.625 20.0557 0.625H32.6462C36.7688 0.625 40 3.90347 40 7.83763V20.2958C40 31.2241 31.0863 39.9666 20.0557 39.9666ZM19.8991 11.7803C11.5768 11.7803 6.889 18.6718 6.69348 18.9652C6.21149 19.6878 6.21149 20.6224 6.69384 21.3455C6.889 21.6384 11.5768 28.5299 19.8991 28.5299C28.2214 28.5299 32.9092 21.6384 33.1047 21.345C33.5868 20.6224 33.5868 19.6878 33.1044 18.9647C32.9092 18.6718 28.2214 11.7803 19.8991 11.7803ZM7.84542 20.5774C7.67429 20.3208 7.67429 19.9894 7.84542 19.7328C8.00265 19.4968 11.3403 14.5942 17.3076 13.4182C14.6035 14.462 12.6793 17.0872 12.6793 20.1551C12.6793 23.2229 14.6035 25.8482 17.3076 26.892C11.3403 25.7159 8.00265 20.8133 7.84542 20.5774ZM16.5721 20.1551C16.5721 20.5374 16.2622 20.8472 15.8799 20.8472C15.4977 20.8472 15.1878 20.5374 15.1878 20.1551C15.1878 17.5573 17.3012 15.4438 19.8991 15.4438C20.2813 15.4438 20.5912 15.7536 20.5912 16.1359C20.5912 16.5183 20.2814 16.8281 19.8991 16.8281C18.0645 16.8281 16.5721 18.3205 16.5721 20.1551ZM19.8991 21.8378C18.9713 21.8378 18.2164 21.083 18.2164 20.1551C18.2164 19.2272 18.9713 18.4723 19.8991 18.4723C20.827 18.4723 21.5819 19.2272 21.5819 20.1551C21.5819 21.083 20.827 21.8378 19.8991 21.8378ZM31.9528 20.5774C31.7956 20.8133 28.458 25.7159 22.4907 26.892C25.1949 25.8481 27.1189 23.2229 27.1189 20.1551C27.1189 17.0873 25.1949 14.462 22.4907 13.4182C28.458 14.5943 31.7956 19.4968 31.9528 19.7328C32.124 19.9894 32.124 20.3208 31.9528 20.5774Z"
/>
</svg>
);
}

View File

@@ -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<string, FC<ComponentProps<"svg">>> = {
RESEND: Resend,
SENDGRID: SendGrid,
SENTRY: Sentry,
SIGNOZ: SigNoz,
SLACK: Slack,
SUPABASE: Supabase,
TAILSCALE: Tailscale,

View File

@@ -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";

View File

@@ -0,0 +1,267 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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": <payload>}.
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
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
})
}

View File

@@ -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

View File

@@ -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),

View File

@@ -53,6 +53,7 @@ func NewBuiltinRegistry() *Registry {
resendRegistration(),
sendgridRegistration(),
sentryRegistration(),
signozRegistration(),
slackRegistration(),
supabaseRegistration(),
tailscaleRegistration(),

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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")
})
}

View File

@@ -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,

View File

@@ -39,6 +39,10 @@ type (
OrganizationSlug string `json:"organization_slug"`
}
SigNozConnectorSettings struct {
BaseURL string `json:"base_url"`
}
GrafanaConnectorSettings struct {
BaseURL string `json:"base_url"`
}

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- 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';

View File

@@ -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")

View File

@@ -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