Compare commits

...

27 Commits

Author SHA1 Message Date
grandwizard28
21069bc652 test(e2e): locate the sso auth domain elements by data-testid 2026-08-10 18:54:04 +05:30
grandwizard28
5b8785480a test(e2e): cover the sso auth domain config flows 2026-08-10 18:26:45 +05:30
grandwizard28
fee8eb9a29 fix(authdomain): drop the patch endpoint and the unused redirectURI
Flipping SSO enforcement goes through the standard update like every
other resource, so the patch endpoint and PatchableAuthDomain go away.
GoogleConfig loses redirectURI, which nothing reads; the migration
strips it from persisted documents. domain.go declarations are ordered
const, var, structs, exported functions, methods.
2026-08-10 18:02:57 +05:30
grandwizard28
90d884c113 chore: merge main 2026-08-10 17:06:38 +05:30
Pandey
f44d6c7c84 docs(contributing): document the kind/spec envelope for sum types (#12494)
#### Description

- Documents the kind/spec envelope pattern for sum types in
`docs/contributing/go/types.md`: the envelope shape, why it goes at the
point of variance rather than the resource root, the tagging-style
rationale (adjacently tagged vs internally tagged vs sibling optional
fields), the validating `UnmarshalJSON`, the OpenAPI variant structs,
and the data-migration-vs-storable-twin trade-off for legacy persisted
shapes.
- Examples are generic (`FooConfig` with `bar`/`baz` kinds), with
`RuleThresholdData`, `EvaluationEnvelope` and the dashboard plugins as
the in-tree references.
- Cross-links from `handler.md`'s "`oneOf` with a discriminator"
section, which keeps owning the schema mechanics.
2026-08-10 11:22:33 +00:00
grandwizard28
07eb864447 docs(contributing): keep only the auth domain worked-example sync
The kind/spec pattern documentation moves to its own PR with generic
examples; this branch only keeps the existing worked example in step
with the refactored types.
2026-08-10 16:34:53 +05:30
grandwizard28
df2658de38 test(callbackauthn): port the google authn tests to /api/v2/auth_domains 2026-08-10 16:13:41 +05:30
grandwizard28
125b5b3ab8 chore: merge main
# Conflicts:
#	tests/fixtures/googleidp.py
#	tests/fixtures/signoz.py
#	tests/fixtures/tls.py
#	tests/integration/tests/callbackauthn/04_google.py
2026-08-10 16:11:36 +05:30
grandwizard28
e25bb31f28 chore: merge the reusable tls fixture from the stacked test PR 2026-08-10 11:54:06 +05:30
grandwizard28
3a6c54a47a test(fixtures): make the integration CA a reusable tls fixture
The CA becomes a reuse-wrapped tls fixture with issue_server_keystore
issuing per-hostname keystores into tmpfs directories, so other mocks
that must serve TLS under a real hostname (e.g. chat.googleapis.com)
can chain to the same CA. The CA itself lives in the pytest cache
directory because basetemp is wiped every session while reused
containers must keep chaining to it; create_signoz takes the CA as an
optional argument.
2026-08-10 11:53:51 +05:30
grandwizard28
015689f587 test(callbackauthn): cover the patch endpoint and document the migration path 2026-08-10 11:48:35 +05:30
grandwizard28
3aacca0d52 fix(frontend): patch SSO enforcement and co-locate the config conversion
The enforcement toggle uses the new PATCH endpoint instead of echoing
the whole provider config through PUT, and both directions of the
envelope<->form translation now live in CreateEdit.utils.ts, replacing
the cross-enum cast with an explicit kind-to-provider mapping.
2026-08-10 11:48:31 +05:30
grandwizard28
f4d428cbee fix(authtypes): migrate the persisted auth domain config to the envelope shape
A new sqlmigration rewrites auth_domain.data from the flat legacy
document into {enabled, config: {kind, spec}, roleMapping} with the
renamed saml keys, so all legacy-shape code goes away: the storable
twins, the google_auth translation and the four per-kind conversion
switches collapse into a single variant registry that UnmarshalJSON,
JSONSchemaOneOf and the discriminator mapping derive from. AuthDomain
now exposes the domain shape (Enabled, Kind, Config, RoleMapping and
typed spec accessors) instead of the persisted document, creates build
the row directly instead of re-parsing their own marshal output, and
config presence is enforced explicitly on Postable and Updatable — the
old PUT path never enforced it at all. A new PATCH endpoint flips SSO
enforcement without rewriting the provider configuration, and secret
fields are marked format password in the schema.
2026-08-10 11:48:29 +05:30
grandwizard28
60cb50af25 chore: merge google authn test infrastructure from the stacked test PR
# Conflicts:
#	tests/fixtures/googleidp.py
#	tests/integration/tests/callbackauthn/04_google.py
2026-08-10 11:26:27 +05:30
grandwizard28
609a9c9bf9 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, all signed by one session-scoped key.
2026-08-10 11:25:24 +05:30
grandwizard28
b955f1eee1 chore(frontend): remove the loginPrecheck mock for an endpoint that no longer exists 2026-08-10 11:21:40 +05:30
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
48 changed files with 2511 additions and 1080 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
@@ -601,6 +628,7 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
domainToAdminEmail:
additionalProperties:
@@ -612,10 +640,12 @@ components:
type: boolean
insecureSkipEmailVerified:
type: boolean
redirectURI:
type: string
serviceAccountJson:
format: password
type: string
required:
- clientId
- clientSecret
type: object
AuthtypesOIDCConfig:
properties:
@@ -624,6 +654,7 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
getUserInfo:
type: boolean
@@ -633,6 +664,10 @@ components:
type: string
issuerAlias:
type: string
required:
- issuer
- clientId
- clientSecret
type: object
AuthtypesOrgSessionContext:
properties:
@@ -654,8 +689,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 +804,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 +855,12 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- config
type: object
AuthtypesUpdatableRole:
properties:
@@ -10602,275 +10654,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
@@ -14673,6 +14456,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.
- `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)` and `Patch(patchable)`. 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 FooConfig struct {
Kind FooKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
```
```json
{ "kind": "bar", "spec": { "url": "...", "timeout": "30s" } }
```
`Kind` is a `valuer.String` enum implementing `Enum()`; `Spec` holds exactly one concrete variant type (`BarSpec`, `BazSpec`, …). `RuleThresholdData` and `EvaluationEnvelope` in `pkg/types/ruletypes/` are the canonical in-tree examples; the dashboard panel/query/variable plugins in `pkg/types/dashboardtypes/` are the same pattern behind generics. (`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 — a `Foo` has a `name` and an `enabled` flag regardless of which kind it is configured with; only its configuration varies, so the envelope is the `config` field:
```json
{ "name": "my-foo", "enabled": true, "config": { "kind": "bar", "spec": { "...": "..." } } }
```
Hoisting `kind`/`spec` to the root would turn the whole resource into a `oneOf`: every flavor (`PostableFoo`, `UpdatableFoo`, `GettableFoo`) 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 — root `kind` conventionally answers "what resource is this" (`Dashboard`), never "which flavor of config does it hold".
The existing domains already follow this placement:
- **Rules** — plain root; envelopes on the varying fields: `thresholds: {kind, spec}` and `evaluation: {kind, spec}`.
- **Dashboards** — 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. 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* (`{"kind": "bar", ...fields flattened}`) mixes common and variant fields, admits cross-variant key collisions, and forces every variant schema to redeclare the discriminator; *sibling optional fields* (`{"kind": "bar", "barConfig": {}, "bazConfig": {}}`) 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 `Bar *BarSpec`, `Baz *BazSpec` next to a discriminator cannot be expressed as an OpenAPI discriminated union, forces nilability checks on every consumer, and silently admits contradictory payloads (kind=bar with a baz spec). 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 *FooConfig) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
// ... unmarshal raw, decode raw["kind"] ...
switch kind {
case FooKindBar:
spec := BarSpec{}
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.(BarSpec)`) 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** (`fooConfigBar{Kind; Spec BarSpec}`), 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 legacy persisted shape gets a data migration or a `StorableX`.** When rows were written before the envelope existed, prefer an idempotent `sqlmigration` that rewrites them into the new shape, so the storable type simply nests the envelope. Only when the old shape must keep being written (external writers, rollback windows) keep it in a storable twin and convert at the type boundary.
## 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,10 +53,6 @@ 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 {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
if err != nil {
return "", err
@@ -85,6 +81,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
if err != nil {
return nil, err
@@ -106,14 +107,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 && oidcConfig.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[oidcConfig.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -123,7 +124,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 !oidcConfig.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 +136,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if nameClaim := oidcConfig.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 := oidcConfig.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -161,7 +162,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if roleClaim := oidcConfig.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -177,11 +178,16 @@ 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)
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, nil, err
}
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if oidcConfig.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, oidcConfig.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
if err != nil {
return nil, nil, err
}
@@ -189,13 +195,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.RoleMapping() != nil && len(authDomain.RoleMapping().GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
ClientID: oidcConfig.ClientID,
ClientSecret: oidcConfig.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -212,7 +218,12 @@ 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})
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.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,10 +40,6 @@ 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 {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
sp, err := a.serviceProvider(siteURL, authDomain)
if err != nil {
return "", err
@@ -73,6 +69,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
sp, err := a.serviceProvider(state.URL, authDomain)
if err != nil {
return nil, err
@@ -101,19 +102,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := samlConfig.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 := samlConfig.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := samlConfig.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -131,7 +132,12 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
certStore, err := a.getCertificateStore(authDomain)
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
certStore, err := a.getCertificateStore(samlConfig)
if err != nil {
return nil, err
}
@@ -142,32 +148,32 @@ 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: samlConfig.Location,
IdentityProviderIssuer: samlConfig.EntityID,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !samlConfig.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
}, nil
}
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
func (a *AuthN) getCertificateStore(samlConfig authtypes.SamlConfig) (dsig.X509CertificateStore, error) {
certStore := &dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{},
}
var certBytes []byte
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if strings.Contains(samlConfig.Certificate, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(samlConfig.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(samlConfig.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,12 @@ export interface AuthtypesGoogleConfigDTO {
/**
* @type string
*/
clientId?: string;
clientId: string;
/**
* @type string
* @format password
*/
clientSecret?: string;
clientSecret: string;
/**
* @type object
*/
@@ -1916,24 +1932,34 @@ export interface AuthtypesGoogleConfigDTO {
insecureSkipEmailVerified?: boolean;
/**
* @type string
*/
redirectURI?: string;
/**
* @type string
* @format password
*/
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
* @format password
*/
clientSecret?: string;
clientSecret: string;
/**
* @type boolean
*/
@@ -1945,79 +1971,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 +2035,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 +2068,10 @@ export interface AuthtypesGettableAuthDomainDTO {
* @format date-time
*/
createdAt?: string;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
@@ -2075,6 +2084,7 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
/**
* @type string
* @format date-time
@@ -2271,11 +2281,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 +2423,12 @@ export interface AuthtypesTransactionDTO {
}
export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
roleMapping?: AuthtypesRoleMappingDTO;
}
export interface AuthtypesUpdatableRoleDTO {
@@ -10525,42 +10545,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
@@ -11234,6 +11218,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} />,
@@ -78,6 +78,7 @@ function AuthnProviderSelector({
<Button
onClick={(): void => setAuthnProvider(provider.key)}
type="primary"
data-testid={`authn-provider-configure-${provider.key}`}
>
Configure
</Button>

View File

@@ -10,8 +10,6 @@ import {
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
@@ -24,10 +22,11 @@ import APIError from 'types/api/error';
import AuthnProviderSelector from './AuthnProviderSelector';
import {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
kindToProvider,
prepareConfig,
prepareInitialValues,
prepareRoleMapping,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
@@ -41,7 +40,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 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.config?.ssoType || '');
>(kindToProvider(record?.config?.kind));
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -85,68 +84,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { mutate: updateAuthDomain, isLoading: isUpdating } =
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}, [form]);
// Prepares role mapping for API payload
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
@@ -158,25 +95,23 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
const values = form.getFieldsValue(true) as FormValues;
const name = values.name ?? '';
const config = prepareConfig(values, authnProvider);
const roleMapping = prepareRoleMapping(values);
if (!config) {
return;
}
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: true,
config,
roleMapping,
},
},
{
@@ -196,14 +131,9 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
enabled: values.enabled ?? false,
config,
roleMapping,
},
},
{
@@ -219,8 +149,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
@@ -243,10 +171,10 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
>
<Form
name="auth-domain"
data-testid="auth-domain-form"
initialValues={defaultTo(prepareInitialValues(record), {
name: '',
ssoEnabled: false,
ssoType: '',
enabled: false,
})}
form={form}
layout="vertical"
@@ -262,12 +190,22 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{configureAuthnProvider(authnProvider, isCreate)}
<section className="action-buttons">
{isCreate && (
<Button onClick={onBackHandler} variant="solid" color="secondary">
<Button
onClick={onBackHandler}
variant="solid"
color="secondary"
testId="auth-domain-back"
>
Back
</Button>
)}
{!isCreate && (
<Button onClick={onClose} variant="solid" color="secondary">
<Button
onClick={onClose}
variant="solid"
color="secondary"
testId="auth-domain-cancel"
>
Cancel
</Button>
)}
@@ -276,6 +214,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
variant="solid"
color="primary"
loading={isCreating || isUpdating}
testId="auth-domain-save"
>
Save Changes
</Button>

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,9 @@
import {
AuthtypesAuthDomainConfigDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
@@ -6,11 +11,29 @@ import {
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Maps the config envelope's per-variant kind to the provider enum driving the
* create/edit UI.
*/
export function kindToProvider(
kind?: AuthtypesAuthDomainConfigDTO['kind'],
): AuthtypesAuthNProviderDTO | '' {
switch (kind) {
case AuthtypesAuthDomainConfigSAMLDTOKind.saml:
return AuthtypesAuthNProviderDTO.saml;
case AuthtypesAuthDomainConfigGoogleDTOKind.google:
return AuthtypesAuthNProviderDTO.google;
case AuthtypesAuthDomainConfigOIDCDTOKind.oidc:
return AuthtypesAuthNProviderDTO.oidc;
default:
return '';
}
}
// 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,33 +130,141 @@ 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,
};
}
/**
* Prepares Google Auth config for API payload
*/
export function prepareGoogleAuthConfig(
values: FormValues,
): AuthtypesGoogleConfigDTO | undefined {
const config = values.googleAuthConfig;
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}
/**
* Prepares role mapping for API payload; only returned when there is
* meaningful content.
*/
export function prepareRoleMapping(
values: FormValues,
): AuthtypesRoleMappingDTO | undefined {
const roleMapping = values.roleMapping;
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}
/**
* Prepares the kind/spec config envelope for API payload; the inverse of
* prepareInitialValues.
*/
export function prepareConfig(
values: FormValues,
provider: AuthtypesAuthNProviderDTO | '',
): AuthtypesAuthDomainConfigDTO | undefined {
switch (provider) {
case AuthtypesAuthNProviderDTO.saml:
return values.samlConfig
? {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: values.samlConfig,
}
: undefined;
case AuthtypesAuthNProviderDTO.google: {
const spec = prepareGoogleAuthConfig(values);
return spec
? {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec,
}
: undefined;
}
case AuthtypesAuthNProviderDTO.oidc:
return values.oidcConfig
? {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: values.oidcConfig,
}
: undefined;
default:
return undefined;
}
}

View File

@@ -91,7 +91,11 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="google-domain" disabled={!isCreate} />
<Input
id="google-domain"
disabled={!isCreate}
testId="google-auth-domain"
/>
</Form.Item>
</div>
@@ -109,7 +113,7 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" />
<Input id="google-client-id" testId="google-auth-client-id" />
</Form.Item>
</div>
@@ -131,7 +135,7 @@ function ConfigureGoogleAuthAuthnProvider({
},
]}
>
<Input id="google-client-secret" />
<Input id="google-client-secret" testId="google-auth-client-secret" />
</Form.Item>
</div>
@@ -143,6 +147,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-skip-email-verification"
testId="google-auth-skip-email-verified"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
@@ -180,7 +185,10 @@ function ConfigureGoogleAuthAuthnProvider({
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
<div
className="authn-provider__collapse-header"
data-testid="google-auth-workspace-groups-header"
>
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
@@ -221,6 +229,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-fetch-groups"
testId="google-auth-fetch-groups"
onChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
@@ -251,6 +260,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<AntdInput.TextArea
id="google-service-account-json"
data-testid="google-auth-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
@@ -270,6 +280,7 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-transitive-membership"
testId="google-auth-transitive-membership"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
@@ -299,7 +310,10 @@ function ConfigureGoogleAuthAuthnProvider({
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput placeholder="Type a group email and press Enter" />
<EmailTagInput
placeholder="Type a group email and press Enter"
testId="google-auth-allowed-groups"
/>
</Form.Item>
</div>
</div>

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

@@ -9,12 +9,14 @@ interface EmailTagInputProps {
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
testId?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
testId,
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
@@ -34,7 +36,7 @@ function EmailTagInput({
);
return (
<div className="email-tag-input">
<div className="email-tag-input" data-testid={testId}>
<Tooltip
title={validationError}
open={!!validationError}

View File

@@ -74,6 +74,7 @@ function RoleMappingSection({
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
data-testid="role-mapping-header"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
@@ -138,6 +139,7 @@ function RoleMappingSection({
>
<Checkbox
id="use-role-attribute"
testId="role-mapping-use-role-attribute"
onChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
@@ -166,13 +168,20 @@ function RoleMappingSection({
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div key={field.key} className="role-mapping-section__row">
<div
key={field.key}
className="role-mapping-section__row"
data-testid="role-mapping-row"
>
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input placeholder="IDP Group Name" />
<Input
placeholder="IDP Group Name"
testId="role-mapping-group-name"
/>
</Form.Item>
<Form.Item
@@ -199,6 +208,7 @@ function RoleMappingSection({
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
testId="role-mapping-remove"
>
<Trash2 size={12} />
</Button>
@@ -212,6 +222,7 @@ function RoleMappingSection({
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
prefix={<Plus size={14} />}
testId="role-mapping-add"
>
Add Group Mapping
</Button>

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,
},
},
{
@@ -65,7 +60,12 @@ function SSOEnforcementToggle({
};
return (
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
);
}

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,8 @@ describe('SSOEnforcementToggle', () => {
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
enabled: false,
config: mockGoogleAuthDomain.config,
}),
);
});

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

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { HTMLAttributes, useCallback, useMemo, useState } from 'react';
import { Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -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,
@@ -157,13 +157,15 @@ function AuthDomain(): JSX.Element {
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
testId="auth-domain-configure"
>
Configure {SSOType.get(record.config?.ssoType || '')}
Configure {SSOType.get(record.config?.kind || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
testId="auth-domain-delete"
>
Delete
</Button>
@@ -177,7 +179,9 @@ function AuthDomain(): JSX.Element {
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<h3 className="auth-domain-title" data-testid="auth-domain-title">
Authenticated Domains
</h3>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
@@ -186,6 +190,7 @@ function AuthDomain(): JSX.Element {
variant="solid"
size="sm"
color="primary"
testId="auth-domain-add"
>
Add Domain
</Button>
@@ -195,7 +200,14 @@ function AuthDomain(): JSX.Element {
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={undefined}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
@@ -228,6 +240,7 @@ function AuthDomain(): JSX.Element {
onClick={hideDeleteModal}
className="cancel-btn"
prefix={<X size={16} />}
testId="auth-domain-delete-cancel"
>
Cancel
</Button>,
@@ -237,6 +250,7 @@ function AuthDomain(): JSX.Element {
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
testId="auth-domain-delete-confirm"
>
Delete Domain
</Button>,

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,12 +59,11 @@ func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *auth
return "", err
}
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
oauth2Config, err := a.oauth2Config(siteURL, authDomain, oidcProvider)
if err != nil {
return "", err
}
oauth2Config := a.oauth2Config(siteURL, authDomain, oidcProvider)
return oauth2Config.AuthCodeURL(
authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String(),
oauth2.SetAuthURLParam("hd", authDomain.StorableAuthDomain().Name),
@@ -93,7 +92,16 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
oauth2Config := a.oauth2Config(state.URL, authDomain, oidcProvider)
googleConfig, err := authDomain.Config().GoogleConfig()
if err != nil {
return nil, err
}
oauth2Config, err := a.oauth2Config(state.URL, authDomain, oidcProvider)
if err != nil {
return nil, err
}
token, err := oauth2Config.Exchange(ctx, query.Get("code"))
if err != nil {
var retrieveError *oauth2.RetrieveError
@@ -111,7 +119,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: googleConfig.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 +143,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 !googleConfig.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 +156,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 googleConfig.FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, googleConfig)
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 := googleConfig.AllowedGroups
if len(allowedGroups) > 0 {
groups = filterGroups(groups, allowedGroups)
if len(groups) == 0 {
@@ -173,10 +181,15 @@ 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 {
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) (*oauth2.Config, error) {
googleConfig, err := authDomain.Config().GoogleConfig()
if err != nil {
return nil, err
}
return &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
ClientID: googleConfig.ClientID,
ClientSecret: googleConfig.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -184,10 +197,10 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
Host: siteURL.Host,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
}).String(),
}
}, nil
}
func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string, config *authtypes.GoogleConfig) ([]string, error) {
func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string, config authtypes.GoogleConfig) ([]string, error) {
adminEmail := config.GetAdminEmailForDomain(userEmail)
if adminEmail == "" {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no admin email configured for domain of %s", userEmail)

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.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
@@ -154,7 +154,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.Kind()].(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.Kind().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.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.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.Enabled() {
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.Kind(), 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.Kind(), 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

@@ -237,6 +237,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
)
}

View File

@@ -0,0 +1,182 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type restructureAuthDomainConfig struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type restructureAuthDomainRow struct {
bun.BaseModel `bun:"table:auth_domain"`
ID string `bun:"id"`
Data string `bun:"data"`
}
// The legacy document keyed the discriminator as ssoType with the chosen
// provider's config in a sibling field; the restructured document is
// {enabled, config: {kind, spec}, roleMapping} with renamed saml spec keys.
var legacySSOTypeToKind = map[string]string{
"google_auth": "google",
"saml": "saml",
"oidc": "oidc",
}
var legacySSOTypeToConfigKey = map[string]string{
"google_auth": "googleAuthConfig",
"saml": "samlConfig",
"oidc": "oidcConfig",
}
var legacySamlKeyToKey = map[string]string{
"samlEntity": "entityId",
"samlIdp": "location",
"samlCert": "certificate",
}
func NewRestructureAuthDomainConfigFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("restructure_auth_domain_config"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &restructureAuthDomainConfig{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *restructureAuthDomainConfig) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *restructureAuthDomainConfig) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*restructureAuthDomainRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
legacy := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &legacy); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable data", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
ssoTypeRaw, ok := legacy["ssoType"]
if !ok {
continue
}
var ssoType string
if err := json.Unmarshal(ssoTypeRaw, &ssoType); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable ssoType", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
kind, ok := legacySSOTypeToKind[ssoType]
if !ok {
migration.logger.WarnContext(ctx, "skipping auth domain with unknown ssoType", slog.String("auth_domain_id", row.ID), slog.String("sso_type", ssoType))
continue
}
spec, ok := legacy[legacySSOTypeToConfigKey[ssoType]]
if !ok || string(spec) == "null" {
migration.logger.WarnContext(ctx, "skipping auth domain with missing provider config", slog.String("auth_domain_id", row.ID), slog.String("sso_type", ssoType))
continue
}
if ssoType == "saml" {
samlSpec := make(map[string]json.RawMessage)
if err := json.Unmarshal(spec, &samlSpec); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable saml config", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
for legacyKey, key := range legacySamlKeyToKey {
if value, ok := samlSpec[legacyKey]; ok {
samlSpec[key] = value
delete(samlSpec, legacyKey)
}
}
if spec, err = json.Marshal(samlSpec); err != nil {
return err
}
}
if ssoType == "google_auth" {
googleSpec := make(map[string]json.RawMessage)
if err := json.Unmarshal(spec, &googleSpec); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable google config", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
delete(googleSpec, "redirectURI")
if spec, err = json.Marshal(googleSpec); err != nil {
return err
}
}
kindRaw, err := json.Marshal(kind)
if err != nil {
return err
}
config, err := json.Marshal(map[string]json.RawMessage{
"kind": kindRaw,
"spec": spec,
})
if err != nil {
return err
}
restructured := map[string]json.RawMessage{
"enabled": json.RawMessage("false"),
"config": config,
}
if enabled, ok := legacy["ssoEnabled"]; ok {
restructured["enabled"] = enabled
}
if roleMapping, ok := legacy["roleMapping"]; ok && string(roleMapping) != "null" {
restructured["roleMapping"] = roleMapping
}
newData, err := json.Marshal(restructured)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*restructureAuthDomainRow)(nil)).
Set("data = ?", string(newData)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *restructureAuthDomainConfig) Down(context.Context, *bun.DB) error {
return 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"
)
@@ -28,9 +29,58 @@ var (
ErrCodeAuthDomainAlreadyExists = errors.MustNewCode("auth_domain_already_exists")
)
// authDomainConfigVariants is the single registry of authn provider kinds:
// UnmarshalJSON, JSONSchemaOneOf and the discriminator mapping all derive from
// it, so a new provider is one entry here plus its authn registration.
var authDomainConfigVariants = []authDomainConfigVariant{
{
kind: AuthNProviderSAML,
decodeSpec: func(data []byte) (any, error) {
spec := SamlConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigSAML{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigSAML",
},
{
kind: AuthNProviderGoogle,
decodeSpec: func(data []byte) (any, error) {
spec := GoogleConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigGoogle{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigGoogle",
},
{
kind: AuthNProviderOIDC,
decodeSpec: func(data []byte) (any, error) {
spec := OIDCConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigOIDC{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigOIDC",
},
}
var (
_ jsonschema.OneOfExposer = AuthDomainConfig{}
_ jsonschema.Preparer = AuthDomainConfig{}
)
type GettableAuthDomain struct {
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -39,12 +89,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,150 +111,46 @@ 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.
// StorableAuthDomainConfig is the JSON document persisted in StorableAuthDomain.Data.
type StorableAuthDomainConfig struct {
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
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"`
}
type authDomainConfigVariant struct {
kind AuthNProvider
decodeSpec func(data []byte) (any, error)
schema any
schemaRef string
}
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
}
func NewAuthDomainFromConfig(name string, config *AuthDomainConfig, orgID valuer.UUID) (*AuthDomain, error) {
data, err := json.Marshal(config)
if err != nil {
return nil, err
}
return NewAuthDomain(name, string(data), orgID)
}
func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, error) {
storableAuthDomain := &StorableAuthDomain{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Name: name,
Data: data,
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
return NewAuthDomainFromStorableAuthDomain(storableAuthDomain)
}
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
authDomainConfig := new(AuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), authDomainConfig); err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: storableAuthDomain,
authDomainConfig: authDomainConfig,
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
Config: *authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}
func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
return typ.storableAuthDomain
}
func (typ *AuthDomain) AuthDomainConfig() *AuthDomainConfig {
return typ.authDomainConfig
}
func (typ *AuthDomain) Update(config *AuthDomainConfig) error {
data, err := json.Marshal(config)
if err != nil {
return err
}
typ.authDomainConfig = config
typ.storableAuthDomain.Data = string(data)
typ.storableAuthDomain.UpdatedAt = time.Now()
return nil
}
func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias PostableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !authDomainNameRegex.MatchString(temp.Name) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
}
*typ = PostableAuthDomain(temp)
return nil
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
type Alias AuthDomainConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
switch temp.AuthNProvider {
case AuthNProviderGoogleAuth:
if temp.Google == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
}
case AuthNProviderSAML:
if temp.SAML == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
}
case AuthNProviderOIDC:
if temp.OIDC == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
}
default:
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
}
*typ = AuthDomainConfig(temp)
return nil
}
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
SamlConfig{},
GoogleConfig{},
OIDCConfig{},
}
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
}
type AuthDomainStore interface {
@@ -228,3 +178,230 @@ type AuthDomainStore interface {
// Delete by orgID and id.
Delete(context.Context, valuer.UUID, valuer.UUID) error
}
func NewAuthDomainFromPostableAuthDomain(postableAuthDomain *PostableAuthDomain, orgID valuer.UUID) (*AuthDomain, error) {
storableAuthDomainConfig := &StorableAuthDomainConfig{
Enabled: postableAuthDomain.Enabled,
Config: postableAuthDomain.Config,
RoleMapping: postableAuthDomain.RoleMapping,
}
data, err := json.Marshal(storableAuthDomainConfig)
if err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: &StorableAuthDomain{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Name: postableAuthDomain.Name,
Data: string(data),
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
storableAuthDomainConfig: storableAuthDomainConfig,
}, nil
}
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
storableAuthDomainConfig := new(StorableAuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), storableAuthDomainConfig); err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: storableAuthDomain,
storableAuthDomainConfig: storableAuthDomainConfig,
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
Enabled: authDomain.Enabled(),
Config: authDomain.Config(),
RoleMapping: authDomain.RoleMapping(),
AuthNProviderInfo: authNProviderInfo,
}
}
// 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 {
oneOf := make([]any, len(authDomainConfigVariants))
for i, variant := range authDomainConfigVariants {
oneOf[i] = variant.schema
}
return oneOf
}
// 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{}
}
mapping := make(map[string]string, len(authDomainConfigVariants))
for _, variant := range authDomainConfigVariants {
mapping[variant.kind.StringValue()] = variant.schemaRef
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": mapping,
}
return nil
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
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")
}
for _, variant := range authDomainConfigVariants {
if variant.kind != kind {
continue
}
spec, err := variant.decodeSpec(specData)
if err != nil {
return err
}
typ.Kind = kind
typ.Spec = spec
return nil
}
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", kind.StringValue())
}
func (config AuthDomainConfig) SamlConfig() (SamlConfig, error) {
spec, ok := config.Spec.(SamlConfig)
if !ok {
return SamlConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not saml")
}
return spec, nil
}
func (config AuthDomainConfig) GoogleConfig() (GoogleConfig, error) {
spec, ok := config.Spec.(GoogleConfig)
if !ok {
return GoogleConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not google")
}
return spec, nil
}
func (config AuthDomainConfig) OIDCConfig() (OIDCConfig, error) {
spec, ok := config.Spec.(OIDCConfig)
if !ok {
return OIDCConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not oidc")
}
return spec, nil
}
func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
return typ.storableAuthDomain
}
func (typ *AuthDomain) Enabled() bool {
return typ.storableAuthDomainConfig.Enabled
}
func (typ *AuthDomain) Kind() AuthNProvider {
return typ.storableAuthDomainConfig.Config.Kind
}
func (typ *AuthDomain) Config() AuthDomainConfig {
return typ.storableAuthDomainConfig.Config
}
func (typ *AuthDomain) RoleMapping() *RoleMapping {
return typ.storableAuthDomainConfig.RoleMapping
}
func (typ *AuthDomain) Update(updatableAuthDomain *UpdatableAuthDomain) error {
storableAuthDomainConfig := &StorableAuthDomainConfig{
Enabled: updatableAuthDomain.Enabled,
Config: updatableAuthDomain.Config,
RoleMapping: updatableAuthDomain.RoleMapping,
}
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
}
func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias PostableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !authDomainNameRegex.MatchString(temp.Name) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
}
// A present config always carries a kind (its UnmarshalJSON rejects
// anything else), so a zero kind means the key was absent.
if temp.Config.Kind.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "config is required")
}
*typ = PostableAuthDomain(temp)
return nil
}
func (typ *UpdatableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias UpdatableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// A present config always carries a kind (its UnmarshalJSON rejects
// anything else), so a zero kind means the key was absent.
if temp.Config.Kind.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "config is required")
}
*typ = UpdatableAuthDomain(temp)
return nil
}

