Compare commits

...

11 Commits

Author SHA1 Message Date
grandwizard28
4f400da770 test(callbackauthn): pin the auth domain POST/GET roundtrip contract
Clients like the terraform provider land state via a follow-up GET after
every write, so the responses must round-trip posted values exactly. The
parametrized cases pin that contract per kind, including the server-side
defaulting (attribute/claim mappings, zero-value scalars) and role-name
normalization (EDITOR -> signoz-editor, null groupMappings).
2026-08-08 17:58:32 +05:30
grandwizard28
de3ba9d20c test(callbackauthn): cover the google authn flow end to end
A wiremock container impersonates Google's OIDC provider: it joins the
test network as accounts.google.com and serves HTTPS with a certificate
issued by a new integration CA, which every signoz container now trusts
via SSL_CERT_FILE, since the google callback authn hardcodes Google's
issuer and fully verifies the RS256 id_token against the served JWKS.
Stubs are installed per test with a pre-signed token for the identity
under test. The domain tests also sweep leftover domains so reruns
against a reused stack stay green.
2026-08-08 17:45:22 +05:30
grandwizard28
fe447e7f35 fix(authtypes): mark validator-required config fields as required in the schema 2026-08-08 17:12:19 +05:30
grandwizard28
94ce3e51f1 fix(apiserver): move auth domain endpoints to /api/v2/auth_domains
The request and response shapes changed, so the endpoints move to a new
version instead of breaking /api/v1/domains in place; the v1 routes are
removed.
2026-08-08 17:02:21 +05:30
grandwizard28
a5ddafc5db fix(authtypes): rename google_auth kind to google and saml ssoUrl to location
The kind follows the provider name; the SAML field follows the Location
attribute of the SingleSignOnService element, consistent with entityId
and certificate. Persisted rows are untouched: StorableAuthDomainConfig
translates the legacy google_auth value on read and keeps writing it.
2026-08-08 16:52:28 +05:30
grandwizard28
54e2caaac2 docs(contributing): explain envelope placement and tagging-style rationale 2026-08-08 16:21:18 +05:30
grandwizard28
9313e7bea5 docs(contributing): document the kind/spec envelope for sum types 2026-08-08 16:12:35 +05:30
grandwizard28
6b58b6fcae fix(tests): update auth domain payloads to kind/spec envelope 2026-08-08 16:11:22 +05:30
grandwizard28
cc5d3574a4 fix(frontend): adopt kind/spec auth domain payload in org settings
Regenerates the orval client (AuthtypesAuthDomainConfigDTO is now a
discriminated union) and updates the AuthDomain container, toggle, list
and tests to the new enabled/config/roleMapping root shape and the
renamed SAML spec keys.
2026-08-08 16:07:58 +05:30
grandwizard28
6175ff3fc2 chore(openapi): regenerate spec for auth domain kind/spec envelope 2026-08-08 16:00:21 +05:30
grandwizard28
db7ec08969 fix(authtypes): restructure auth domain payload into a kind/spec envelope
The auth domain config previously carried the discriminator (ssoType) and
the per-provider payloads as sibling fields, which cannot be expressed as
an OpenAPI discriminated union. AuthDomainConfig is now a kind/spec
envelope; ssoEnabled and roleMapping move to the root as enabled and
roleMapping. The persisted shape is unchanged: StorableAuthDomainConfig
keeps the legacy keys and conversions happen at the type boundary.
2026-08-08 15:59:32 +05:30
44 changed files with 2115 additions and 860 deletions

View File

@@ -464,27 +464,50 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
discriminator:
mapping:
google: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
oidc: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
saml: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
type: object
AuthtypesAuthDomainConfigGoogle:
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigOIDC:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigSAML:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
$ref: '#/components/schemas/AuthtypesSamlConfig'
required:
- kind
- spec
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- google
- saml
- email_password
- oidc
@@ -531,12 +554,16 @@ components:
createdAt:
format: date-time
type: string
enabled:
type: boolean
id:
type: string
name:
type: string
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
updatedAt:
format: date-time
type: string
@@ -616,6 +643,9 @@ components:
type: string
serviceAccountJson:
type: string
required:
- clientId
- clientSecret
type: object
AuthtypesOIDCConfig:
properties:
@@ -633,6 +663,10 @@ components:
type: string
issuerAlias:
type: string
required:
- issuer
- clientId
- clientSecret
type: object
AuthtypesOrgSessionContext:
properties:
@@ -654,8 +688,15 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
name:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- name
- config
type: object
AuthtypesPostableEmailPasswordSession:
properties:
@@ -762,14 +803,18 @@ components:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
certificate:
type: string
entityId:
type: string
insecureSkipAuthNRequestsSigned:
type: boolean
samlCert:
type: string
samlEntity:
type: string
samlIdp:
location:
type: string
required:
- entityId
- location
- certificate
type: object
AuthtypesSessionContext:
properties:
@@ -809,6 +854,12 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- config
type: object
AuthtypesUpdatableRole:
properties:
@@ -10511,275 +10562,6 @@ paths:
summary: Update public dashboard
tags:
- dashboard
/api/v1/domains:
get:
deprecated: false
description: This endpoint lists all auth domains
operationId: ListAuthDomains
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List all auth domains
tags:
- authdomains
post:
deprecated: false
description: This endpoint creates an auth domain
operationId: CreateAuthDomain
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create auth domain
tags:
- authdomains
/api/v1/domains/{id}:
delete:
deprecated: false
description: This endpoint deletes an auth domain
operationId: DeleteAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
operationId: UpdateAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update auth domain
tags:
- authdomains
/api/v1/downtime_schedules:
get:
deprecated: false
@@ -14804,6 +14586,275 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/auth_domains:
get:
deprecated: false
description: This endpoint lists all auth domains
operationId: ListAuthDomains
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List all auth domains
tags:
- authdomains
post:
deprecated: false
description: This endpoint creates an auth domain
operationId: CreateAuthDomain
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create auth domain
tags:
- authdomains
/api/v2/auth_domains/{id}:
delete:
deprecated: false
description: This endpoint deletes an auth domain
operationId: DeleteAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
operationId: UpdateAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update auth domain
tags:
- authdomains
/api/v2/dashboard_views:
get:
deprecated: false

View File

