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