View File

@@ -12,13 +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"`
// What is the meaning of this? Should we remove this?
RedirectURI string `json:"redirectURI"`
ClientSecret string `json:"clientSecret" required:"true" format:"password"`
// Whether to fetch the Google workspace groups (required additional API scopes)
FetchGroups bool `json:"fetchGroups"`
@@ -26,7 +23,7 @@ type GoogleConfig struct {
// Service Account creds JSON stored for Google Admin SDK access
// This is content of the JSON file stored directly into db as string
// Required if FetchGroups is true (unless running on GCE with default credentials)
ServiceAccountJSON string `json:"serviceAccountJson,omitempty"`
ServiceAccountJSON string `json:"serviceAccountJson,omitempty" format:"password"`
// Map of workspace domain to admin email for service account impersonation
// The service account will impersonate this admin to call the directory API

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" format:"password"`
// 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.
@@ -33,24 +33,34 @@ 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
}
// 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 temp.SamlCert == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
if config.Location == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "location is required")
}
if temp.AttributeMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
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
}

136
tests/e2e/helpers/sso.ts Normal file
View File

@@ -0,0 +1,136 @@
import { expect, type Page } from '@playwright/test';
import { authToken } from './common';
// ─── Constants ───────────────────────────────────────────────────────────
export const ORG_SETTINGS_PATH = '/settings/org-settings';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface GoogleAuthDomainSeed {
/** Domain name (e.g. `sso-edit.example.com`). Keep unique per test. */
name: string;
/** Enforce-SSO flag. Defaults to false. */
enabled?: boolean;
clientId?: string;
clientSecret?: string;
/**
* Enables Google Workspace group fetching. The backend then requires
* `serviceAccountJson` and `domainToAdminEmail`, and only with it may
* `allowedGroups` be set.
*/
fetchGroups?: boolean;
serviceAccountJson?: string;
domainToAdminEmail?: Record<string, string>;
allowedGroups?: string[];
roleMapping?: {
defaultRole?: string;
groupMappings?: Record<string, string>;
useRoleAttribute?: boolean;
};
}
// ─── API helpers ─────────────────────────────────────────────────────────
/**
* Seed a Google auth domain via POST /api/v2/auth_domains. Returns the new
* domain ID. Pair with {@link deleteAuthDomainByNameViaApi} for cleanup.
*/
export async function createGoogleAuthDomainViaApi(
page: Page,
seed: GoogleAuthDomainSeed,
): Promise<string> {
const token = await authToken(page);
const spec: Record<string, unknown> = {
clientId: seed.clientId ?? 'e2e-client-id.apps.googleusercontent.com',
clientSecret: seed.clientSecret ?? 'e2e-client-secret',
fetchGroups: seed.fetchGroups ?? false,
insecureSkipEmailVerified: false,
};
if (seed.serviceAccountJson) {
spec.serviceAccountJson = seed.serviceAccountJson;
}
if (seed.domainToAdminEmail) {
spec.domainToAdminEmail = seed.domainToAdminEmail;
}
if (seed.allowedGroups) {
spec.allowedGroups = seed.allowedGroups;
}
const res = await page.request.post('/api/v2/auth_domains', {
data: {
name: seed.name,
enabled: seed.enabled ?? false,
config: { kind: 'google', spec },
roleMapping: seed.roleMapping,
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/auth_domains ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
}
/** Delete an auth domain by ID (best-effort cleanup). */
export async function deleteAuthDomainViaApi(
page: Page,
id: string,
): Promise<void> {
const token = await authToken(page);
await page.request.delete(`/api/v2/auth_domains/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
/**
* Delete any auth domain named `name`; a no-op when absent. Doubles as the
* leftover guard before seeding (domain names are unique per org, so a
* crashed earlier run would otherwise make the seed conflict).
*/
export async function deleteAuthDomainByNameViaApi(
page: Page,
name: string,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.get('/api/v2/auth_domains', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`GET /api/v2/auth_domains ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as {
data: Array<{ id: string; name: string }> | null;
};
const match = (json.data ?? []).find((domain) => domain.name === name);
if (match) {
await deleteAuthDomainViaApi(page, match.id);
}
}
// ─── Navigation ────────────────────────────────────────────────────────────
/** Open org settings and wait for the Authenticated Domains section. */
export async function gotoAuthDomains(page: Page): Promise<void> {
await page.goto(ORG_SETTINGS_PATH);
await expect(page.getByTestId('auth-domain-title')).toBeVisible();
}
/** Open the Configure (edit) modal for the domain row named `name`. */
export async function openConfigureAuthDomain(
page: Page,
name: string,
): Promise<void> {
await page
.getByTestId(`auth-domain-row-${name}`)
.getByTestId('auth-domain-configure')
.click();
await expect(page.getByTestId('auth-domain-form')).toBeVisible();
}

View File

@@ -0,0 +1,235 @@
import { expect, test } from '../../fixtures/auth';
import {
createGoogleAuthDomainViaApi,
deleteAuthDomainByNameViaApi,
gotoAuthDomains,
openConfigureAuthDomain,
ORG_SETTINGS_PATH,
} from '../../helpers/sso';
// Every test seeds its own uniquely-named domain so the file can run fully
// parallel. Names are registered here and removed by the afterEach guard;
// the delete-by-name call before each seed clears leftovers of crashed runs.
const cleanupNames: string[] = [];
test.afterEach(async ({ authedPage: page }) => {
for (const name of cleanupNames.splice(0)) {
await deleteAuthDomainByNameViaApi(page, name);
}
});
test('TC-01 org settings shows the authenticated domains section', async ({
authedPage: page,
}) => {
await page.goto(ORG_SETTINGS_PATH);
await expect(page.getByTestId('auth-domain-title')).toBeVisible();
await expect(page.getByTestId('auth-domain-add')).toBeVisible();
});
test('TC-02 create a google auth domain via the UI', async ({
authedPage: page,
}) => {
const domain = 'sso-create.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await gotoAuthDomains(page);
await page.getByTestId('auth-domain-add').click();
await page.getByTestId('authn-provider-configure-google').click();
await page.getByTestId('google-auth-domain').fill(domain);
await page
.getByTestId('google-auth-client-id')
.fill('e2e-client-id.apps.googleusercontent.com');
await page.getByTestId('google-auth-client-secret').fill('e2e-client-secret');
await page.getByTestId('auth-domain-save').click();
await expect(page.getByText('Domain created successfully')).toBeVisible();
const row = page.getByTestId(`auth-domain-row-${domain}`);
await expect(row).toBeVisible();
await expect(row.getByTestId('auth-domain-configure')).toHaveText(
'Configure Google Auth',
);
});
test('TC-03 editing the client id persists across reopen', async ({
authedPage: page,
}) => {
const domain = 'sso-edit.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, { name: domain });
await gotoAuthDomains(page);
await openConfigureAuthDomain(page, domain);
await expect(page.getByTestId('google-auth-client-id')).toHaveValue(
'e2e-client-id.apps.googleusercontent.com',
);
await page
.getByTestId('google-auth-client-id')
.fill('rotated-client-id.apps.googleusercontent.com');
await page.getByTestId('auth-domain-save').click();
await expect(page.getByText('Domain updated successfully')).toBeVisible();
await expect(page.getByTestId('auth-domain-form')).toBeHidden();
await openConfigureAuthDomain(page, domain);
await expect(page.getByTestId('google-auth-client-id')).toHaveValue(
'rotated-client-id.apps.googleusercontent.com',
);
});
test('TC-04 removing a group mapping persists after save', async ({
authedPage: page,
}) => {
const domain = 'sso-rolemap.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, {
name: domain,
roleMapping: {
defaultRole: 'signoz-viewer',
groupMappings: {
engineers: 'signoz-editor',
support: 'signoz-viewer',
},
},
});
await gotoAuthDomains(page);
await openConfigureAuthDomain(page, domain);
await page.getByTestId('role-mapping-header').click();
const rows = page.getByTestId('role-mapping-row');
await expect(rows).toHaveCount(2);
// Go marshals map keys sorted, so "engineers" is always the first row.
await expect(rows.nth(0).getByTestId('role-mapping-group-name')).toHaveValue(
'engineers',
);
await rows.nth(0).getByTestId('role-mapping-remove').click();
await expect(rows).toHaveCount(1);
// The PUT body is the #2402 contract: the removed mapping must be gone
// from the payload, not just from the form state.
const putRequest = page.waitForRequest(
(req) =>
req.method() === 'PUT' && req.url().includes('/api/v2/auth_domains/'),
);
await page.getByTestId('auth-domain-save').click();
const body = JSON.parse((await putRequest).postData() ?? '{}');
expect(body.roleMapping?.groupMappings).toEqual({
support: 'signoz-viewer',
});
await expect(page.getByText('Domain updated successfully')).toBeVisible();
await expect(page.getByTestId('auth-domain-form')).toBeHidden();
await openConfigureAuthDomain(page, domain);
await page.getByTestId('role-mapping-header').click();
await expect(rows).toHaveCount(1);
await expect(rows.getByTestId('role-mapping-group-name')).toHaveValue(
'support',
);
});
test('TC-05 disabling fetch groups clears the allowed groups', async ({
authedPage: page,
}) => {
const domain = 'sso-groups.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, {
name: domain,
fetchGroups: true,
serviceAccountJson: '{"type":"service_account","project_id":"e2e"}',
domainToAdminEmail: { '*': 'admin@sso-groups.example.com' },
allowedGroups: ['engineering@sso-groups.example.com'],
});
await gotoAuthDomains(page);
await openConfigureAuthDomain(page, domain);
await page.getByTestId('google-auth-workspace-groups-header').click();
const fetchGroups = page
.getByTestId('google-auth-fetch-groups')
.getByRole('checkbox');
await expect(fetchGroups).toBeChecked();
await expect(
page
.getByTestId('google-auth-allowed-groups')
.locator('.ant-select-selection-item'),
).toHaveCount(1);
await fetchGroups.click();
await expect(fetchGroups).not.toBeChecked();
// The PUT body is the #2402 contract: with fetchGroups off, the payload
// must drop allowedGroups instead of resending the stale list.
const putRequest = page.waitForRequest(
(req) =>
req.method() === 'PUT' && req.url().includes('/api/v2/auth_domains/'),
);
await page.getByTestId('auth-domain-save').click();
const spec = JSON.parse((await putRequest).postData() ?? '{}').config?.spec;
expect(spec?.fetchGroups).toBeFalsy();
expect(spec?.allowedGroups).toBeUndefined();
expect(spec?.domainToAdminEmail).toEqual({});
await expect(page.getByText('Domain updated successfully')).toBeVisible();
await expect(page.getByTestId('auth-domain-form')).toBeHidden();
await openConfigureAuthDomain(page, domain);
await page.getByTestId('google-auth-workspace-groups-header').click();
await expect(fetchGroups).not.toBeChecked();
// Re-enable to reveal the group fields: the allowed-groups list must be
// empty, not repopulated from the pre-disable state.
await fetchGroups.click();
await expect(
page
.getByTestId('google-auth-allowed-groups')
.locator('.ant-select-selection-item'),
).toHaveCount(0);
});
test('TC-06 enforce sso toggle persists across reload', async ({
authedPage: page,
}) => {
const domain = 'sso-toggle.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, { name: domain, enabled: false });
await gotoAuthDomains(page);
const toggle = page
.getByTestId(`auth-domain-row-${domain}`)
.getByTestId('auth-domain-enforce-sso');
await expect(toggle).not.toBeChecked();
const putResponse = page.waitForResponse(
(res) =>
res.request().method() === 'PUT' &&
res.url().includes('/api/v2/auth_domains/'),
);
await toggle.click();
expect((await putResponse).status()).toBe(204);
await page.reload();
await expect(
page
.getByTestId(`auth-domain-row-${domain}`)
.getByTestId('auth-domain-enforce-sso'),
).toBeChecked();
});
test('TC-07 delete a domain via the UI', async ({ authedPage: page }) => {
const domain = 'sso-delete.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, { name: domain });
await gotoAuthDomains(page);
await page
.getByTestId(`auth-domain-row-${domain}`)
.getByTestId('auth-domain-delete')
.click();
await page.getByTestId('auth-domain-delete-confirm').click();
await expect(page.getByText('Domain deleted successfully')).toBeVisible();
await expect(page.getByTestId(`auth-domain-row-${domain}`)).toHaveCount(0);
});

