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

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