@@ -349,7 +349,7 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`). How to model the sum type itself is covered in [types.md](types.md#sum-types-the-kindspec-envelope) — this section is only about its schema.
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.

View File

@@ -61,31 +61,37 @@ type Channel struct {
```go
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
}
type StorableAuthDomain struct {
bun.BaseModel `bun:"table:auth_domain"`
types.Identifiable
Name string `bun:"name"`
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
OrgID valuer.UUID `bun:"org_id"`
types.TimeAuditable
}
type PostableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type UpdateableAuthDomain struct {
Config AuthDomainConfig `json:"config"` // Name intentionally absent
type UpdatableAuthDomain struct {
Enabled bool `json:"enabled"` // Name intentionally absent
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type GettableAuthDomain struct {
*StorableAuthDomain
*AuthDomainConfig
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
```
@@ -93,11 +99,74 @@ type GettableAuthDomain struct {
Each flavor exists for a concrete reason:
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request. `AuthDomainConfig` is a kind/spec envelope — see the next section.
- `UpdatableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
## Sum types: the kind/spec envelope
When a domain type is a *sum type* — exactly one of several variants, selected by a discriminator — model it as an envelope with a `kind` and a `spec`:
```go
type AuthDomainConfig struct {
Kind AuthNProvider `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
```
```json
{ "kind": "saml", "spec": { "entityId": "...", "location": "...", "certificate": "..." } }
```
`Kind` is a `valuer.String` enum implementing `Enum()`; `Spec` holds exactly one concrete variant type. `RuleThresholdData` and `EvaluationEnvelope` in `pkg/types/ruletypes/` and `AuthDomainConfig` in `pkg/types/authtypes/` are the canonical examples. (`QueryEnvelope` in querybuildertypes uses `type` as the discriminator key for historical reasons; new envelopes use `kind`.)
### The envelope goes at the point of variance, not the resource root
Put the envelope on the field that actually varies. The resource root is almost never a sum type — an auth domain always has a `name`, `enabled`, and `roleMapping` regardless of provider; only its provider configuration varies, so the envelope is the `config` field:
```json
{ "name": "signoz.io", "enabled": true, "config": { "kind": "saml", "spec": { "..." : "..." } }, "roleMapping": null }
```
Hoisting `kind`/`spec` to the root would turn the whole resource into a `oneOf`: every flavor (`PostableX`, `UpdatableX`, `GettableX`) then needs one variant schema per kind, each repeating the common fields; every new common field has to be added to all of them; and generated clients get unions of large objects instead of one small union that narrows on `config.kind`. A root-level `kind` also collides with the resource-model meaning of the word — in the Kubernetes/Perses model, root `kind` answers "what resource is this" (`Dashboard`), never "which flavor of config does it hold".
The other domains already follow this placement:
- **Rules** — plain root; envelopes on the varying fields: `thresholds: {kind, spec}` and `evaluation: {kind, spec}`.
- **Dashboards** — Perses resource model: metadata at the root plus one typed `spec`; the unions sit deep inside, at each panel/query/variable plugin (`{kind, spec}` in `perses_plugin_wrappers.go`).
- **Saved views** — root `{schemaVersion, spec}`, where `spec` is a *versioning* envelope holding one fixed type, not a union; the unions are inside it (`spec.queries: [{type, spec}]`). Same word, different job — a versioned body is not a discriminated union.
### Why this tagging style
Of the union encodings in common use, the envelope is the *adjacently tagged* one — tag and payload side by side — as used by the Kubernetes resource model, Perses plugins, CloudFormation (`Type` + `Properties`), and Grafana provisioning (`type` + `settings`). Variant payloads stay collision-free, and each kind maps to a named wrapper schema that carries the discriminator, which is exactly what OpenAPI generators need. The alternatives lose on those points: *internally tagged* (`{"type": "saml", ...fields flattened}` — Stripe, GitHub webhooks) mixes common and variant fields, admits cross-variant key collisions, and forces every variant schema to redeclare the discriminator; *sibling optional fields* (`{"type": "saml", "samlConfig": {}, "oidcConfig": {}}` — classic Kubernetes `VolumeSource`, and the pre-envelope auth domain) is the anti-pattern the first rule below exists to prevent.
The rules that make the envelope work:
- **Never model variants as sibling fields.** A struct with `SAML *SamlConfig`, `Google *GoogleConfig`, `OIDC *OIDCConfig` next to a discriminator cannot be expressed as an OpenAPI discriminated union, forces nilability checks on every consumer, and silently admits contradictory payloads (kind=saml with a google config). The chosen variant *is* the payload.
- **The envelope owns `UnmarshalJSON`.** Decode `kind` first, then switch on it to decode and validate the matching concrete type into `Spec`. Unknown kinds and missing specs are rejected at the boundary:
```go
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
// ... unmarshal raw, decode raw["kind"] ...
switch kind {
case AuthNProviderSAML:
spec := SamlConfig{}
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return err
}
typ.Spec = spec
// ... one case per kind, default rejects ...
}
typ.Kind = kind
return nil
}
```
- **Consumers type-assert on `Spec`** (`config.Spec.(SamlConfig)`) after switching on `Kind`. If assertion sites multiply, add typed accessors on the envelope (see `EvaluationEnvelope.GetEvaluation()`).
- **OpenAPI needs one unexported variant struct per kind** (`authDomainConfigSAML{Kind; Spec SamlConfig}`), exposed via `JSONSchemaOneOf()` and mapped via `PrepareJSONSchema` with the `x-signoz-discriminator` extension. The schema mechanics are covered in [handler.md](handler.md#oneof-with-a-discriminator).
- **A persisted legacy shape stays in a `StorableX`.** If rows were written before the envelope existed, keep the old JSON shape in a storable type (`StorableAuthDomainConfig` keeps `ssoType` + sibling configs) and convert to/from the envelope at the type boundary — the data layer never changes shape retroactively.
## Conventions that tie the flavors together
@@ -139,6 +208,8 @@ Both are optional. Do not introduce them if `PostableX` already covers the case.
- Every domain package defines the core type `X`. Only `X` is mandatory.
- Add `PostableX` / `GettableX` / `UpdatableX` / `StorableX` one at a time, only when the shape actually diverges from `X`.
- Model sum types as a `{kind, spec}` envelope with a validating `UnmarshalJSON` — never as sibling variant fields next to a discriminator.
- The envelope goes on the field that varies, never at the resource root — common fields stay on the resource, outside the union.
- Domain logic lives on `X`, not on the flavor types.
- Conversions can be a `New<Output>From<Input>` constructor or a receiver-style `ToY()` method — pick whichever reads best at the call site.
- Use a type alias when two shapes are truly identical.

View File

@@ -53,7 +53,7 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
@@ -106,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
if claims == nil && authDomain.StorableAuthDomainConfig().OIDC.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
emailClaim, ok := claims[authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -123,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
}
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
if !authDomain.StorableAuthDomainConfig().OIDC.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
@@ -135,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if nameClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if groupsClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -161,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if roleClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -177,11 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
if authDomain.StorableAuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.StorableAuthDomainConfig().OIDC.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
oidcProvider, err := oidc.NewProvider(ctx, authDomain.StorableAuthDomainConfig().OIDC.Issuer)
if err != nil {
return nil, nil, err
}
@@ -189,13 +189,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
if authDomain.StorableAuthDomainConfig().RoleMapping != nil && len(authDomain.StorableAuthDomainConfig().RoleMapping.GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
ClientID: authDomain.StorableAuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.StorableAuthDomainConfig().OIDC.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -212,7 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
}
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.StorableAuthDomainConfig().OIDC.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())

View File

@@ -40,7 +40,7 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
@@ -101,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
if groupAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -142,11 +142,11 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
IdentityProviderSSOURL: authDomain.StorableAuthDomainConfig().SAML.Location,
IdentityProviderIssuer: authDomain.StorableAuthDomainConfig().SAML.EntityID,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !authDomain.StorableAuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
@@ -159,15 +159,15 @@ func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509
}
var certBytes []byte
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if strings.Contains(authDomain.StorableAuthDomainConfig().SAML.Certificate, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.StorableAuthDomainConfig().SAML.Certificate))
if block == nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
}
certBytes = block.Bytes
} else {
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
certData, err := base64.StdEncoding.DecodeString(authDomain.StorableAuthDomainConfig().SAML.Certificate)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}

View File

@@ -38,14 +38,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
*/
export const listAuthDomains = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListAuthDomains200>({
url: `/api/v1/domains`,
url: `/api/v2/auth_domains`,
method: 'GET',
signal,
});
};
export const getListAuthDomainsQueryKey = () => {
return [`/api/v1/domains`] as const;
return [`/api/v2/auth_domains`] as const;
};
export const getListAuthDomainsQueryOptions = <
@@ -125,7 +125,7 @@ export const createAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v1/domains`,
url: `/api/v2/auth_domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableAuthDomainDTO,
@@ -208,7 +208,7 @@ export const deleteAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'DELETE',
signal,
});
@@ -287,7 +287,7 @@ export const getAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'GET',
signal,
});
@@ -296,7 +296,7 @@ export const getAuthDomain = (
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
return [`/api/v2/auth_domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
@@ -389,7 +389,7 @@ export const updateAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
url: `/api/v2/auth_domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableAuthDomainDTO,

View File

@@ -1861,8 +1861,19 @@ export interface AuthtypesAttributeMappingDTO {
role?: string;
}
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
saml = 'saml',
}
export interface AuthtypesSamlConfigDTO {
attributeMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
certificate: string;
/**
* @type string
*/
entityId: string;
/**
* @type boolean
*/
@@ -1870,17 +1881,21 @@ export interface AuthtypesSamlConfigDTO {
/**
* @type string
*/
samlCert?: string;
/**
* @type string
*/
samlEntity?: string;
/**
* @type string
*/
samlIdp?: string;
location: string;
}
export interface AuthtypesAuthDomainConfigSAMLDTO {
/**
* @type string
* @enum saml
*/
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
spec: AuthtypesSamlConfigDTO;
}
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
google = 'google',
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
[key: string]: string;
};
@@ -1893,11 +1908,11 @@ export interface AuthtypesGoogleConfigDTO {
/**
* @type string
*/
clientId?: string;
clientId: string;
/**
* @type string
*/
clientSecret?: string;
clientSecret: string;
/**
* @type object
*/
@@ -1924,16 +1939,28 @@ export interface AuthtypesGoogleConfigDTO {
serviceAccountJson?: string;
}
export interface AuthtypesAuthDomainConfigGoogleDTO {
/**
* @type string
* @enum google
*/
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
spec: AuthtypesGoogleConfigDTO;
}
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
oidc = 'oidc',
}
export interface AuthtypesOIDCConfigDTO {
claimMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
clientId?: string;
clientId: string;
/**
* @type string
*/
clientSecret?: string;
clientSecret: string;
/**
* @type boolean
*/
@@ -1945,79 +1972,33 @@ export interface AuthtypesOIDCConfigDTO {
/**
* @type string
*/
issuer?: string;
issuer: string;
/**
* @type string
*/
issuerAlias?: string;
}
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
export interface AuthtypesAuthDomainConfigOIDCDTO {
/**
* @type string
* @enum oidc
*/
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
spec: AuthtypesOIDCConfigDTO;
}
export type AuthtypesAuthDomainConfigDTO =
| AuthtypesAuthDomainConfigSAMLDTO
| AuthtypesAuthDomainConfigGoogleDTO
| AuthtypesAuthDomainConfigOIDCDTO;
export enum AuthtypesAuthNProviderDTO {
google_auth = 'google_auth',
google = 'google',
saml = 'saml',
email_password = 'email_password',
oidc = 'oidc',
}
export type AuthtypesAuthDomainConfigDTO =
| (AuthtypesSamlConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesGoogleConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesOIDCConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
});
export interface AuthtypesAuthNProviderInfoDTO {
/**
* @type string,null
@@ -2055,6 +2036,31 @@ export interface AuthtypesDeprecatedPostableUserRoleDTO {
id: string;
}
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
* @type string
*/
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2063,6 +2069,10 @@ export interface AuthtypesGettableAuthDomainDTO {
* @format date-time
*/
createdAt?: string;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
@@ -2075,6 +2085,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
/**
* @type string
* @format date-time
@@ -2271,11 +2282,16 @@ export interface AuthtypesOrgSessionContextDTO {
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
name?: string;
name: string;
roleMapping?: AuthtypesRoleMappingDTO;
}
export interface AuthtypesPostableEmailPasswordSessionDTO {
@@ -2408,7 +2424,12 @@ export interface AuthtypesTransactionDTO {
}
export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
roleMapping?: AuthtypesRoleMappingDTO;
}
export interface AuthtypesUpdatableRoleDTO {
@@ -10425,42 +10446,6 @@ export type CreatePublicDashboard201 = {
export type UpdatePublicDashboardPathParameters = {
id: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDowntimeSchedulesParams = {
/**
* @type boolean,null
@@ -11159,6 +11144,42 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**

View File

@@ -16,7 +16,7 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: AuthtypesAuthNProviderDTO.google_auth,
key: AuthtypesAuthNProviderDTO.google,
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <SolidGoogle size={37} />,

View File

@@ -8,6 +8,10 @@ import {
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesAuthDomainConfigDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
@@ -41,7 +45,7 @@ function configureAuthnProvider(
switch (authnProvider) {
case 'saml':
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
case 'google_auth':
case 'google':
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
case 'oidc':
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
@@ -61,7 +65,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.config?.ssoType || '');
>((record?.config?.kind as unknown as AuthtypesAuthNProviderDTO) ?? '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -147,6 +151,33 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
};
}, [form]);
// Prepares the kind/spec config envelope for API payload
const getConfig = useCallback((): AuthtypesAuthDomainConfigDTO | undefined => {
switch (authnProvider) {
case AuthtypesAuthNProviderDTO.saml:
return {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: form.getFieldValue('samlConfig'),
};
case AuthtypesAuthNProviderDTO.google: {
const spec = getGoogleAuthConfig();
return spec
? {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec,
}
: undefined;
}
case AuthtypesAuthNProviderDTO.oidc:
return {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: form.getFieldValue('oidcConfig'),
};
default:
return undefined;
}
}, [authnProvider, form, getGoogleAuthConfig]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
@@ -159,24 +190,21 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const config = getConfig();
const roleMapping = getRoleMapping();
if (!config) {
return;
}
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: true,
config,
roleMapping,
},
},
{
@@ -196,14 +224,9 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: form.getFieldValue('enabled'),
config,
roleMapping,
},
},
{
@@ -219,7 +242,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getConfig,
getRoleMapping,
handleError,
isCreate,
@@ -245,8 +268,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
name="auth-domain"
initialValues={defaultTo(prepareInitialValues(record), {
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
})}
form={form}
layout="vertical"

View File

@@ -1,4 +1,8 @@
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import {
convertDomainMappingsToList,
@@ -82,8 +86,7 @@ describe('prepareInitialValues', () => {
it('returns empty defaults when no record is provided', () => {
expect(prepareInitialValues(undefined)).toStrictEqual({
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
});
});
@@ -91,15 +94,20 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'CERT',
},
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
@@ -112,10 +120,10 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'id',
clientSecret: 'secret',
domainToAdminEmail: { 'example.com': 'admin@example.com' },
@@ -132,11 +140,16 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.example.com',
clientId: 'id',
clientSecret: 'secret',
},
},
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);

View File

@@ -1,4 +1,7 @@
import {
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
@@ -9,8 +12,7 @@ import {
// Form values interface for internal use (includes array-based fields for UI)
export interface FormValues {
name?: string;
ssoEnabled?: boolean;
ssoType?: string;
enabled?: boolean;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
@@ -107,31 +109,36 @@ export function prepareInitialValues(
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
};
}
const config = record.config ?? {};
const { config } = record;
return {
name: record.name,
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
enabled: record.enabled,
samlConfig:
config?.kind === AuthtypesAuthDomainConfigSAMLDTOKind.saml
? config.spec
: undefined,
oidcConfig:
config?.kind === AuthtypesAuthDomainConfigOIDCDTOKind.oidc
? config.spec
: undefined,
googleAuthConfig:
config?.kind === AuthtypesAuthDomainConfigGoogleDTOKind.google
? {
...config.spec,
domainToAdminEmailList: convertDomainMappingsToList(
config.spec.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
? {
...config.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
config.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: config.roleMapping
? {
...config.roleMapping,
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
config.roleMapping.groupMappings,
record.roleMapping.groupMappings,
),
}
: undefined,

View File

@@ -76,7 +76,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
name={['samlConfig', 'location']}
className="authn-provider__form-item"
rules={[
{
@@ -98,7 +98,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
name={['samlConfig', 'entityId']}
className="authn-provider__form-item"
rules={[
{
@@ -120,7 +120,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
name={['samlConfig', 'certificate']}
className="authn-provider__form-item"
rules={[
{

View File

@@ -31,7 +31,7 @@ function SSOEnforcementToggle({
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
if (!record.id || !record.config) {
return;
}
@@ -41,14 +41,9 @@ function SSOEnforcementToggle({
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
enabled: checked,
config: record.config,
roleMapping: record.roleMapping,
},
},
{

View File

@@ -58,7 +58,7 @@ describe('AuthDomain', () => {
});
});
it('reflects ssoEnabled state from nested config in each row toggle', async () => {
it('reflects the enabled state in each row toggle', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
@@ -68,9 +68,9 @@ describe('AuthDomain', () => {
render(<AuthDomain />);
// mockDomainsListResponse rows:
// [0] signoz.io → config.ssoEnabled: true
// [1] example.com → config.ssoEnabled: false
// [2] corp.io → config.ssoEnabled: true
// [0] signoz.io → enabled: true
// [1] example.com → enabled: false
// [2] corp.io → enabled: true
const switches = await screen.findAllByRole('switch');
expect(switches).toHaveLength(3);
expect(switches[0]).toBeChecked();

View File

@@ -112,9 +112,7 @@ describe('CreateEdit — save payload correctness', () => {
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
roleMapping: expect.objectContaining({ groupMappings: {} }),
}),
roleMapping: expect.objectContaining({ groupMappings: {} }),
});
});
@@ -161,7 +159,7 @@ describe('CreateEdit — save payload correctness', () => {
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
googleAuthConfig: expect.objectContaining({
spec: expect.objectContaining({
domainToAdminEmail: {},
}),
}),

View File

@@ -188,8 +188,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
@@ -221,8 +221,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
@@ -249,7 +249,7 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
});
it('loads a stored role mapping by role name and round-trips it on save', async () => {
@@ -280,8 +280,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -1,6 +1,9 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesAuthDomainConfigGoogleDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
@@ -48,11 +51,10 @@ jest.mock('@signozhq/ui/button', () => ({
type SavedPayload = {
config: {
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
kind?: string;
spec?: Record<string, unknown>;
};
roleMapping?: Record<string, unknown>;
};
async function submitForm(
@@ -81,7 +83,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
@@ -91,18 +93,20 @@ describe('CreateEdit — payload sanitization', () => {
});
it('strips workspace fields when fetchGroups is false', async () => {
const googleConfig =
mockGoogleAuthWithWorkspaceGroups.config as AuthtypesAuthDomainConfigGoogleDTO;
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
...googleConfig,
spec: {
...googleConfig.spec,
fetchGroups: false,
},
},
});
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
@@ -113,7 +117,7 @@ describe('CreateEdit — payload sanitization', () => {
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.googleAuthConfig;
const g = payload.config.spec;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
@@ -131,10 +135,10 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
const s = payload.config.spec;
expect(s?.location).toBe('https://idp.saml-attrs.com/sso');
expect(s?.entityId).toBe('urn:saml-attrs:idp');
expect(s?.certificate).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
@@ -148,7 +152,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.oidcConfig;
const o = payload.config.spec;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
@@ -168,24 +172,21 @@ describe('CreateEdit — payload sanitization', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
roleMapping: {
...mockDomainWithRoleMapping.roleMapping,
useRoleAttribute: true,
},
});
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
expect(payload.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
expect(payload.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -57,7 +57,7 @@ describe('SSOEnforcementToggle', () => {
isDefaultChecked={false}
record={{
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
enabled: false,
}}
/>,
);
@@ -95,9 +95,7 @@ describe('SSOEnforcementToggle', () => {
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
enabled: false,
}),
);
});

View File

@@ -1,22 +1,24 @@
import {
AuthtypesAuthNProviderDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v2/auth_domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v2/auth_domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
@@ -30,13 +32,13 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
enabled: false,
config: {
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'MOCK_CERTIFICATE',
},
},
authNProviderInfo: {
@@ -48,10 +50,10 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
@@ -66,22 +68,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.enterprise.com/sso',
entityId: 'urn:enterprise:idp',
certificate: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
authNProviderInfo: {
@@ -94,18 +96,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
},
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
@@ -116,10 +118,10 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
@@ -143,13 +145,13 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.saml-attrs.com/sso',
entityId: 'urn:saml-attrs:idp',
certificate: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
@@ -168,10 +170,10 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
enabled: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
@@ -218,7 +220,7 @@ export const mockUpdateSuccessResponse = {
status: 'success',
data: {
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
enabled: false,
},
};

View File

@@ -26,7 +26,7 @@ import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['google', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
@@ -121,8 +121,8 @@ function AuthDomain(): JSX.Element {
},
{
title: 'Enforce SSO',
dataIndex: ['config', 'ssoEnabled'],
key: 'ssoEnabled',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (
value: boolean,
@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
Configure {SSOType.get(record.config?.kind || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"

View File

@@ -10,7 +10,7 @@ import (
)
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
ID: "ListAuthDomains",
Tags: []string{"authdomains"},
Summary: "List all auth domains",
@@ -27,7 +27,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
ID: "CreateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Create auth domain",
@@ -44,7 +44,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
ID: "GetAuthDomain",
Tags: []string{"authdomains"},
Summary: "Get auth domain by ID",
@@ -61,7 +61,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
ID: "UpdateAuthDomain",
Tags: []string{"authdomains"},
Summary: "Update auth domain",
@@ -78,7 +78,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
ID: "DeleteAuthDomain",
Tags: []string{"authdomains"},
Summary: "Delete auth domain",

View File

@@ -59,7 +59,7 @@ func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *auth
return "", err
}
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogle {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
}
@@ -111,7 +111,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: no id_token in token response")
}
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.StorableAuthDomainConfig().Google.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", errors.Attr(err))
@@ -135,7 +135,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
}
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
if !authDomain.StorableAuthDomainConfig().Google.InsecureSkipEmailVerified {
if !claims.EmailVerified {
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", slog.String("email", claims.Email))
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
@@ -148,14 +148,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
var groups []string
if authDomain.AuthDomainConfig().Google.FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
if authDomain.StorableAuthDomainConfig().Google.FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.StorableAuthDomainConfig().Google)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", errors.Attr(err))
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
}
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
allowedGroups := authDomain.StorableAuthDomainConfig().Google.AllowedGroups
if len(allowedGroups) > 0 {
groups = filterGroups(groups, allowedGroups)
if len(groups) == 0 {
@@ -175,8 +175,8 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
return &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
ClientID: authDomain.StorableAuthDomainConfig().Google.ClientID,
ClientSecret: authDomain.StorableAuthDomainConfig().Google.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{

View File

@@ -26,7 +26,7 @@ func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID,
referencedBy := make([]string, 0)
for _, domain := range domains {
for _, mappedRole := range domain.AuthDomainConfig().RoleMapping.RoleNames() {
for _, mappedRole := range domain.StorableAuthDomainConfig().RoleMapping.RoleNames() {
if mappedRole == roleName {
referencedBy = append(referencedBy, domain.StorableAuthDomain().Name)
break

View File

@@ -38,7 +38,7 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
return
}
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
authDomain, err := authtypes.NewAuthDomainFromPostableAuthDomain(body, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
@@ -99,7 +99,13 @@ func (handler *handler) Get(rw http.ResponseWriter, req *http.Request) {
return
}
render.Success(rw, http.StatusOK, authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain)))
gettableAuthDomain, err := authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, gettableAuthDomain)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
@@ -120,7 +126,13 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
authDomains := make([]*authtypes.GettableAuthDomain, len(domains))
for i, domain := range domains {
authDomains[i] = authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
gettableAuthDomain, err := authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
if err != nil {
render.Error(rw, err)
return
}
authDomains[i] = gettableAuthDomain
}
render.Success(rw, http.StatusOK, authDomains)
@@ -154,7 +166,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = authDomain.Update(&body.Config)
err = authDomain.Update(body)
if err != nil {
render.Error(rw, err)
return

View File

@@ -33,7 +33,7 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
}
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
if callbackAuthN, ok := module.authNs[domain.StorableAuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
return callbackAuthN.ProviderInfo(ctx, domain)
}
return &authtypes.AuthNProviderInfo{}
@@ -72,7 +72,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats := make(map[string]any)
for _, domain := range domains {
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
key := "authdomain." + domain.StorableAuthDomainConfig().AuthNProvider.StringValue() + ".count"
if value, ok := stats[key]; ok {
stats[key] = value.(int64) + 1
} else {
@@ -86,7 +86,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
}
func (module *module) validateRoleMapping(ctx context.Context, domain *authtypes.AuthDomain) error {
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
roleNames := domain.StorableAuthDomainConfig().RoleMapping.RoleNames()
if len(roleNames) == 0 {
return nil
}

View File

@@ -75,7 +75,7 @@ func (handler *handler) CreateSessionByGoogleCallback(rw http.ResponseWriter, re
values := req.URL.Query()
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderGoogleAuth, values)
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderGoogle, values)
if err != nil {
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
return

View File

@@ -152,7 +152,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", err
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
roleMapping := authDomain.StorableAuthDomainConfig().RoleMapping
roleAttributeExists := false
if roleMapping != nil && roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
@@ -215,11 +215,11 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
}
if !authDomain.AuthDomainConfig().SSOEnabled {
if !authDomain.StorableAuthDomainConfig().SSOEnabled {
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
}
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().AuthNProvider, module.authNs)
provider, err := getProvider[authn.CallbackAuthN](authDomain.StorableAuthDomainConfig().AuthNProvider, module.authNs)
if err != nil {
return nil, err
}
@@ -233,7 +233,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
return nil, err
}
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.StorableAuthDomainConfig().AuthNProvider, loginURL), nil
}
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {

View File

@@ -22,6 +22,6 @@ func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, s
return map[authtypes.AuthNProvider]authn.AuthN{
authtypes.AuthNProviderEmailPassword: emailPasswordAuthN,
authtypes.AuthNProviderGoogleAuth: googleCallbackAuthN,
authtypes.AuthNProviderGoogle: googleCallbackAuthN,
}, nil
}

View File

@@ -16,7 +16,7 @@ var (
)
var (
AuthNProviderGoogleAuth = AuthNProvider{valuer.NewString("google_auth")}
AuthNProviderGoogle = AuthNProvider{valuer.NewString("google")}
AuthNProviderSAML = AuthNProvider{valuer.NewString("saml")}
AuthNProviderEmailPassword = AuthNProvider{valuer.NewString("email_password")}
AuthNProviderOIDC = AuthNProvider{valuer.NewString("oidc")}
@@ -158,7 +158,7 @@ func (typ *Identity) ToClaims() Claims {
func (AuthNProvider) Enum() []any {
return []any{
AuthNProviderGoogleAuth,
AuthNProviderGoogle,
AuthNProviderSAML,
AuthNProviderEmailPassword,
AuthNProviderOIDC,

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
"github.com/uptrace/bun"
)
@@ -30,7 +31,9 @@ var (
type GettableAuthDomain struct {
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -39,12 +42,16 @@ type AuthNProviderInfo struct {
}
type PostableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type UpdatableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type StorableAuthDomain struct {
@@ -57,36 +64,96 @@ type StorableAuthDomain struct {
types.TimeAuditable
}
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
// for a discriminated union. OpenAPI's discriminator requires every oneOf
// branch to be a $ref to a named component and a sibling property whose value
// selects the variant. ssoType is already discriminator-shaped, but the
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
// oidcConfig) instead of being the payload itself, so no discriminator can
// be attached. Refactor AuthDomainConfig into an envelope (see
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
// the payload and ssoType is the discriminator.
type AuthDomainConfig struct {
SSOEnabled bool `json:"ssoEnabled"`
AuthNProvider AuthNProvider `json:"ssoType"`
SAML *SamlConfig `json:"samlConfig"`
Google *GoogleConfig `json:"googleAuthConfig"`
OIDC *OIDCConfig `json:"oidcConfig"`
RoleMapping *RoleMapping `json:"roleMapping"`
Kind AuthNProvider `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// authDomainConfigSAML is the OpenAPI schema for an AuthDomainConfig with kind=saml.
type authDomainConfigSAML struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec SamlConfig `json:"spec" description:"The saml configuration." required:"true"`
}
// authDomainConfigGoogle is the OpenAPI schema for an AuthDomainConfig with kind=google.
type authDomainConfigGoogle struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec GoogleConfig `json:"spec" description:"The google auth configuration." required:"true"`
}
// authDomainConfigOIDC is the OpenAPI schema for an AuthDomainConfig with kind=oidc.
type authDomainConfigOIDC struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec OIDCConfig `json:"spec" description:"The oidc configuration." required:"true"`
}
var (
_ jsonschema.OneOfExposer = AuthDomainConfig{}
_ jsonschema.Preparer = AuthDomainConfig{}
)
// JSONSchemaOneOf returns the oneOf variants for the AuthDomainConfig discriminated union.
// Each variant represents a different authn provider kind with its corresponding spec schema.
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
authDomainConfigSAML{},
authDomainConfigGoogle{},
authDomainConfigOIDC{},
}
}
// PrepareJSONSchema marks the schema with x-signoz-discriminator;
// signoz.attachDiscriminators promotes it to a real OpenAPI 3
// discriminator after reflection.
func (AuthDomainConfig) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": map[string]string{
AuthNProviderSAML.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigSAML",
AuthNProviderGoogle.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigGoogle",
AuthNProviderOIDC.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigOIDC",
},
}
return nil
}
// StorableAuthDomainConfig is the JSON document persisted in StorableAuthDomain.Data.
// Its shape (and the shapes it nests) must stay backward compatible with existing rows.
type StorableAuthDomainConfig struct {
SSOEnabled bool `json:"ssoEnabled"`
AuthNProvider AuthNProvider `json:"ssoType"`
SAML *StorableSamlConfig `json:"samlConfig"`
Google *GoogleConfig `json:"googleAuthConfig"`
OIDC *OIDCConfig `json:"oidcConfig"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
// storableAuthNProviderGoogle is the value persisted in ssoType for google domains,
// kept for compatibility with rows written before the provider was renamed.
var storableAuthNProviderGoogle = AuthNProvider{valuer.NewString("google_auth")}
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
}
func NewAuthDomainFromConfig(name string, config *AuthDomainConfig, orgID valuer.UUID) (*AuthDomain, error) {
data, err := json.Marshal(config)
func NewAuthDomainFromPostableAuthDomain(postableAuthDomain *PostableAuthDomain, orgID valuer.UUID) (*AuthDomain, error) {
storableAuthDomainConfig, err := newStorableAuthDomainConfig(postableAuthDomain.Enabled, postableAuthDomain.Config, postableAuthDomain.RoleMapping)
if err != nil {
return nil, err
}
return NewAuthDomain(name, string(data), orgID)
data, err := json.Marshal(storableAuthDomainConfig)
if err != nil {
return nil, err
}
return NewAuthDomain(postableAuthDomain.Name, string(data), orgID)
}
func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, error) {
@@ -107,22 +174,85 @@ func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, er
}
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
authDomainConfig := new(AuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), authDomainConfig); err != nil {
storableAuthDomainConfig := new(StorableAuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), storableAuthDomainConfig); err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: storableAuthDomain,
authDomainConfig: authDomainConfig,
storableAuthDomain: storableAuthDomain,
storableAuthDomainConfig: storableAuthDomainConfig,
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) (*GettableAuthDomain, error) {
config, err := newAuthDomainConfigFromStorableAuthDomainConfig(authDomain.StorableAuthDomainConfig())
if err != nil {
return nil, err
}
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
Config: *authDomain.AuthDomainConfig(),
Enabled: authDomain.StorableAuthDomainConfig().SSOEnabled,
Config: config,
RoleMapping: authDomain.StorableAuthDomainConfig().RoleMapping,
AuthNProviderInfo: authNProviderInfo,
}, nil
}
func newStorableAuthDomainConfig(enabled bool, config AuthDomainConfig, roleMapping *RoleMapping) (*StorableAuthDomainConfig, error) {
storableAuthDomainConfig := &StorableAuthDomainConfig{
SSOEnabled: enabled,
AuthNProvider: config.Kind,
RoleMapping: roleMapping,
}
switch config.Kind {
case AuthNProviderSAML:
spec, ok := config.Spec.(SamlConfig)
if !ok {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
}
samlConfig := StorableSamlConfig(spec)
storableAuthDomainConfig.SAML = &samlConfig
case AuthNProviderGoogle:
spec, ok := config.Spec.(GoogleConfig)
if !ok {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
}
storableAuthDomainConfig.Google = &spec
case AuthNProviderOIDC:
spec, ok := config.Spec.(OIDCConfig)
if !ok {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
}
storableAuthDomainConfig.OIDC = &spec
default:
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", config.Kind.StringValue())
}
return storableAuthDomainConfig, nil
}
func newAuthDomainConfigFromStorableAuthDomainConfig(storableAuthDomainConfig *StorableAuthDomainConfig) (AuthDomainConfig, error) {
switch storableAuthDomainConfig.AuthNProvider {
case AuthNProviderSAML:
return AuthDomainConfig{Kind: AuthNProviderSAML, Spec: SamlConfig(*storableAuthDomainConfig.SAML)}, nil
case AuthNProviderGoogle:
return AuthDomainConfig{Kind: AuthNProviderGoogle, Spec: *storableAuthDomainConfig.Google}, nil
case AuthNProviderOIDC:
return AuthDomainConfig{Kind: AuthNProviderOIDC, Spec: *storableAuthDomainConfig.OIDC}, nil
default:
return AuthDomainConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", storableAuthDomainConfig.AuthNProvider.StringValue())
}
}
@@ -130,17 +260,22 @@ func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
return typ.storableAuthDomain
}
func (typ *AuthDomain) AuthDomainConfig() *AuthDomainConfig {
return typ.authDomainConfig
func (typ *AuthDomain) StorableAuthDomainConfig() *StorableAuthDomainConfig {
return typ.storableAuthDomainConfig
}
func (typ *AuthDomain) Update(config *AuthDomainConfig) error {
data, err := json.Marshal(config)
func (typ *AuthDomain) Update(updatableAuthDomain *UpdatableAuthDomain) error {
storableAuthDomainConfig, err := newStorableAuthDomainConfig(updatableAuthDomain.Enabled, updatableAuthDomain.Config, updatableAuthDomain.RoleMapping)
if err != nil {
return err
}
typ.authDomainConfig = config
data, err := json.Marshal(storableAuthDomainConfig)
if err != nil {
return err
}
typ.storableAuthDomainConfig = storableAuthDomainConfig
typ.storableAuthDomain.Data = string(data)
typ.storableAuthDomain.UpdatedAt = time.Now()
return nil
@@ -163,15 +298,84 @@ func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
type Alias AuthDomainConfig
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal auth domain config")
}
kindData, ok := raw["kind"]
if !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "kind is required")
}
var kind AuthNProvider
if err := json.Unmarshal(kindData, &kind); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal kind")
}
specData, ok := raw["spec"]
if !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "spec is required")
}
switch kind {
case AuthNProviderSAML:
spec := SamlConfig{}
if err := json.Unmarshal(specData, &spec); err != nil {
return err
}
typ.Spec = spec
case AuthNProviderGoogle:
spec := GoogleConfig{}
if err := json.Unmarshal(specData, &spec); err != nil {
return err
}
typ.Spec = spec
case AuthNProviderOIDC:
spec := OIDCConfig{}
if err := json.Unmarshal(specData, &spec); err != nil {
return err
}
typ.Spec = spec
default:
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", kind.StringValue())
}
typ.Kind = kind
return nil
}
func (typ StorableAuthDomainConfig) MarshalJSON() ([]byte, error) {
type Alias StorableAuthDomainConfig
temp := Alias(typ)
if temp.AuthNProvider == AuthNProviderGoogle {
temp.AuthNProvider = storableAuthNProviderGoogle
}
return json.Marshal(temp)
}
func (typ *StorableAuthDomainConfig) UnmarshalJSON(data []byte) error {
type Alias StorableAuthDomainConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.AuthNProvider == storableAuthNProviderGoogle {
temp.AuthNProvider = AuthNProviderGoogle
}
switch temp.AuthNProvider {
case AuthNProviderGoogleAuth:
case AuthNProviderGoogle:
if temp.Google == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
}
@@ -190,17 +394,8 @@ func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
}
*typ = AuthDomainConfig(temp)
*typ = StorableAuthDomainConfig(temp)
return nil
}
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
SamlConfig{},
GoogleConfig{},
OIDCConfig{},
}
}
type AuthDomainStore interface {

View File

@@ -12,10 +12,10 @@ const wildCardDomain = "*"
type GoogleConfig struct {
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
ClientID string `json:"clientId"`
ClientID string `json:"clientId" required:"true"`
// It is the application's secret.
ClientSecret string `json:"clientSecret"`
ClientSecret string `json:"clientSecret" required:"true"`
// What is the meaning of this? Should we remove this?
RedirectURI string `json:"redirectURI"`

View File

@@ -8,7 +8,7 @@ import (
type OIDCConfig struct {
// It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".
Issuer string `json:"issuer"`
Issuer string `json:"issuer" required:"true"`
// Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail
// This provides a way to override the Issuer url from the .well-known/openid-configuration issuer
@@ -16,10 +16,10 @@ type OIDCConfig struct {
IssuerAlias string `json:"issuerAlias"`
// It is the application's ID.
ClientID string `json:"clientId"`
ClientID string `json:"clientId" required:"true"`
// It is the application's secret.
ClientSecret string `json:"clientSecret"`
ClientSecret string `json:"clientSecret" required:"true"`
// Mapping of claims to the corresponding fields in the token.
ClaimMapping AttributeMapping `json:"claimMapping"`

View File

@@ -7,14 +7,14 @@ import (
)
type SamlConfig struct {
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">
SamlEntity string `json:"samlEntity"`
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entityId}">
EntityID string `json:"entityId" required:"true"`
// The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>
SamlIdp string `json:"samlIdp"`
// The SSO endpoint of the SAML identity provider. It can typically be found in the Location attribute of the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{location}"/>
Location string `json:"location" required:"true"`
// The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>
SamlCert string `json:"samlCert"`
// The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{certificate}</ds:X509Certificate></ds:X509Certificate>
Certificate string `json:"certificate" required:"true"`
// Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
// For providers like jumpcloud, this should be set to true.
@@ -25,6 +25,17 @@ type SamlConfig struct {
AttributeMapping AttributeMapping `json:"attributeMapping"`
}
// StorableSamlConfig is SamlConfig in its persisted shape. It differs from SamlConfig
// only in JSON keys, which are kept for compatibility with rows written before the
// keys were renamed.
type StorableSamlConfig struct {
EntityID string `json:"samlEntity"`
Location string `json:"samlIdp"`
Certificate string `json:"samlCert"`
InsecureSkipAuthNRequestsSigned bool `json:"insecureSkipAuthNRequestsSigned"`
AttributeMapping AttributeMapping `json:"attributeMapping"`
}
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
type Alias SamlConfig
@@ -33,24 +44,51 @@ func (config *SamlConfig) UnmarshalJSON(data []byte) error {
return err
}
if temp.SamlEntity == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlEntity is required")
samlConfig := SamlConfig(temp)
if err := samlConfig.validate(); err != nil {
return err
}
if temp.SamlIdp == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlIdp is required")
*config = samlConfig
return nil
}
func (config *StorableSamlConfig) UnmarshalJSON(data []byte) error {
type Alias StorableSamlConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.SamlCert == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
samlConfig := SamlConfig(StorableSamlConfig(temp))
if err := samlConfig.validate(); err != nil {
return err
}
if temp.AttributeMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
*config = StorableSamlConfig(samlConfig)
return nil
}
// validate also assigns the default attribute mapping when none is present.
func (config *SamlConfig) validate() error {
if config.EntityID == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "entityId is required")
}
if config.Location == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "location is required")
}
if config.Certificate == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "certificate is required")
}
if config.AttributeMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &config.AttributeMapping); err != nil {
return err
}
}
*config = SamlConfig(temp)
return nil
}

View File

@@ -24,6 +24,7 @@ pytest_plugins = [
"fixtures.browser",
"fixtures.keycloak",
"fixtures.idp",
"fixtures.googleidp",
"fixtures.notification_channel",
"fixtures.maildev",
"fixtures.alerts",

220
tests/fixtures/googleidp.py vendored Normal file
View File

@@ -0,0 +1,220 @@
import base64
import json
import time
from collections.abc import Callable
from http import HTTPStatus
from urllib.parse import urlparse
import docker
import docker.errors
import pytest
import requests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from testcontainers.core.container import DockerContainer, Network
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import reuse, tls, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
# The google callback authn hardcodes Google's issuer, so the mock must be
# reachable as accounts.google.com over TLS from the signoz container: the
# wiremock container joins the network under that alias and serves HTTPS on 443
# with a certificate issued by the integration CA that signoz trusts.
ISSUER = "https://accounts.google.com"
ISSUER_HOST = "accounts.google.com"
def perform_google_login(
signoz: types.SigNoz,
googleidp: types.TestContainerDocker,
get_session_context: Callable[[str], dict],
email: str,
) -> str:
"""Drive the google login flow for email and return the final redirect URL.
The authorize URL points at https://accounts.google.com (resolvable only
inside the docker network), so it is rewritten to the mock's host-mapped
port, mirroring how the oidc suite rewrites keycloak URLs.
"""
session_context = get_session_context(email)
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
assert url.startswith(f"{ISSUER}/")
parsed_url = urlparse(url)
authorize_url = googleidp.host_configs["8080"].get(f"{parsed_url.path}?{parsed_url.query}")
response = requests.get(authorize_url, allow_redirects=False, timeout=5)
assert response.status_code == HTTPStatus.FOUND
callback_url = response.headers["Location"]
assert "/api/v1/complete/google" in callback_url
response = requests.get(callback_url, allow_redirects=False, timeout=30)
assert response.status_code == HTTPStatus.SEE_OTHER
return response.headers["Location"]
def google_oidc_mappings(email: str, name: str, hd: str, audience: str, email_verified: bool = True) -> list[Mapping]:
"""Wiremock mappings for one Google OIDC login: discovery, an auto-approving
authorize redirect, a token response with an RS256 id_token for the given
identity, and the JWKS the signoz container verifies it against. The signing
key is ephemeral — the token and JWKS stubs are always installed together."""
signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": audience,
"sub": f"google-oauth2|{email}",
"email": email,
"email_verified": email_verified,
"name": name,
"hd": hd,
"iat": now,
"exp": now + 3600,
}
signing_input = base64url(json.dumps({"alg": "RS256", "kid": "googleidp-integration", "typ": "JWT"}).encode()) + "." + base64url(json.dumps(claims).encode())
signature = signing_key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
id_token = signing_input + "." + base64url(signature)
public_numbers = signing_key.public_key().public_numbers()
return [
Mapping(
request=MappingRequest(method=HttpMethods.GET, url_path="/.well-known/openid-configuration"),
response=MappingResponse(
status=200,
json_body={
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/o/oauth2/v2/auth",
"token_endpoint": f"{ISSUER}/token",
"jwks_uri": f"{ISSUER}/jwks",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
},
),
),
Mapping(
request=MappingRequest(method=HttpMethods.GET, url_path="/o/oauth2/v2/auth"),
response=MappingResponse(
status=302,
headers={
# Triple-stache: redirect_uri and state are URLs; handlebars
# would otherwise HTML-escape their special characters.
"Location": "{{{request.query.redirect_uri}}}?code=integration-test-code&state={{{request.query.state}}}",
},
transformers=["response-template"],
),
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path="/token"),
response=MappingResponse(
status=200,
json_body={
"access_token": "integration-test-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": id_token,
},
),
),
Mapping(
request=MappingRequest(method=HttpMethods.GET, url_path="/jwks"),
response=MappingResponse(
status=200,
json_body={
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "googleidp-integration",
"n": base64url(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, "big")),
"e": base64url(public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, "big")),
}
]
},
),
),
]
@pytest.fixture(name="googleidp", scope="package")
def googleidp(
network: Network,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerDocker:
"""Wiremock impersonating Google's OIDC provider. Stubs are installed per
test via make_http_mocks with google_oidc_mappings; port 8080 serves the
admin API and the authorize redirect to the test process."""
def create() -> types.TestContainerDocker:
keystore_dir = tls.ensure_server_keystore(pytestconfig, ISSUER_HOST)
container = DockerContainer("wiremock/wiremock:2.35.1-1")
container.with_command(f"--https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {tls.KEYSTORE_PASSWORD} --local-response-templating")
container.with_volume_mapping(str(keystore_dir), "/certs", "ro")
container.with_exposed_ports(8080)
container.with_network(network)
container.with_network_aliases(ISSUER_HOST)
container.start()
host = container.get_container_host_ip()
host_port = container.get_exposed_port(8080)
for attempt in range(20):
try:
response = requests.get(f"http://{host}:{host_port}/__admin/mappings", timeout=2)
if response.status_code == HTTPStatus.OK:
break
except Exception as e: # pylint: disable=broad-exception-caught
logger.info("googleidp attempt %d: %s", attempt + 1, e)
time.sleep(1)
else:
raise TimeoutError("googleidp container did not become ready")
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"8080": types.TestContainerUrlConfig("http", host, host_port),
},
container_configs={
"443": types.TestContainerUrlConfig("https", ISSUER_HOST, 443),
},
)
def delete(container: types.TestContainerDocker) -> None:
client = docker.from_env()
try:
client.containers.get(container_id=container.id).stop()
client.containers.get(container_id=container.id).remove(v=True)
except docker.errors.NotFound:
logger.info("googleidp container %s already gone", container.id)
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
return reuse.wrap(
request,
pytestconfig,
"googleidp",
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
restore,
)

View File

@@ -624,7 +624,7 @@ def _ensure_groups_client_scope(client: KeycloakAdmin) -> None:
def get_oidc_domain(signoz: types.SigNoz, admin_token: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -665,7 +665,7 @@ def perform_oidc_login(
def get_saml_domain(signoz: types.SigNoz, admin_token: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -11,7 +11,7 @@ import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from fixtures import reuse, types
from fixtures import reuse, tls, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -115,6 +115,13 @@ def create_signoz(
"rw",
)
# Trust the integration CA so tests can stand in for real TLS hosts
# (e.g. the fake accounts.google.com); SSL_CERT_FILE replaces the Go
# root pool, which is fine here since every other mocked upstream is
# plain http.
container.with_env("SSL_CERT_FILE", tls.CA_CONTAINER_PATH)
container.with_volume_mapping(str(tls.ensure_ca(pytestconfig) / "ca.pem"), tls.CA_CONTAINER_PATH, "ro")
container.start()
def ready(container: DockerContainer) -> None:

90
tests/fixtures/tls.py vendored Normal file
View File

@@ -0,0 +1,90 @@
import datetime
from pathlib import Path
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import NameOID
# The integration CA is mounted into every signoz container and trusted via
# SSL_CERT_FILE (which replaces the Go root pool), so mocks that must be
# reached over TLS under a real hostname (e.g. accounts.google.com) can serve
# certificates issued by it. Material is persisted under .pytest_cache so
# --reuse runs keep the chain the running containers already trust.
CA_CONTAINER_PATH = "/etc/signoz-integration/ca.pem"
KEYSTORE_PASSWORD = "password" # noqa: S105
def ensure_ca(pytestconfig: pytest.Config) -> Path:
"""Directory holding the integration CA (ca.pem + ca.key), created once."""
ca_dir = pytestconfig.cache.mkdir("tls")
if (ca_dir / "ca.pem").exists() and (ca_dir / "ca.key").exists():
return ca_dir
now = datetime.datetime.now(datetime.UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "signoz-integration-ca")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
(ca_dir / "ca.pem").write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
(ca_dir / "ca.key").write_bytes(
ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
return ca_dir
def ensure_server_keystore(pytestconfig: pytest.Config, hostname: str) -> Path:
"""Directory holding a PKCS12 keystore (keystore.p12, password
KEYSTORE_PASSWORD) with a certificate for hostname issued by the
integration CA."""
ca_dir = ensure_ca(pytestconfig)
keystore_dir = pytestconfig.cache.mkdir(f"tls-{hostname}")
if (keystore_dir / "keystore.p12").exists():
return keystore_dir
ca_cert = x509.load_pem_x509_certificate((ca_dir / "ca.pem").read_bytes())
ca_key = serialization.load_pem_private_key((ca_dir / "ca.key").read_bytes(), password=None)
now = datetime.datetime.now(datetime.UTC)
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
leaf_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
.issuer_name(ca_cert.subject)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.sign(ca_key, hashes.SHA256())
)
(keystore_dir / "keystore.p12").write_bytes(
pkcs12.serialize_key_and_certificates(
name=hostname.encode(),
key=leaf_key,
cert=leaf_cert,
cas=[ca_cert],
encryption_algorithm=serialization.BestAvailableEncryption(KEYSTORE_PASSWORD.encode()),
)
)
return keystore_dir

View File

@@ -50,13 +50,13 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
signoz.self.host_configs["8080"].get("/signoz/api/v2/auth_domains"),
json={
"name": "oidc.basepath.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"kind": "oidc",
"spec": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp

View File

@@ -48,16 +48,16 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
signoz.self.host_configs["8080"].get("/signoz/api/v2/auth_domains"),
json={
"name": "saml.basepath.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
},
},
},

View File

@@ -1,6 +1,7 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
@@ -14,27 +15,33 @@ def test_create_and_get_domain(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Get domains which should be an empty list
# Reruns against a reused stack find domains from previous runs; drop them
# all so the suite starts from a clean slate.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = response.json()["data"]
assert len(data) == 0
for domain in response.json()["data"]:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Create a domain with google auth config
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain-google.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "google_auth",
"googleAuthConfig": {
"kind": "google",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"redirectURI": "redirect-uri",
@@ -49,16 +56,16 @@ def test_create_and_get_domain(
# Create a domain with saml config
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain-saml.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
},
},
@@ -70,7 +77,7 @@ def test_create_and_get_domain(
# List the domains
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -86,7 +93,7 @@ def test_create_and_get_domain(
"domain-google.integration.test",
"domain-saml.integration.test",
]
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
assert domain["config"]["kind"] in ["google", "saml"]
def test_create_invalid(
@@ -96,15 +103,15 @@ def test_create_invalid(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a domain with type saml and body for oidc, this should fail because oidcConfig is not allowed for saml
# Create a domain with kind saml and a spec for oidc, this should fail because the spec does not match the kind
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"oidcConfig": {
"kind": "saml",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"issuer": "issuer",
@@ -117,18 +124,34 @@ def test_create_invalid(
assert response.status_code == HTTPStatus.BAD_REQUEST
# Create a domain with a kind but no spec
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain.integration.test",
"enabled": True,
"config": {
"kind": "saml",
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# Create a domain with invalid name
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "$%^invalid",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
},
},
@@ -140,17 +163,17 @@ def test_create_invalid(
# Create a domain with no name
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
}
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
@@ -160,7 +183,7 @@ def test_create_invalid(
# Create a domain with no config
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain.integration.test",
},
@@ -180,21 +203,21 @@ def test_create_invalid_role_mapping(
# Create domain with invalid defaultRole
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "invalid-role-test.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
},
"roleMapping": {
"defaultRole": "SUPERADMIN", # Invalid role
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
},
"roleMapping": {
"defaultRole": "SUPERADMIN", # Invalid role
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
@@ -204,22 +227,22 @@ def test_create_invalid_role_mapping(
# Create domain with invalid role in groupMappings
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "invalid-group-role.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"admins": "SUPERUSER", # Invalid role
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"admins": "SUPERUSER", # Invalid role
},
},
},
@@ -231,23 +254,23 @@ def test_create_invalid_role_mapping(
# Valid role mapping should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "valid-role-mapping.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
},
},
@@ -256,3 +279,288 @@ def test_create_invalid_role_mapping(
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.parametrize(
("config", "role_mapping", "expected_config", "expected_role_mapping"),
[
pytest.param(
{
"kind": "google",
"spec": {"clientId": "client-id", "clientSecret": "client-secret"},
},
None,
{
"kind": "google",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"redirectURI": "",
"fetchGroups": False,
"insecureSkipEmailVerified": False,
},
},
None,
id="google_minimal",
),
pytest.param(
{
"kind": "google",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"redirectURI": "https://redirect.integration.test",
"fetchGroups": True,
"serviceAccountJson": '{"type": "service_account"}',
"domainToAdminEmail": {
"roundtrip.integration.test": "admin@roundtrip.integration.test",
"*": "fallback@roundtrip.integration.test",
},
"fetchTransitiveGroupMembership": True,
"allowedGroups": ["group-one", "group-two"],
"insecureSkipEmailVerified": True,
},
},
None,
{
"kind": "google",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"redirectURI": "https://redirect.integration.test",
"fetchGroups": True,
"serviceAccountJson": '{"type": "service_account"}',
"domainToAdminEmail": {
"roundtrip.integration.test": "admin@roundtrip.integration.test",
"*": "fallback@roundtrip.integration.test",
},
"fetchTransitiveGroupMembership": True,
"allowedGroups": ["group-one", "group-two"],
"insecureSkipEmailVerified": True,
},
},
None,
id="google_full",
),
pytest.param(
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
},
},
None,
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": False,
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
},
},
None,
id="saml_minimal",
),
pytest.param(
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": True,
"attributeMapping": {"email": "mail"},
},
},
None,
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": True,
"attributeMapping": {"email": "mail", "name": "name", "groups": "groups", "role": "role"},
},
},
None,
id="saml_partial_attribute_mapping",
),
pytest.param(
{
"kind": "oidc",
"spec": {
"issuer": "https://issuer.integration.test",
"clientId": "client-id",
"clientSecret": "client-secret",
},
},
None,
{
"kind": "oidc",
"spec": {
"issuer": "https://issuer.integration.test",
"issuerAlias": "",
"clientId": "client-id",
"clientSecret": "client-secret",
"claimMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
"insecureSkipEmailVerified": False,
"getUserInfo": False,
},
},
None,
id="oidc_minimal",
),
pytest.param(
{
"kind": "oidc",
"spec": {
"issuer": "https://issuer.integration.test",
"issuerAlias": "https://alias.integration.test",
"clientId": "client-id",
"clientSecret": "client-secret",
"claimMapping": {"email": "eml", "name": "nm", "groups": "grps", "role": "rl"},
"insecureSkipEmailVerified": True,
"getUserInfo": True,
},
},
None,
{
"kind": "oidc",
"spec": {
"issuer": "https://issuer.integration.test",
"issuerAlias": "https://alias.integration.test",
"clientId": "client-id",
"clientSecret": "client-secret",
"claimMapping": {"email": "eml", "name": "nm", "groups": "grps", "role": "rl"},
"insecureSkipEmailVerified": True,
"getUserInfo": True,
},
},
None,
id="oidc_full",
),
pytest.param(
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
},
},
{
"defaultRole": "EDITOR",
"groupMappings": {"platform-team": "ADMIN"},
"useRoleAttribute": False,
},
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": False,
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
},
},
{
"defaultRole": "signoz-editor",
"groupMappings": {"platform-team": "signoz-admin"},
"useRoleAttribute": False,
},
id="role_mapping_names_normalized",
),
pytest.param(
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
},
},
{"defaultRole": "VIEWER", "useRoleAttribute": True},
{
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "https://idp.integration.test/sso",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": False,
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
},
},
{"defaultRole": "signoz-viewer", "groupMappings": None, "useRoleAttribute": True},
id="role_mapping_null_group_mappings",
),
],
)
def test_domain_roundtrip( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
config: dict,
role_mapping: dict | None,
expected_config: dict,
expected_role_mapping: dict | None,
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Drop a same-named leftover so reruns against a reused stack stay green.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
for domain in response.json()["data"]:
if domain["name"] == "roundtrip.integration.test":
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "roundtrip.integration.test",
"enabled": True,
"config": config,
"roleMapping": role_mapping,
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
domain_id = response.json()["data"]["id"]
# Clients (e.g. the terraform provider) read state back with a follow-up
# GET after every write, so posted values must round-trip exactly; the
# server-side defaulting and role-name normalization pinned here are part
# of that contract.
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["name"] == "roundtrip.integration.test"
assert data["enabled"] is True
assert data["config"] == expected_config
assert data["roleMapping"] == expected_role_mapping
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT

View File

@@ -50,16 +50,16 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "saml.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
},
},
},
@@ -71,7 +71,7 @@ def test_create_auth_domain(
# Get the domains from signoz
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -174,30 +174,30 @@ def test_saml_update_domain_with_group_mappings(
# update the existing saml domain to have role mappings also
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
"attributeMapping": {
"name": "givenName",
"groups": "groups",
"role": "signoz_role",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -329,29 +329,29 @@ def test_saml_update_domain_with_use_role_claim(
settings = get_saml_settings()
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
"attributeMapping": {
"name": "displayName",
"groups": "groups",
"role": "signoz_role",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -48,13 +48,13 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "oidc.integration.test",
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"kind": "oidc",
"spec": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp
@@ -121,12 +121,12 @@ def test_oidc_update_domain_with_group_mappings(
settings = get_oidc_settings(client_id)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"kind": "oidc",
"spec": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
@@ -139,15 +139,15 @@ def test_oidc_update_domain_with_group_mappings(
"role": "signoz_role",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -278,12 +278,12 @@ def test_oidc_update_domain_with_use_role_claim(
settings = get_oidc_settings(client_id)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"kind": "oidc",
"spec": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
@@ -296,14 +296,14 @@ def test_oidc_update_domain_with_use_role_claim(
"role": "signoz_role",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -0,0 +1,205 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
assert_user_has_role,
find_user_with_roles_by_email,
)
from fixtures.googleidp import google_oidc_mappings, perform_google_login
from fixtures.types import Operation, SigNoz
GOOGLE_DOMAIN = "google.integration.test"
GOOGLE_CLIENT_ID = "google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET = "google-client-secret"
def test_create_auth_domain(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Reruns against a reused stack find the domain from the previous run;
# drop it so creation always starts from a clean slate.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
for domain in response.json()["data"]:
if domain["name"] == GOOGLE_DOMAIN:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": GOOGLE_DOMAIN,
"enabled": True,
"config": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
def test_google_authn(
signoz: SigNoz,
googleidp: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
email = "viewer@google.integration.test"
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Viewer", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
assert "accessToken=" in redirect_url
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
assert found_user["displayName"] == "Google Viewer"
assert_user_has_role(found_user, "signoz-viewer")
def test_google_authn_hd_mismatch(
signoz: SigNoz,
googleidp: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
# The id_token carries a hosted-domain claim for a different workspace than
# the auth domain; the callback must reject it and provision no user.
email = "intruder@google.integration.test"
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Intruder", hd="other.workspace.test", audience=GOOGLE_CLIENT_ID))
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
assert "callbackauthnerr" in redirect_url
assert "accessToken=" not in redirect_url
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert not any(user["email"] == email for user in response.json()["data"])
def test_google_authn_unverified_email(
signoz: SigNoz,
googleidp: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
email = "unverified@google.integration.test"
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Unverified", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID, email_verified=False))
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
assert "callbackauthnerr" in redirect_url
# Opting the domain into insecureSkipEmailVerified must let the same
# unverified identity through.
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
domain = next(domain for domain in response.json()["data"] if domain["name"] == GOOGLE_DOMAIN)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
"insecureSkipEmailVerified": True,
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
assert "accessToken=" in redirect_url
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
assert_user_has_role(found_user, "signoz-viewer")
def test_google_role_mapping_default_role(
signoz: SigNoz,
googleidp: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
domain = next(domain for domain in response.json()["data"] if domain["name"] == GOOGLE_DOMAIN)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
},
},
"roleMapping": {
"defaultRole": "EDITOR",
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
email = "editor@google.integration.test"
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Editor", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
assert "accessToken=" in redirect_url
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
assert_user_has_role(found_user, "signoz-editor")