View File

@@ -74,7 +74,7 @@ def perform_google_login(
def get_google_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

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

@@ -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,30 +15,35 @@ 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 +55,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 +76,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 +92,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 +102,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 +123,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 +162,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 +182,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 +202,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 +226,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 +253,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 +278,376 @@ 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",
"fetchGroups": False,
"insecureSkipEmailVerified": False,
},
},
None,
id="google_minimal",
),
pytest.param(
{
"kind": "google",
"spec": {
"clientId": "client-id",
"clientSecret": "client-secret",
"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",
"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
def test_update_enabled(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
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"] == "update-enabled.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": "update-enabled.integration.test",
"enabled": True,
"config": {
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
domain_id = response.json()["data"]["id"]
# Flipping enforcement goes through the same full update as any other
# change; the echoed provider config must survive the round trip.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={
"enabled": False,
"config": {
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
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["enabled"] is False
assert data["config"] == {
"kind": "saml",
"spec": {
"entityId": "saml-entity",
"location": "saml-idp",
"certificate": "saml-cert",
"insecureSkipAuthNRequestsSigned": False,
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
},
}
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

@@ -30,20 +30,20 @@ def test_create_auth_domain(
domain = get_google_domain(signoz, admin_token)
if domain:
response = requests.delete(
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']}"),
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/v1/domains"),
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": GOOGLE_DOMAIN,
"enabled": True,
"config": {
"ssoEnabled": True,
"ssoType": "google_auth",
"googleAuthConfig": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
},
@@ -121,12 +121,12 @@ def test_google_authn_unverified_email(
domain = get_google_domain(signoz, admin_token)
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": "google_auth",
"googleAuthConfig": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
"insecureSkipEmailVerified": True,
@@ -156,18 +156,18 @@ def test_google_role_mapping_default_role(
domain = get_google_domain(signoz, admin_token)
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": "google_auth",
"googleAuthConfig": {
"kind": "google",
"spec": {
"clientId": GOOGLE_CLIENT_ID,
"clientSecret": GOOGLE_CLIENT_SECRET,
},
"roleMapping": {
"defaultRole": "EDITOR",
},
},
"roleMapping": {
"defaultRole": "EDITOR",
},
},
headers={"Authorization": f"Bearer {admin_token}"},