mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-08 14:10:42 +01:00
Compare commits
11 Commits
test/semco
...
fix/authdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f400da770 | ||
|
|
de3ba9d20c | ||
|
|
fe447e7f35 | ||
|
|
94ce3e51f1 | ||
|
|
a5ddafc5db | ||
|
|
54e2caaac2 | ||
|
|
9313e7bea5 | ||
|
|
6b58b6fcae | ||
|
|
cc5d3574a4 | ||
|
|
6175ff3fc2 | ||
|
|
db7ec08969 |
4
Makefile
4
Makefile
@@ -220,10 +220,6 @@ py-test-teardown: ## Tear down the shared SigNoz backend
|
||||
py-test: ## Runs integration tests
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --capture=no integration/tests/
|
||||
|
||||
.PHONY: py-test-semconv-phase1
|
||||
py-test-semconv-phase1: py-test-setup ## Rebuild the shared stack and run the semantic-convention Phase 1 matrix
|
||||
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/tests/queriertraces/13_semconv_evolution.py
|
||||
|
||||
.PHONY: py-clean
|
||||
py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
@echo ">> cleaning python cache files from tests directory"
|
||||
|
||||
@@ -464,27 +464,50 @@ components:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesAuthDomainConfig:
|
||||
discriminator:
|
||||
mapping:
|
||||
google: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
|
||||
oidc: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
|
||||
saml: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
|
||||
type: object
|
||||
AuthtypesAuthDomainConfigGoogle:
|
||||
properties:
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
oidcConfig:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
kind:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
spec:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AuthtypesAuthDomainConfigOIDC:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
spec:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AuthtypesAuthDomainConfigSAML:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
spec:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AuthtypesAuthNProvider:
|
||||
enum:
|
||||
- google_auth
|
||||
- google
|
||||
- saml
|
||||
- email_password
|
||||
- oidc
|
||||
@@ -531,12 +554,16 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -616,6 +643,9 @@ components:
|
||||
type: string
|
||||
serviceAccountJson:
|
||||
type: string
|
||||
required:
|
||||
- clientId
|
||||
- clientSecret
|
||||
type: object
|
||||
AuthtypesOIDCConfig:
|
||||
properties:
|
||||
@@ -633,6 +663,10 @@ components:
|
||||
type: string
|
||||
issuerAlias:
|
||||
type: string
|
||||
required:
|
||||
- issuer
|
||||
- clientId
|
||||
- clientSecret
|
||||
type: object
|
||||
AuthtypesOrgSessionContext:
|
||||
properties:
|
||||
@@ -654,8 +688,15 @@ components:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
required:
|
||||
- name
|
||||
- config
|
||||
type: object
|
||||
AuthtypesPostableEmailPasswordSession:
|
||||
properties:
|
||||
@@ -762,14 +803,18 @@ components:
|
||||
properties:
|
||||
attributeMapping:
|
||||
$ref: '#/components/schemas/AuthtypesAttributeMapping'
|
||||
certificate:
|
||||
type: string
|
||||
entityId:
|
||||
type: string
|
||||
insecureSkipAuthNRequestsSigned:
|
||||
type: boolean
|
||||
samlCert:
|
||||
type: string
|
||||
samlEntity:
|
||||
type: string
|
||||
samlIdp:
|
||||
location:
|
||||
type: string
|
||||
required:
|
||||
- entityId
|
||||
- location
|
||||
- certificate
|
||||
type: object
|
||||
AuthtypesSessionContext:
|
||||
properties:
|
||||
@@ -809,6 +854,12 @@ components:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
enabled:
|
||||
type: boolean
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AuthtypesUpdatableRole:
|
||||
properties:
|
||||
@@ -10511,275 +10562,6 @@ paths:
|
||||
summary: Update public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v1/domains:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all auth domains
|
||||
operationId: ListAuthDomains
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List all auth domains
|
||||
tags:
|
||||
- authdomains
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an auth domain
|
||||
operationId: CreateAuthDomain
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v1/domains/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an auth domain
|
||||
operationId: DeleteAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns an auth domain by ID
|
||||
operationId: GetAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get auth domain by ID
|
||||
tags:
|
||||
- authdomains
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an auth domain
|
||||
operationId: UpdateAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v1/downtime_schedules:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -14804,6 +14586,275 @@ paths:
|
||||
summary: Update user preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v2/auth_domains:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all auth domains
|
||||
operationId: ListAuthDomains
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List all auth domains
|
||||
tags:
|
||||
- authdomains
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an auth domain
|
||||
operationId: CreateAuthDomain
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v2/auth_domains/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an auth domain
|
||||
operationId: DeleteAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns an auth domain by ID
|
||||
operationId: GetAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get auth domain by ID
|
||||
tags:
|
||||
- authdomains
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an auth domain
|
||||
operationId: UpdateAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v2/dashboard_views:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -61,31 +61,37 @@ type Channel struct {
|
||||
|
||||
```go
|
||||
type AuthDomain struct {
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
authDomainConfig *AuthDomainConfig
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
storableAuthDomainConfig *StorableAuthDomainConfig
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
bun.BaseModel `bun:"table:auth_domain"`
|
||||
types.Identifiable
|
||||
Name string `bun:"name"`
|
||||
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
|
||||
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
|
||||
OrgID valuer.UUID `bun:"org_id"`
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type UpdateableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"` // Name intentionally absent
|
||||
type UpdatableAuthDomain struct {
|
||||
Enabled bool `json:"enabled"` // Name intentionally absent
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
*StorableAuthDomain
|
||||
*AuthDomainConfig
|
||||
StorableAuthDomain
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
```
|
||||
@@ -93,11 +99,74 @@ type GettableAuthDomain struct {
|
||||
Each flavor exists for a concrete reason:
|
||||
|
||||
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
|
||||
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
|
||||
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
|
||||
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request. `AuthDomainConfig` is a kind/spec envelope — see the next section.
|
||||
- `UpdatableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
|
||||
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
|
||||
|
||||
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
|
||||
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
|
||||
|
||||
## Sum types: the kind/spec envelope
|
||||
|
||||
When a domain type is a *sum type* — exactly one of several variants, selected by a discriminator — model it as an envelope with a `kind` and a `spec`:
|
||||
|
||||
```go
|
||||
type AuthDomainConfig struct {
|
||||
Kind AuthNProvider `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "kind": "saml", "spec": { "entityId": "...", "location": "...", "certificate": "..." } }
|
||||
```
|
||||
|
||||
`Kind` is a `valuer.String` enum implementing `Enum()`; `Spec` holds exactly one concrete variant type. `RuleThresholdData` and `EvaluationEnvelope` in `pkg/types/ruletypes/` and `AuthDomainConfig` in `pkg/types/authtypes/` are the canonical examples. (`QueryEnvelope` in querybuildertypes uses `type` as the discriminator key for historical reasons; new envelopes use `kind`.)
|
||||
|
||||
### The envelope goes at the point of variance, not the resource root
|
||||
|
||||
Put the envelope on the field that actually varies. The resource root is almost never a sum type — an auth domain always has a `name`, `enabled`, and `roleMapping` regardless of provider; only its provider configuration varies, so the envelope is the `config` field:
|
||||
|
||||
```json
|
||||
{ "name": "signoz.io", "enabled": true, "config": { "kind": "saml", "spec": { "..." : "..." } }, "roleMapping": null }
|
||||
```
|
||||
|
||||
Hoisting `kind`/`spec` to the root would turn the whole resource into a `oneOf`: every flavor (`PostableX`, `UpdatableX`, `GettableX`) then needs one variant schema per kind, each repeating the common fields; every new common field has to be added to all of them; and generated clients get unions of large objects instead of one small union that narrows on `config.kind`. A root-level `kind` also collides with the resource-model meaning of the word — in the Kubernetes/Perses model, root `kind` answers "what resource is this" (`Dashboard`), never "which flavor of config does it hold".
|
||||
|
||||
The other domains already follow this placement:
|
||||
|
||||
- **Rules** — plain root; envelopes on the varying fields: `thresholds: {kind, spec}` and `evaluation: {kind, spec}`.
|
||||
- **Dashboards** — Perses resource model: metadata at the root plus one typed `spec`; the unions sit deep inside, at each panel/query/variable plugin (`{kind, spec}` in `perses_plugin_wrappers.go`).
|
||||
- **Saved views** — root `{schemaVersion, spec}`, where `spec` is a *versioning* envelope holding one fixed type, not a union; the unions are inside it (`spec.queries: [{type, spec}]`). Same word, different job — a versioned body is not a discriminated union.
|
||||
|
||||
### Why this tagging style
|
||||
|
||||
Of the union encodings in common use, the envelope is the *adjacently tagged* one — tag and payload side by side — as used by the Kubernetes resource model, Perses plugins, CloudFormation (`Type` + `Properties`), and Grafana provisioning (`type` + `settings`). Variant payloads stay collision-free, and each kind maps to a named wrapper schema that carries the discriminator, which is exactly what OpenAPI generators need. The alternatives lose on those points: *internally tagged* (`{"type": "saml", ...fields flattened}` — Stripe, GitHub webhooks) mixes common and variant fields, admits cross-variant key collisions, and forces every variant schema to redeclare the discriminator; *sibling optional fields* (`{"type": "saml", "samlConfig": {}, "oidcConfig": {}}` — classic Kubernetes `VolumeSource`, and the pre-envelope auth domain) is the anti-pattern the first rule below exists to prevent.
|
||||
|
||||
The rules that make the envelope work:
|
||||
|
||||
- **Never model variants as sibling fields.** A struct with `SAML *SamlConfig`, `Google *GoogleConfig`, `OIDC *OIDCConfig` next to a discriminator cannot be expressed as an OpenAPI discriminated union, forces nilability checks on every consumer, and silently admits contradictory payloads (kind=saml with a google config). The chosen variant *is* the payload.
|
||||
- **The envelope owns `UnmarshalJSON`.** Decode `kind` first, then switch on it to decode and validate the matching concrete type into `Spec`. Unknown kinds and missing specs are rejected at the boundary:
|
||||
|
||||
```go
|
||||
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
var raw map[string]json.RawMessage
|
||||
// ... unmarshal raw, decode raw["kind"] ...
|
||||
switch kind {
|
||||
case AuthNProviderSAML:
|
||||
spec := SamlConfig{}
|
||||
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
typ.Spec = spec
|
||||
// ... one case per kind, default rejects ...
|
||||
}
|
||||
typ.Kind = kind
|
||||
return nil
|
||||
}
|
||||
```
|
||||
- **Consumers type-assert on `Spec`** (`config.Spec.(SamlConfig)`) after switching on `Kind`. If assertion sites multiply, add typed accessors on the envelope (see `EvaluationEnvelope.GetEvaluation()`).
|
||||
- **OpenAPI needs one unexported variant struct per kind** (`authDomainConfigSAML{Kind; Spec SamlConfig}`), exposed via `JSONSchemaOneOf()` and mapped via `PrepareJSONSchema` with the `x-signoz-discriminator` extension. The schema mechanics are covered in [handler.md](handler.md#oneof-with-a-discriminator).
|
||||
- **A persisted legacy shape stays in a `StorableX`.** If rows were written before the envelope existed, keep the old JSON shape in a storable type (`StorableAuthDomainConfig` keeps `ssoType` + sibling configs) and convert to/from the envelope at the type boundary — the data layer never changes shape retroactively.
|
||||
|
||||
## Conventions that tie the flavors together
|
||||
|
||||
@@ -139,6 +208,8 @@ Both are optional. Do not introduce them if `PostableX` already covers the case.
|
||||
|
||||
- Every domain package defines the core type `X`. Only `X` is mandatory.
|
||||
- Add `PostableX` / `GettableX` / `UpdatableX` / `StorableX` one at a time, only when the shape actually diverges from `X`.
|
||||
- Model sum types as a `{kind, spec}` envelope with a validating `UnmarshalJSON` — never as sibling variant fields next to a discriminator.
|
||||
- The envelope goes on the field that varies, never at the resource root — common fields stay on the resource, outside the union.
|
||||
- Domain logic lives on `X`, not on the flavor types.
|
||||
- Conversions can be a `New<Output>From<Input>` constructor or a receiver-style `ToY()` method — pick whichever reads best at the call site.
|
||||
- Use a type alias when two shapes are truly identical.
|
||||
|
||||
@@ -53,7 +53,7 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
|
||||
}
|
||||
|
||||
@@ -106,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
|
||||
if claims == nil && authDomain.StorableAuthDomainConfig().OIDC.GetUserInfo {
|
||||
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
emailClaim, ok := claims[authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
if !authDomain.StorableAuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
emailVerifiedClaim, ok := claims["email_verified"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
|
||||
@@ -135,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
|
||||
if nameClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
|
||||
if n, ok := claims[nameClaim].(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if groupsClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if claimValue, exists := claims[groupsClaim]; exists {
|
||||
switch g := claimValue.(type) {
|
||||
case []any:
|
||||
@@ -161,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
|
||||
if roleClaim := authDomain.StorableAuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
|
||||
if r, ok := claims[roleClaim].(string); ok {
|
||||
role = r
|
||||
}
|
||||
@@ -177,11 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
if authDomain.StorableAuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.StorableAuthDomainConfig().OIDC.IssuerAlias)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.StorableAuthDomainConfig().OIDC.Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -189,13 +189,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
scopes := make([]string, len(defaultScopes))
|
||||
copy(scopes, defaultScopes)
|
||||
|
||||
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
|
||||
if authDomain.StorableAuthDomainConfig().RoleMapping != nil && len(authDomain.StorableAuthDomainConfig().RoleMapping.GroupMappings) > 0 {
|
||||
scopes = append(scopes, "groups")
|
||||
}
|
||||
|
||||
return oidcProvider, &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
|
||||
ClientID: authDomain.StorableAuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.StorableAuthDomainConfig().OIDC.ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
@@ -212,7 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.StorableAuthDomainConfig().OIDC.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
|
||||
|
||||
@@ -40,7 +40,7 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
|
||||
}
|
||||
|
||||
@@ -101,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
|
||||
if nameAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
|
||||
name = val
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
|
||||
if groupAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
|
||||
groups = assertionInfo.Values.GetAll(groupAttribute)
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
|
||||
if roleAttribute := authDomain.StorableAuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
|
||||
role = val
|
||||
}
|
||||
@@ -142,11 +142,11 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
// For AWSSSO, this is the value of Application SAML audience.
|
||||
return &saml2.SAMLServiceProvider{
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
|
||||
IdentityProviderSSOURL: authDomain.StorableAuthDomainConfig().SAML.Location,
|
||||
IdentityProviderIssuer: authDomain.StorableAuthDomainConfig().SAML.EntityID,
|
||||
ServiceProviderIssuer: siteURL.Host,
|
||||
AssertionConsumerServiceURL: acsURL.String(),
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
SignAuthnRequests: !authDomain.StorableAuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
AllowMissingAttributes: true,
|
||||
IDPCertificateStore: certStore,
|
||||
SPKeyStore: dsig.RandomKeyStoreForTest(),
|
||||
@@ -159,15 +159,15 @@ func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509
|
||||
}
|
||||
|
||||
var certBytes []byte
|
||||
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
|
||||
if strings.Contains(authDomain.StorableAuthDomainConfig().SAML.Certificate, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.StorableAuthDomainConfig().SAML.Certificate))
|
||||
if block == nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
|
||||
}
|
||||
|
||||
certBytes = block.Bytes
|
||||
} else {
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.StorableAuthDomainConfig().SAML.Certificate)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1861,8 +1861,19 @@ export interface AuthtypesAttributeMappingDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
|
||||
saml = 'saml',
|
||||
}
|
||||
export interface AuthtypesSamlConfigDTO {
|
||||
attributeMapping?: AuthtypesAttributeMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
certificate: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
entityId: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -1870,17 +1881,21 @@ export interface AuthtypesSamlConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlCert?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlEntity?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
samlIdp?: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesAuthDomainConfigSAMLDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum saml
|
||||
*/
|
||||
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
|
||||
spec: AuthtypesSamlConfigDTO;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
|
||||
google = 'google',
|
||||
}
|
||||
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -1893,11 +1908,11 @@ export interface AuthtypesGoogleConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientId?: string;
|
||||
clientId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientSecret?: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -1924,16 +1939,28 @@ export interface AuthtypesGoogleConfigDTO {
|
||||
serviceAccountJson?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesAuthDomainConfigGoogleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum google
|
||||
*/
|
||||
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
|
||||
spec: AuthtypesGoogleConfigDTO;
|
||||
}
|
||||
|
||||
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
|
||||
oidc = 'oidc',
|
||||
}
|
||||
export interface AuthtypesOIDCConfigDTO {
|
||||
claimMapping?: AuthtypesAttributeMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientId?: string;
|
||||
clientId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientSecret?: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -1945,79 +1972,33 @@ export interface AuthtypesOIDCConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issuer?: string;
|
||||
issuer: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issuerAlias?: string;
|
||||
}
|
||||
|
||||
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AuthtypesRoleMappingDTOGroupMappings =
|
||||
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
|
||||
|
||||
export interface AuthtypesRoleMappingDTO {
|
||||
export interface AuthtypesAuthDomainConfigOIDCDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum oidc
|
||||
*/
|
||||
defaultRole?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
useRoleAttribute?: boolean;
|
||||
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
|
||||
spec: AuthtypesOIDCConfigDTO;
|
||||
}
|
||||
|
||||
export type AuthtypesAuthDomainConfigDTO =
|
||||
| AuthtypesAuthDomainConfigSAMLDTO
|
||||
| AuthtypesAuthDomainConfigGoogleDTO
|
||||
| AuthtypesAuthDomainConfigOIDCDTO;
|
||||
|
||||
export enum AuthtypesAuthNProviderDTO {
|
||||
google_auth = 'google_auth',
|
||||
google = 'google',
|
||||
saml = 'saml',
|
||||
email_password = 'email_password',
|
||||
oidc = 'oidc',
|
||||
}
|
||||
export type AuthtypesAuthDomainConfigDTO =
|
||||
| (AuthtypesSamlConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
})
|
||||
| (AuthtypesGoogleConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
})
|
||||
| (AuthtypesOIDCConfigDTO & {
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
});
|
||||
|
||||
export interface AuthtypesAuthNProviderInfoDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
@@ -2055,6 +2036,31 @@ export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AuthtypesRoleMappingDTOGroupMappings =
|
||||
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
|
||||
|
||||
export interface AuthtypesRoleMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
defaultRole?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
useRoleAttribute?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2063,6 +2069,10 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2075,6 +2085,7 @@ export interface AuthtypesGettableAuthDomainDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2271,11 +2282,16 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
config: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
name: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableEmailPasswordSessionDTO {
|
||||
@@ -2408,7 +2424,12 @@ export interface AuthtypesTransactionDTO {
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
config: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableRoleDTO {
|
||||
@@ -10425,42 +10446,6 @@ export type CreatePublicDashboard201 = {
|
||||
export type UpdatePublicDashboardPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListAuthDomains200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesGettableAuthDomainDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDowntimeSchedulesParams = {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -11159,6 +11144,42 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListAuthDomains200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: AuthtypesGettableAuthDomainDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDashboardViews200 = {
|
||||
data: DashboardtypesListableDashboardViewDTO;
|
||||
/**
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
useUpdateAuthDomain,
|
||||
} from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesAuthDomainConfigDTO,
|
||||
AuthtypesAuthDomainConfigGoogleDTOKind,
|
||||
AuthtypesAuthDomainConfigOIDCDTOKind,
|
||||
AuthtypesAuthDomainConfigSAMLDTOKind,
|
||||
AuthtypesAuthNProviderDTO,
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
AuthtypesGoogleConfigDTO,
|
||||
@@ -41,7 +45,7 @@ function configureAuthnProvider(
|
||||
switch (authnProvider) {
|
||||
case 'saml':
|
||||
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
|
||||
case 'google_auth':
|
||||
case 'google':
|
||||
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
|
||||
case 'oidc':
|
||||
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
|
||||
@@ -61,7 +65,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [authnProvider, setAuthnProvider] = useState<
|
||||
AuthtypesAuthNProviderDTO | ''
|
||||
>(record?.config?.ssoType || '');
|
||||
>((record?.config?.kind as unknown as AuthtypesAuthNProviderDTO) ?? '');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
@@ -147,6 +151,33 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
// Prepares the kind/spec config envelope for API payload
|
||||
const getConfig = useCallback((): AuthtypesAuthDomainConfigDTO | undefined => {
|
||||
switch (authnProvider) {
|
||||
case AuthtypesAuthNProviderDTO.saml:
|
||||
return {
|
||||
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
|
||||
spec: form.getFieldValue('samlConfig'),
|
||||
};
|
||||
case AuthtypesAuthNProviderDTO.google: {
|
||||
const spec = getGoogleAuthConfig();
|
||||
return spec
|
||||
? {
|
||||
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
|
||||
spec,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
case AuthtypesAuthNProviderDTO.oidc:
|
||||
return {
|
||||
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
|
||||
spec: form.getFieldValue('oidcConfig'),
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [authnProvider, form, getGoogleAuthConfig]);
|
||||
|
||||
const onSubmitHandler = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
@@ -159,24 +190,21 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
}
|
||||
|
||||
const name = form.getFieldValue('name');
|
||||
const googleAuthConfig = getGoogleAuthConfig();
|
||||
const samlConfig = form.getFieldValue('samlConfig');
|
||||
const oidcConfig = form.getFieldValue('oidcConfig');
|
||||
const config = getConfig();
|
||||
const roleMapping = getRoleMapping();
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreate) {
|
||||
createAuthDomain(
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
enabled: true,
|
||||
config,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -196,14 +224,9 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
{
|
||||
pathParams: { id: record.id },
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
enabled: form.getFieldValue('enabled'),
|
||||
config,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -219,7 +242,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
authnProvider,
|
||||
createAuthDomain,
|
||||
form,
|
||||
getGoogleAuthConfig,
|
||||
getConfig,
|
||||
getRoleMapping,
|
||||
handleError,
|
||||
isCreate,
|
||||
@@ -245,8 +268,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
name="auth-domain"
|
||||
initialValues={defaultTo(prepareInitialValues(record), {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
enabled: false,
|
||||
})}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
AuthtypesAuthDomainConfigGoogleDTOKind,
|
||||
AuthtypesAuthDomainConfigOIDCDTOKind,
|
||||
AuthtypesAuthDomainConfigSAMLDTOKind,
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
AuthtypesGoogleConfigDTO,
|
||||
AuthtypesOIDCConfigDTO,
|
||||
@@ -9,8 +12,7 @@ import {
|
||||
// Form values interface for internal use (includes array-based fields for UI)
|
||||
export interface FormValues {
|
||||
name?: string;
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: string;
|
||||
enabled?: boolean;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
|
||||
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
|
||||
};
|
||||
@@ -107,31 +109,36 @@ export function prepareInitialValues(
|
||||
if (!record) {
|
||||
return {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const config = record.config ?? {};
|
||||
const { config } = record;
|
||||
return {
|
||||
name: record.name,
|
||||
ssoEnabled: config.ssoEnabled,
|
||||
ssoType: config.ssoType,
|
||||
samlConfig: config.samlConfig ?? undefined,
|
||||
oidcConfig: config.oidcConfig ?? undefined,
|
||||
googleAuthConfig: config.googleAuthConfig
|
||||
enabled: record.enabled,
|
||||
samlConfig:
|
||||
config?.kind === AuthtypesAuthDomainConfigSAMLDTOKind.saml
|
||||
? config.spec
|
||||
: undefined,
|
||||
oidcConfig:
|
||||
config?.kind === AuthtypesAuthDomainConfigOIDCDTOKind.oidc
|
||||
? config.spec
|
||||
: undefined,
|
||||
googleAuthConfig:
|
||||
config?.kind === AuthtypesAuthDomainConfigGoogleDTOKind.google
|
||||
? {
|
||||
...config.spec,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
config.spec.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: record.roleMapping
|
||||
? {
|
||||
...config.googleAuthConfig,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
config.googleAuthConfig.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: config.roleMapping
|
||||
? {
|
||||
...config.roleMapping,
|
||||
...record.roleMapping,
|
||||
groupMappingsList: convertGroupMappingsToList(
|
||||
config.roleMapping.groupMappings,
|
||||
record.roleMapping.groupMappings,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('SSOEnforcementToggle', () => {
|
||||
isDefaultChecked={false}
|
||||
record={{
|
||||
...mockGoogleAuthDomain,
|
||||
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
|
||||
enabled: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -95,9 +95,7 @@ describe('SSOEnforcementToggle', () => {
|
||||
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAPI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
ssoEnabled: false,
|
||||
}),
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import './AuthDomain.styles.scss';
|
||||
import '../../IngestionSettings/IngestionSettings.styles.scss';
|
||||
|
||||
export const SSOType = new Map<string, string>([
|
||||
['google_auth', 'Google Auth'],
|
||||
['google', 'Google Auth'],
|
||||
['saml', 'SAML'],
|
||||
['email_password', 'Email Password'],
|
||||
['oidc', 'OIDC'],
|
||||
@@ -121,8 +121,8 @@ function AuthDomain(): JSX.Element {
|
||||
},
|
||||
{
|
||||
title: 'Enforce SSO',
|
||||
dataIndex: ['config', 'ssoEnabled'],
|
||||
key: 'ssoEnabled',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (
|
||||
value: boolean,
|
||||
@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
>
|
||||
Configure {SSOType.get(record.config?.ssoType || '')}
|
||||
Configure {SSOType.get(record.config?.kind || '')}
|
||||
</Button>
|
||||
<Button
|
||||
className="auth-domain-list-action-link delete"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -59,7 +59,7 @@ func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *auth
|
||||
return "", err
|
||||
}
|
||||
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
|
||||
if authDomain.StorableAuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogle {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.StorableAuthDomainConfig().Google.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", errors.Attr(err))
|
||||
@@ -135,7 +135,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
|
||||
if !authDomain.StorableAuthDomainConfig().Google.InsecureSkipEmailVerified {
|
||||
if !claims.EmailVerified {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", slog.String("email", claims.Email))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
|
||||
@@ -148,14 +148,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if authDomain.AuthDomainConfig().Google.FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
|
||||
if authDomain.StorableAuthDomainConfig().Google.FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.StorableAuthDomainConfig().Google)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", errors.Attr(err))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
|
||||
allowedGroups := authDomain.StorableAuthDomainConfig().Google.AllowedGroups
|
||||
if len(allowedGroups) > 0 {
|
||||
groups = filterGroups(groups, allowedGroups)
|
||||
if len(groups) == 0 {
|
||||
@@ -175,8 +175,8 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
|
||||
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
|
||||
ClientID: authDomain.StorableAuthDomainConfig().Google.ClientID,
|
||||
ClientSecret: authDomain.StorableAuthDomainConfig().Google.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
|
||||
@@ -26,7 +26,7 @@ func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID,
|
||||
|
||||
referencedBy := make([]string, 0)
|
||||
for _, domain := range domains {
|
||||
for _, mappedRole := range domain.AuthDomainConfig().RoleMapping.RoleNames() {
|
||||
for _, mappedRole := range domain.StorableAuthDomainConfig().RoleMapping.RoleNames() {
|
||||
if mappedRole == roleName {
|
||||
referencedBy = append(referencedBy, domain.StorableAuthDomain().Name)
|
||||
break
|
||||
|
||||
@@ -38,7 +38,7 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
|
||||
authDomain, err := authtypes.NewAuthDomainFromPostableAuthDomain(body, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -99,7 +99,13 @@ func (handler *handler) Get(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain)))
|
||||
gettableAuthDomain, err := authtypes.NewGettableAuthDomainFromAuthDomain(authDomain, handler.module.GetAuthNProviderInfo(ctx, authDomain))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, gettableAuthDomain)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -120,7 +126,13 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
authDomains := make([]*authtypes.GettableAuthDomain, len(domains))
|
||||
for i, domain := range domains {
|
||||
authDomains[i] = authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
|
||||
gettableAuthDomain, err := authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
authDomains[i] = gettableAuthDomain
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, authDomains)
|
||||
@@ -154,7 +166,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = authDomain.Update(&body.Config)
|
||||
err = authDomain.Update(body)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -33,7 +33,7 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
|
||||
}
|
||||
|
||||
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
|
||||
if callbackAuthN, ok := module.authNs[domain.StorableAuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
|
||||
return callbackAuthN.ProviderInfo(ctx, domain)
|
||||
}
|
||||
return &authtypes.AuthNProviderInfo{}
|
||||
@@ -72,7 +72,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats := make(map[string]any)
|
||||
|
||||
for _, domain := range domains {
|
||||
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
|
||||
key := "authdomain." + domain.StorableAuthDomainConfig().AuthNProvider.StringValue() + ".count"
|
||||
if value, ok := stats[key]; ok {
|
||||
stats[key] = value.(int64) + 1
|
||||
} else {
|
||||
@@ -86,7 +86,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
}
|
||||
|
||||
func (module *module) validateRoleMapping(ctx context.Context, domain *authtypes.AuthDomain) error {
|
||||
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
|
||||
roleNames := domain.StorableAuthDomainConfig().RoleMapping.RoleNames()
|
||||
if len(roleNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,7 +152,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", err
|
||||
}
|
||||
|
||||
roleMapping := authDomain.AuthDomainConfig().RoleMapping
|
||||
roleMapping := authDomain.StorableAuthDomainConfig().RoleMapping
|
||||
|
||||
roleAttributeExists := false
|
||||
if roleMapping != nil && roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
|
||||
@@ -215,11 +215,11 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().SSOEnabled {
|
||||
if !authDomain.StorableAuthDomainConfig().SSOEnabled {
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
|
||||
}
|
||||
|
||||
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().AuthNProvider, module.authNs)
|
||||
provider, err := getProvider[authn.CallbackAuthN](authDomain.StorableAuthDomainConfig().AuthNProvider, module.authNs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
|
||||
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.StorableAuthDomainConfig().AuthNProvider, loginURL), nil
|
||||
}
|
||||
|
||||
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
@@ -31,61 +29,13 @@ var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
v3.FilterOperatorNotILike: "NOT ILIKE",
|
||||
}
|
||||
|
||||
func resourceSemconvMembers(key string) []string {
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
}
|
||||
|
||||
func resourceValueExpression(key string) string {
|
||||
members := resourceSemconvMembers(key)
|
||||
if len(members) == 1 {
|
||||
return fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key)
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(labels, '%s'), '')", member))
|
||||
}
|
||||
return "COALESCE(" + strings.Join(values, ", ") + ")"
|
||||
}
|
||||
|
||||
func resourcePresenceExpression(key string, exists bool) string {
|
||||
members := resourceSemconvMembers(key)
|
||||
if len(members) == 1 {
|
||||
if exists {
|
||||
return fmt.Sprintf("simpleJSONHas(labels, '%s')", key)
|
||||
}
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key)
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if exists {
|
||||
conditions = append(conditions, fmt.Sprintf("simpleJSONHas(labels, '%s')", member))
|
||||
} else {
|
||||
conditions = append(conditions, fmt.Sprintf("not simpleJSONHas(labels, '%s')", member))
|
||||
}
|
||||
}
|
||||
separator := " OR "
|
||||
if !exists {
|
||||
separator = " AND "
|
||||
}
|
||||
return "(" + strings.Join(conditions, separator) + ")"
|
||||
}
|
||||
|
||||
// buildResourceFilter builds a clickhouse filter string for resource labels
|
||||
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}) string {
|
||||
// for all operators except contains and like
|
||||
searchKey := resourceValueExpression(key)
|
||||
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key)
|
||||
|
||||
// for contains and like it will be case insensitive
|
||||
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), '%s')", key)
|
||||
if len(resourceSemconvMembers(key)) > 1 {
|
||||
lowerSearchKey = "lower(" + searchKey + ")"
|
||||
}
|
||||
|
||||
chFmtVal := utils.ClickHouseFormattedValue(value)
|
||||
|
||||
@@ -93,9 +43,9 @@ func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value
|
||||
|
||||
switch op {
|
||||
case v3.FilterOperatorExists:
|
||||
return resourcePresenceExpression(key, true)
|
||||
return fmt.Sprintf("simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorNotExists:
|
||||
return resourcePresenceExpression(key, false)
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||
return fmt.Sprintf(logsOp, searchKey, chFmtVal)
|
||||
case v3.FilterOperatorContains, v3.FilterOperatorNotContains:
|
||||
@@ -160,38 +110,6 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
|
||||
// we can use lower index for =, in etc but it's difficult to do it for !=, NIN etc
|
||||
// if as x != "ABC" we cannot predict something like "not lower(labels) like '%%x%%abc%%'". It has it be "not lower(labels) like '%%x%%ABC%%'"
|
||||
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}) string {
|
||||
return buildResourceIndexFilterForKey(key, op, value, true)
|
||||
}
|
||||
|
||||
func buildResourceIndexFilterForKey(key string, op v3.FilterOperator, value interface{}, resolveFamily bool) string {
|
||||
members := []string{key}
|
||||
if resolveFamily {
|
||||
members = resourceSemconvMembers(key)
|
||||
}
|
||||
if len(members) > 1 {
|
||||
switch op {
|
||||
case v3.FilterOperatorNotEqual,
|
||||
v3.FilterOperatorNotLike,
|
||||
v3.FilterOperatorNotILike,
|
||||
v3.FilterOperatorNotContains,
|
||||
v3.FilterOperatorNotExists,
|
||||
v3.FilterOperatorNotRegex,
|
||||
v3.FilterOperatorNotIn:
|
||||
return ""
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if condition := buildResourceIndexFilterForKey(member, op, value, false); condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
// not using clickhouseFormattedValue as we don't wan't the quotes
|
||||
strVal := fmt.Sprintf("%s", value)
|
||||
fmtValEscapedForContains := utils.QuoteEscapedStringForContains(strVal, true)
|
||||
@@ -288,31 +206,14 @@ func buildResourceFiltersFromGroupBy(groupBy []v3.AttributeKey) []string {
|
||||
if attr.Type != v3.AttributeKeyTypeResource {
|
||||
continue
|
||||
}
|
||||
members := resourceSemconvMembers(attr.Key)
|
||||
if len(members) == 1 {
|
||||
conditions = append(conditions, fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", attr.Key, attr.Key))
|
||||
continue
|
||||
}
|
||||
indexConditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
indexConditions = append(indexConditions, fmt.Sprintf("labels like '%%%s%%'", member))
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("(%s AND (%s))", resourcePresenceExpression(attr.Key, true), strings.Join(indexConditions, " OR ")))
|
||||
conditions = append(conditions, fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", attr.Key, attr.Key))
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
func buildResourceFiltersFromAggregateAttribute(aggregateAttribute v3.AttributeKey) string {
|
||||
if aggregateAttribute.Key != "" && aggregateAttribute.Type == v3.AttributeKeyTypeResource {
|
||||
members := resourceSemconvMembers(aggregateAttribute.Key)
|
||||
if len(members) == 1 {
|
||||
return fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", aggregateAttribute.Key, aggregateAttribute.Key)
|
||||
}
|
||||
indexConditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
indexConditions = append(indexConditions, fmt.Sprintf("labels like '%%%s%%'", member))
|
||||
}
|
||||
return fmt.Sprintf("(%s AND (%s))", resourcePresenceExpression(aggregateAttribute.Key, true), strings.Join(indexConditions, " OR "))
|
||||
return fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", aggregateAttribute.Key, aggregateAttribute.Key)
|
||||
}
|
||||
|
||||
return ""
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_buildResourceFilter(t *testing.T) {
|
||||
@@ -554,38 +552,3 @@ func Test_buildResourceSubQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticConventionResourceFamily(t *testing.T) {
|
||||
const resolvedValue = "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''))"
|
||||
|
||||
for _, requestedName := range []string{"deployment.environment.name", "deployment.environment"} {
|
||||
t.Run(requestedName, func(t *testing.T) {
|
||||
assert.Equal(t, resolvedValue+" = 'production'", buildResourceFilter("=", requestedName, v3.FilterOperatorEqual, "production"))
|
||||
assert.Equal(t, "(simpleJSONHas(labels, 'deployment.environment.name') OR simpleJSONHas(labels, 'deployment.environment'))", buildResourceFilter("", requestedName, v3.FilterOperatorExists, nil))
|
||||
assert.Equal(t, "(not simpleJSONHas(labels, 'deployment.environment.name') AND not simpleJSONHas(labels, 'deployment.environment'))", buildResourceFilter("", requestedName, v3.FilterOperatorNotExists, nil))
|
||||
assert.Equal(t, "(labels like '%deployment.environment.name\":\"production%' OR labels like '%deployment.environment\":\"production%')", buildResourceIndexFilter(requestedName, v3.FilterOperatorEqual, "production"))
|
||||
assert.Empty(t, buildResourceIndexFilter(requestedName, v3.FilterOperatorNotEqual, "production"), "negative family filter must not use a rejecting index hint")
|
||||
})
|
||||
}
|
||||
|
||||
filters, err := buildResourceFiltersFromFilterItems(&v3.FilterSet{Items: []v3.FilterItem{{
|
||||
Key: v3.AttributeKey{
|
||||
Key: "deployment.environment.name",
|
||||
DataType: v3.AttributeKeyDataTypeString,
|
||||
Type: v3.AttributeKeyTypeResource,
|
||||
},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: "production",
|
||||
}}})
|
||||
require.NoError(t, err, "family filter items must build before their output is inspected")
|
||||
wantFilters := []string{
|
||||
resolvedValue + " = 'production'",
|
||||
"(labels like '%deployment.environment.name\":\"production%' OR labels like '%deployment.environment\":\"production%')",
|
||||
}
|
||||
assert.Equal(t, wantFilters, filters)
|
||||
|
||||
wantPresence := "((simpleJSONHas(labels, 'deployment.environment.name') OR simpleJSONHas(labels, 'deployment.environment')) AND (labels like '%deployment.environment.name%' OR labels like '%deployment.environment%'))"
|
||||
groupBy := buildResourceFiltersFromGroupBy([]v3.AttributeKey{{Key: "deployment.environment", Type: v3.AttributeKeyTypeResource}})
|
||||
assert.Equal(t, []string{wantPresence}, groupBy)
|
||||
assert.Equal(t, wantPresence, buildResourceFiltersFromAggregateAttribute(v3.AttributeKey{Key: "deployment.environment.name", Type: v3.AttributeKeyTypeResource}))
|
||||
}
|
||||
|
||||
@@ -6,32 +6,16 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
columns = serviceMapColumns()
|
||||
columns = map[string]struct{}{
|
||||
"deployment_environment": {},
|
||||
"k8s_cluster_name": {},
|
||||
"k8s_namespace_name": {},
|
||||
}
|
||||
)
|
||||
|
||||
func serviceMapColumns() map[string]string {
|
||||
columns := map[string]string{
|
||||
"k8s_cluster_name": "k8s_cluster_name",
|
||||
"k8s_namespace_name": "k8s_namespace_name",
|
||||
}
|
||||
|
||||
// Dependency-graph rows keep their historical physical column name. Both
|
||||
// semantic-convention request spellings target that same derived column.
|
||||
for _, member := range semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}) {
|
||||
columns[strings.ReplaceAll(member, ".", "_")] = "deployment_environment"
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
var filterQuery string
|
||||
var namedArgs []interface{}
|
||||
@@ -40,40 +24,39 @@ func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
operator := tag.GetOperator()
|
||||
value := tag.GetValues()
|
||||
|
||||
column, ok := columns[key]
|
||||
if !ok {
|
||||
if _, ok := columns[key]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case model.InOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotInOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.EqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotEqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.ContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.NotContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.StartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.NotStartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.ExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", column)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", key)
|
||||
case model.NotExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", column)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", key)
|
||||
}
|
||||
}
|
||||
return filterQuery, namedArgs
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildServiceMapQueryAcceptsEnvironmentFamily(t *testing.T) {
|
||||
for _, requestedName := range []string{"deployment.environment.name", "deployment.environment"} {
|
||||
t.Run(requestedName, func(t *testing.T) {
|
||||
tags := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
|
||||
Key: requestedName,
|
||||
StringValues: []string{"production"},
|
||||
Operator: model.InOperator,
|
||||
TagType: model.ResourceAttributeTagType,
|
||||
})}
|
||||
|
||||
query, args := BuildServiceMapQuery(tags)
|
||||
argName := "deployment_environment"
|
||||
if requestedName == "deployment.environment.name" {
|
||||
argName = "deployment_environment_name"
|
||||
}
|
||||
assert.Equal(t, " AND deployment_environment IN @"+argName, query)
|
||||
require.Len(t, args, 1)
|
||||
named, ok := args[0].(driver.NamedValue)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, argName, named.Name)
|
||||
assert.Equal(t, []interface{}{"production"}, named.Value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package querybuilder_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTraceFamilyUsesMaterializedHistoricalMember(t *testing.T) {
|
||||
current := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
historical := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
}
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
current.Name,
|
||||
telemetrytypes.FieldContextAttribute,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
|
||||
matches := querybuilder.MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
historical.Name: {historical},
|
||||
})
|
||||
require.Len(t, matches, 1, "family metadata should resolve to one logical field")
|
||||
expression, err := tracestelemetryschema.NewFieldMapper().FieldFor(context.Background(), valuer.UUID{}, 0, 0, matches[0])
|
||||
require.NoError(t, err, "resolved trace family should map to a value expression")
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(`attribute_string_deployment$$environment`, ''), '')",
|
||||
expression,
|
||||
"family expression should retain the promoted historical member",
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -984,105 +982,25 @@ func assignIfEmpty(s *string, value string) {
|
||||
// MatchingFieldKeys returns the field keys from the map that match the given key,
|
||||
// honoring any context/data type the user specified.
|
||||
func MatchingFieldKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
}
|
||||
members := []string{field.Name}
|
||||
// Only trace field mappers understand semantic-convention families today.
|
||||
// Logs and metrics must keep using the requested spelling until theirs land.
|
||||
if field.Signal == telemetrytypes.SignalUnspecified || field.Signal == telemetrytypes.SignalTraces {
|
||||
members = semconv.Members(semconv.KindAttribute, selector)
|
||||
}
|
||||
isFamily := len(members) > 1
|
||||
fieldKeysForName := make([]*telemetrytypes.TelemetryFieldKey, 0)
|
||||
indexByIdentity := make(map[string]int)
|
||||
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
|
||||
|
||||
appendMatches := func(lookupName string, memberName string, contextAlreadyMatched bool) {
|
||||
for _, item := range fieldKeys[lookupName] {
|
||||
if !contextAlreadyMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
|
||||
continue
|
||||
}
|
||||
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
|
||||
continue
|
||||
}
|
||||
|
||||
// A wildcard lookup may have found a same-named field in a scope where
|
||||
// this family does not apply. Keep exact names, but reject cross-member
|
||||
// matches outside the generated family scope.
|
||||
traceFamilyMatch := isFamily && item.Signal == telemetrytypes.SignalTraces
|
||||
if memberName != field.Name {
|
||||
if !traceFamilyMatch {
|
||||
continue
|
||||
}
|
||||
itemSelector := telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: item.FieldContext,
|
||||
}
|
||||
if !slices.Contains(semconv.Members(semconv.KindAttribute, itemSelector), memberName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
physicalMembers := item.SemconvMembers
|
||||
if len(physicalMembers) == 0 {
|
||||
physicalMembers = []string{memberName}
|
||||
}
|
||||
materializedColumns := maps.Clone(item.SemconvMaterializedColumns)
|
||||
if item.Materialized {
|
||||
if materializedColumns == nil {
|
||||
materializedColumns = make(map[string]string)
|
||||
}
|
||||
physicalKey := *item
|
||||
physicalKey.Name = memberName
|
||||
materializedColumns[memberName] = strings.Trim(telemetrytypes.FieldKeyToMaterializedColumnName(&physicalKey), "`")
|
||||
}
|
||||
identity := item.Signal.StringValue() + ";" + item.FieldContext.StringValue() + ";" + item.FieldDataType.StringValue()
|
||||
if traceFamilyMatch {
|
||||
if index, found := indexByIdentity[identity]; found {
|
||||
for _, physicalMember := range physicalMembers {
|
||||
if !slices.Contains(fieldKeysForName[index].SemconvMembers, physicalMember) {
|
||||
fieldKeysForName[index].SemconvMembers = append(fieldKeysForName[index].SemconvMembers, physicalMember)
|
||||
}
|
||||
}
|
||||
if len(materializedColumns) > 0 {
|
||||
if fieldKeysForName[index].SemconvMaterializedColumns == nil {
|
||||
fieldKeysForName[index].SemconvMaterializedColumns = make(map[string]string)
|
||||
}
|
||||
maps.Copy(fieldKeysForName[index].SemconvMaterializedColumns, materializedColumns)
|
||||
}
|
||||
continue
|
||||
}
|
||||
indexByIdentity[identity] = len(fieldKeysForName)
|
||||
}
|
||||
resolved := *item
|
||||
// The requested spelling is the response identity. Field mappers use
|
||||
// it to resolve the available family members current-first.
|
||||
if traceFamilyMatch {
|
||||
resolved.Name = field.Name
|
||||
resolved.SemconvMembers = slices.Clone(physicalMembers)
|
||||
resolved.SemconvMaterializedColumns = materializedColumns
|
||||
// Materialization is member-specific after family keys are merged.
|
||||
resolved.Materialized = false
|
||||
}
|
||||
fieldKeysForName = append(fieldKeysForName, &resolved)
|
||||
// match by name; keep items whose context and data type match (unspecified matches any)
|
||||
for _, item := range fieldKeys[field.Name] {
|
||||
if (field.FieldContext == telemetrytypes.FieldContextUnspecified || field.FieldContext == item.FieldContext) &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || field.FieldDataType == item.FieldDataType) {
|
||||
fieldKeysForName = append(fieldKeysForName, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Members are current-first, so metadata from the current key wins when
|
||||
// both spellings describe the same signal/context/type.
|
||||
for _, member := range members {
|
||||
appendMatches(member, member, false)
|
||||
}
|
||||
|
||||
// A context may have been split off a name that legitimately contained it
|
||||
// (e.g. `attribute.key`); preserve that historical alternate reading for
|
||||
// every family member.
|
||||
// A context may have been split off a name that legitimately contained it (e.g.
|
||||
// `attribute.key`); also look up the context-prefixed name so both readings resolve.
|
||||
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
for _, member := range members {
|
||||
appendMatches(fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), member), member, true)
|
||||
contextPrefixedFieldName := fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), field.Name)
|
||||
for _, item := range fieldKeys[contextPrefixedFieldName] {
|
||||
// Context already matched via the lookup key; only data type needs checking.
|
||||
if field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || item.FieldDataType == field.FieldDataType {
|
||||
fieldKeysForName = append(fieldKeysForName, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPrepareWhereClause_EmptyVariableList ensures PrepareWhereClause errors when a variable has an empty list value.
|
||||
@@ -686,118 +685,6 @@ func TestVisitKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchingFieldKeysResolvesCurrentTraceNameFromOldMetadata(t *testing.T) {
|
||||
old := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Description: "old metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
"deployment.environment.name",
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
|
||||
matches := MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{old.Name: {old}})
|
||||
|
||||
require.Len(t, matches, 1, "trace family lookup must resolve before inspecting metadata")
|
||||
assert.Equal(t, "deployment.environment.name", matches[0].Name)
|
||||
assert.Equal(t, "old metadata", matches[0].Description)
|
||||
assert.Equal(t, []string{"deployment.environment"}, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
func TestMatchingFieldKeysUsesCurrentTraceMetadataForOldName(t *testing.T) {
|
||||
current := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Description: "current metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
old := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Description: "old metadata",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
old.Name,
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
|
||||
matches := MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
old.Name: {old},
|
||||
})
|
||||
|
||||
require.Len(t, matches, 1, "trace family lookup must resolve before inspecting metadata")
|
||||
assert.Equal(t, old.Name, matches[0].Name)
|
||||
assert.Equal(t, "current metadata", matches[0].Description)
|
||||
assert.Equal(t, []string{current.Name, old.Name}, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
func TestMatchingFieldKeysKeepsLogSemconvNamesLiteral(t *testing.T) {
|
||||
current := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
old := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
current.Name,
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
|
||||
matches := MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
old.Name: {old},
|
||||
})
|
||||
|
||||
require.Len(t, matches, 1, "log lookup must keep the requested spelling literal")
|
||||
assert.Equal(t, current.Name, matches[0].Name)
|
||||
assert.Empty(t, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
func TestMatchingFieldKeysKeepsMetricSemconvNamesLiteral(t *testing.T) {
|
||||
current := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
old := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
requested := telemetrytypes.NewTelemetryFieldKey(
|
||||
current.Name,
|
||||
telemetrytypes.FieldContextResource,
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
)
|
||||
|
||||
matches := MatchingFieldKeys(requested, map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
old.Name: {old},
|
||||
})
|
||||
|
||||
require.Len(t, matches, 1, "metric lookup must keep the requested spelling literal")
|
||||
assert.Equal(t, current.Name, matches[0].Name)
|
||||
assert.Empty(t, matches[0].SemconvMembers)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestVisitComparison
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -237,7 +237,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
|
||||
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
|
||||
sqlmigration.NewMigrateDeploymentEnvironmentQuickFilterFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
const deploymentEnvironmentCurrent = "deployment.environment.name"
|
||||
|
||||
type migrateDeploymentEnvironmentQuickFilter struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type semconvQuickFilterRow struct {
|
||||
bun.BaseModel `bun:"table:quick_filter"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Filter string `bun:"filter"`
|
||||
}
|
||||
|
||||
func NewMigrateDeploymentEnvironmentQuickFilterFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("migrate_semconv_quick_filter"),
|
||||
func(_ context.Context, settings factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &migrateDeploymentEnvironmentQuickFilter{logger: settings.Logger}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *migrateDeploymentEnvironmentQuickFilter) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func deploymentEnvironmentOld() string {
|
||||
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: deploymentEnvironmentCurrent,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
if len(members) < 2 {
|
||||
return deploymentEnvironmentCurrent
|
||||
}
|
||||
return members[1]
|
||||
}
|
||||
|
||||
func rewriteQuickFilterSemconv(filterJSON, from, to string) (string, bool, error) {
|
||||
var filters []map[string]any
|
||||
if err := json.Unmarshal([]byte(filterJSON), &filters); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, filter := range filters {
|
||||
if key, ok := filter["key"].(string); ok && key == from {
|
||||
filter["key"] = to
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return filterJSON, false, nil
|
||||
}
|
||||
|
||||
rewritten, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return string(rewritten), true, nil
|
||||
}
|
||||
|
||||
func (migration *migrateDeploymentEnvironmentQuickFilter) migrate(ctx context.Context, db *bun.DB, from, to string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
rows := make([]*semconvQuickFilterRow, 0)
|
||||
if err := tx.NewSelect().
|
||||
Model(&rows).
|
||||
Where("signal IN (?)", bun.In([]string{"traces", "api_monitoring", "exceptions"})).
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
rewritten, changed, err := rewriteQuickFilterSemconv(row.Filter, from, to)
|
||||
if err != nil {
|
||||
// Quick filters are user-editable. One malformed legacy row must not
|
||||
// prevent the application from starting or block every other org's
|
||||
// migration.
|
||||
if migration.logger != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping quick filter with unreadable filter JSON",
|
||||
slog.String("quick_filter_id", row.ID), slog.Any("error", err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*semconvQuickFilterRow)(nil)).
|
||||
Set("filter = ?", rewritten).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *migrateDeploymentEnvironmentQuickFilter) Up(ctx context.Context, db *bun.DB) error {
|
||||
return migration.migrate(ctx, db, deploymentEnvironmentOld(), deploymentEnvironmentCurrent)
|
||||
}
|
||||
|
||||
func (migration *migrateDeploymentEnvironmentQuickFilter) Down(ctx context.Context, db *bun.DB) error {
|
||||
return migration.migrate(ctx, db, deploymentEnvironmentCurrent, deploymentEnvironmentOld())
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRewriteQuickFilterSemconv(t *testing.T) {
|
||||
oldName := deploymentEnvironmentOld()
|
||||
input := `[{"key":"service.name","dataType":"string","type":"resource"},{"key":"` + oldName + `","dataType":"string","type":"resource","custom":true}]`
|
||||
|
||||
rewritten, changed, err := rewriteQuickFilterSemconv(input, oldName, deploymentEnvironmentCurrent)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, changed)
|
||||
|
||||
var filters []map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(rewritten), &filters))
|
||||
assert.Equal(t, "service.name", filters[0]["key"])
|
||||
assert.Equal(t, deploymentEnvironmentCurrent, filters[1]["key"])
|
||||
assert.Equal(t, true, filters[1]["custom"], "unknown filter properties must be preserved")
|
||||
|
||||
restored, changed, err := rewriteQuickFilterSemconv(rewritten, deploymentEnvironmentCurrent, oldName)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, changed)
|
||||
require.NoError(t, json.Unmarshal([]byte(restored), &filters))
|
||||
assert.Equal(t, oldName, filters[1]["key"])
|
||||
}
|
||||
|
||||
func TestRewriteQuickFilterSemconvNoop(t *testing.T) {
|
||||
input := `[{"key":"service.name","dataType":"string","type":"resource"}]`
|
||||
rewritten, changed, err := rewriteQuickFilterSemconv(input, deploymentEnvironmentOld(), deploymentEnvironmentCurrent)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, changed)
|
||||
assert.Equal(t, input, rewritten)
|
||||
}
|
||||
@@ -44,73 +44,6 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
|
||||
return fmt.Sprintf(`%%%s%%`, key.Name)
|
||||
}
|
||||
|
||||
func memberKey(key *telemetrytypes.TelemetryFieldKey, name string) *telemetrytypes.TelemetryFieldKey {
|
||||
member := *key
|
||||
member.Name = name
|
||||
return &member
|
||||
}
|
||||
|
||||
func keyIndexCondition(sb *sqlbuilder.SelectBuilder, column string, key *telemetrytypes.TelemetryFieldKey, members []string) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
conditions = append(conditions, sb.Like(column, keyIndexFilter(memberKey(key, member))))
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func valueIndexCondition(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
column string,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
members []string,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
caseInsensitive bool,
|
||||
) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
patterns := valueForIndexFilter(op, memberKey(key, member), value)
|
||||
switch values := patterns.(type) {
|
||||
case []string:
|
||||
for _, pattern := range values {
|
||||
conditions = append(conditions, sb.Like(column, pattern))
|
||||
}
|
||||
default:
|
||||
if caseInsensitive {
|
||||
conditions = append(conditions, sb.ILike(column, values))
|
||||
} else {
|
||||
conditions = append(conditions, sb.Like(column, values))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, members []string, exists bool) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
field := fmt.Sprintf("simpleJSONHas(%s, '%s')", column, member)
|
||||
if exists {
|
||||
conditions = append(conditions, sb.E(field, true))
|
||||
} else {
|
||||
conditions = append(conditions, sb.NE(field, true))
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
return sb.And(conditions...)
|
||||
}
|
||||
|
||||
// SkipResourceFilter is not applicable here: the fingerprint table only stores resource attributes.
|
||||
func (b *defaultConditionBuilder) ConditionFor(
|
||||
ctx context.Context,
|
||||
@@ -182,10 +115,8 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
// as we have not changed the resource column in the resource fingerprint table.
|
||||
column := columns[0]
|
||||
|
||||
members := resourceSemconvMembers(key)
|
||||
isFamily := len(members) > 1
|
||||
keyIdxFilter := keyIndexCondition(sb, column.Name, key, members)
|
||||
singleValueIndexFilter := valueForIndexFilter(op, memberKey(key, members[0]), value)
|
||||
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
|
||||
valueForIndexFilter := valueForIndexFilter(op, key, value)
|
||||
|
||||
fieldName, err := b.fm.FieldFor(ctx, valuer.UUID{}, startNs, endNs, key)
|
||||
if err != nil {
|
||||
@@ -197,15 +128,12 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.E(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, false),
|
||||
sb.Like(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
if isFamily {
|
||||
return sb.NE(fieldName, formattedValue), nil
|
||||
}
|
||||
return sb.And(
|
||||
sb.NE(fieldName, formattedValue),
|
||||
sb.NotLike(column.Name, singleValueIndexFilter),
|
||||
sb.NotLike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
return sb.And(sb.GT(fieldName, formattedValue), keyIdxFilter), nil
|
||||
@@ -220,7 +148,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, true),
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotLike, qbtypes.FilterOperatorNotILike:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
@@ -257,11 +185,13 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
inConditions = append(inConditions, sb.E(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.Or(inConditions...)
|
||||
mainCondition = sb.And(
|
||||
mainCondition,
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, false),
|
||||
)
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.Like(column.Name, v))
|
||||
}
|
||||
}
|
||||
mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...))
|
||||
|
||||
return mainCondition, nil
|
||||
case qbtypes.FilterOperatorNotIn:
|
||||
@@ -274,11 +204,8 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
notInConditions = append(notInConditions, sb.NE(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.And(notInConditions...)
|
||||
if isFamily {
|
||||
return mainCondition, nil
|
||||
}
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := singleValueIndexFilter.([]string); ok {
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.NotLike(column.Name, v))
|
||||
}
|
||||
@@ -288,11 +215,13 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
|
||||
case qbtypes.FilterOperatorExists:
|
||||
return sb.And(
|
||||
memberPresenceCondition(sb, column.Name, members, true),
|
||||
sb.E(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
keyIdxFilter,
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
return memberPresenceCondition(sb, column.Name, members, false), nil
|
||||
return sb.And(
|
||||
sb.NE(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
), nil
|
||||
|
||||
case qbtypes.FilterOperatorRegexp:
|
||||
return sb.And(
|
||||
@@ -308,7 +237,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, fmt.Sprintf(`%%%s%%`, formattedValue)),
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, key, members, op, value, true),
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
|
||||
@@ -220,216 +220,3 @@ func TestConditionBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamilyPositiveFilterExcludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)")
|
||||
assert.Equal(t, []any{
|
||||
"production",
|
||||
"%deployment.environment.name%",
|
||||
"%deployment.environment%",
|
||||
`%deployment.environment.name":"production%`,
|
||||
`%deployment.environment":"production%`,
|
||||
}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotEqualIncludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "staging", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') <> ?")
|
||||
assert.Equal(t, []any{"staging"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotInIncludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotIn, []any{"staging", "dev"}, sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "(COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') <> ? AND COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') <> ?)")
|
||||
assert.Equal(t, []any{"staging", "dev"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotLikeIncludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotLike, "%stag%", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "LOWER(COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '')) NOT LIKE LOWER(?)")
|
||||
assert.Equal(t, []any{"%stag%"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotContainsIncludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotContains, "stag", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "LOWER(COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '')) NOT LIKE LOWER(?)")
|
||||
assert.Equal(t, []any{"%stag%"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotRegexpIncludesKeylessRows(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotRegexp, "stag.*", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "NOT match(COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), ''), ?)")
|
||||
assert.Equal(t, []any{"stag.*"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyExistsChecksEveryMember(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorExists, nil, sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "(simpleJSONHas(labels, 'deployment.environment.name') = ? OR simpleJSONHas(labels, 'deployment.environment') = ?) AND (labels LIKE ? OR labels LIKE ?)")
|
||||
assert.Equal(t, []any{true, true, "%deployment.environment.name%", "%deployment.environment%"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyNotExistsChecksEveryMember(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "simpleJSONHas(labels, 'deployment.environment.name') <> ? AND simpleJSONHas(labels, 'deployment.environment') <> ?")
|
||||
assert.Equal(t, []any{true, true}, args)
|
||||
}
|
||||
|
||||
func TestLogSemconvNameStaysLiteral(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, _, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Contains(t, sql, "simpleJSONExtractString(labels, 'deployment.environment.name') = ? AND labels LIKE ? AND labels LIKE ?")
|
||||
assert.NotContains(t, sql, "deployment.environment')")
|
||||
assert.Equal(t, []any{"production", "%deployment.environment.name%", `%deployment.environment.name":"production%`}, args)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package resourcefilter
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -34,20 +32,6 @@ func NewFieldMapper() *defaultFieldMapper {
|
||||
return &defaultFieldMapper{}
|
||||
}
|
||||
|
||||
func resourceSemconvMembers(key *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if key.Signal != telemetrytypes.SignalTraces || key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return []string{key.Name}
|
||||
}
|
||||
if len(key.SemconvMembers) > 0 {
|
||||
return key.SemconvMembers
|
||||
}
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *defaultFieldMapper) getColumn(
|
||||
_ context.Context,
|
||||
_, _ uint64,
|
||||
@@ -82,15 +66,7 @@ func (m *defaultFieldMapper) FieldFor(
|
||||
return "", err
|
||||
}
|
||||
if key.FieldContext == telemetrytypes.FieldContextResource {
|
||||
members := resourceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(%s, '%s'), '')", columns[0].Name, member))
|
||||
}
|
||||
return "COALESCE(" + strings.Join(values, ", ") + ", '')", nil
|
||||
}
|
||||
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, members[0]), nil
|
||||
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
|
||||
}
|
||||
return columns[0].Name, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
@@ -152,14 +151,6 @@ func (t *telemetryMetaStore) tracesTblStatementToFieldKeys(ctx context.Context)
|
||||
return materialisedKeys, nil
|
||||
}
|
||||
|
||||
func traceSemconvMembers(name string, fieldContext telemetrytypes.FieldContext) []string {
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: fieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
// getTracesKeys returns the keys from the spans that match the field selection criteria.
|
||||
func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
@@ -1329,17 +1320,6 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// GetKeys backs key suggestions and remains literal. The internal multi-key
|
||||
// lookup expands only trace selectors so query builders see stored family members.
|
||||
expandedTraceSelectors := make([]*telemetrytypes.FieldKeySelector, 0, len(tracesSelectors))
|
||||
for _, selector := range tracesSelectors {
|
||||
for _, member := range traceSemconvMembers(selector.Name, selector.FieldContext) {
|
||||
memberSelector := *selector
|
||||
memberSelector.Name = member
|
||||
expandedTraceSelectors = append(expandedTraceSelectors, &memberSelector)
|
||||
}
|
||||
}
|
||||
tracesSelectors = expandedTraceSelectors
|
||||
tracesKeys, tracesComplete, err := t.getTracesKeys(ctx, tracesSelectors)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -1562,16 +1542,7 @@ func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, fieldValueS
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.tracesDBName + "." + t.tracesFieldsTblName)
|
||||
|
||||
if fieldValueSelector.Name != "" {
|
||||
members := traceSemconvMembers(fieldValueSelector.Name, fieldValueSelector.FieldContext)
|
||||
if len(members) == 1 {
|
||||
sb.Where(sb.E("tag_key", members[0]))
|
||||
} else {
|
||||
memberValues := make([]any, 0, len(members))
|
||||
for _, member := range members {
|
||||
memberValues = append(memberValues, member)
|
||||
}
|
||||
sb.Where(sb.In("tag_key", memberValues...))
|
||||
}
|
||||
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||
}
|
||||
|
||||
// now look at the field context
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -84,38 +83,3 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllValuesReturnsValuesFromEveryTraceSemconvFamilyMember(t *testing.T) {
|
||||
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, ®exMatcher{})
|
||||
mock := mockTelemetryStore.Mock()
|
||||
|
||||
metadata := NewTelemetryMetaStore(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockTelemetryStore,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
mock.ExpectQuery(`SELECT DISTINCT string_value, number_value FROM signoz_traces\.distributed_tag_attributes_v2 WHERE tag_key IN \(\?, \?\) AND tag_type = \? AND tag_data_type = \? LIMIT \?`).
|
||||
WithArgs("deployment.environment.name", "deployment.environment", "resource", "string", 51).
|
||||
WillReturnRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "string_value", Type: "String"},
|
||||
{Name: "number_value", Type: "Float64"},
|
||||
}, [][]any{
|
||||
{"production", float64(0)},
|
||||
{"staging", float64(0)},
|
||||
{"production", float64(0)},
|
||||
}))
|
||||
|
||||
values, complete, err := metadata.GetAllValues(context.Background(), valuer.UUID{}, &telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: &telemetrytypes.FieldKeySelector{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Name: "deployment.environment",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, complete)
|
||||
assert.Equal(t, []string{"production", "staging"}, values.StringValues)
|
||||
assert.NoError(t, mock.ExpectationsWereMet(), "all expected metadata queries should be executed")
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ import (
|
||||
)
|
||||
|
||||
type conditionBuilder struct {
|
||||
fm *fieldMapper
|
||||
fm qbtypes.FieldMapper
|
||||
}
|
||||
|
||||
var _ qbtypes.ConditionBuilder = (*conditionBuilder)(nil)
|
||||
|
||||
func NewConditionBuilder(fm *fieldMapper) *conditionBuilder {
|
||||
func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
|
||||
return &conditionBuilder{fm: fm}
|
||||
}
|
||||
|
||||
@@ -154,17 +154,6 @@ func (c *conditionBuilder) conditionFor(
|
||||
// in the query builder, `exists` and `not exists` are used for
|
||||
// key membership checks, so depending on the column type, the condition changes
|
||||
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||
// A semantic-convention family is represented by one current-first value
|
||||
// expression, but presence still has to inspect every physical member. In
|
||||
// particular, using ExistsExpression below with the requested key would add
|
||||
// a mapContains check for only that spelling and reject fallback-only rows.
|
||||
if isTraceSemconvFamily(key) {
|
||||
pred, err := c.fm.existsExpressionFor(ctx, orgID, startNs, endNs, key, operator == qbtypes.FilterOperatorExists)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sqlbuilder.Escape(pred), nil
|
||||
}
|
||||
columns, err := c.fm.ColumnFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -308,82 +308,6 @@ func TestConditionFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionForSemconvFamilyPositiveFilterChecksPresence(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, warnings, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, sql, "(COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], ''), '') = ? AND ((mapContains(attributes_string, 'deployment.environment.name') OR mapContains(attributes_string, 'deployment.environment'))))")
|
||||
assert.Equal(t, []any{"production"}, args)
|
||||
}
|
||||
|
||||
func TestConditionForSemconvFamilyPreservesMaterializedMemberExistsColumn(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
SemconvMaterializedColumns: map[string]string{
|
||||
"deployment.environment": "attribute_string_deployment$$environment",
|
||||
},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, warnings, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, sql, "`attribute_string_deployment$$environment_exists`")
|
||||
assert.NotContains(t, sql, "`attribute_string_deployment$environment_exists`")
|
||||
assert.Equal(t, []any{"production"}, args)
|
||||
}
|
||||
|
||||
func TestConditionForSemconvFamilyNotExistsChecksEveryMember(t *testing.T) {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name", "deployment.environment"},
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
conditions, warnings, err := NewConditionBuilder(NewFieldMapper()).ConditionFor(
|
||||
context.Background(), valuer.UUID{}, 0, 0, key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}},
|
||||
qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, sql, "NOT (((mapContains(attributes_string, 'deployment.environment.name') OR mapContains(attributes_string, 'deployment.environment'))))")
|
||||
assert.Empty(t, args)
|
||||
}
|
||||
|
||||
func TestConditionForResourceWithEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
releaseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -168,44 +167,6 @@ func NewFieldMapper() *fieldMapper {
|
||||
return &fieldMapper{}
|
||||
}
|
||||
|
||||
func traceSemconvMembers(key *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource && key.FieldContext != telemetrytypes.FieldContextAttribute {
|
||||
return []string{key.Name}
|
||||
}
|
||||
if len(key.SemconvMembers) > 0 {
|
||||
return key.SemconvMembers
|
||||
}
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
func isTraceSemconvFamily(key *telemetrytypes.TelemetryFieldKey) bool {
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource && key.FieldContext != telemetrytypes.FieldContextAttribute {
|
||||
return false
|
||||
}
|
||||
_, ok := semconv.Lookup(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
})
|
||||
return ok
|
||||
}
|
||||
|
||||
func traceSemconvMapMemberExpressions(columnName string, key *telemetrytypes.TelemetryFieldKey, member string) (string, string) {
|
||||
if materializedColumn, ok := key.SemconvMaterializedColumns[member]; ok {
|
||||
return fmt.Sprintf("`%s`", materializedColumn), fmt.Sprintf("`%s_exists`", materializedColumn)
|
||||
}
|
||||
if key.Materialized && key.Name == member {
|
||||
physicalKey := *key
|
||||
physicalKey.Name = member
|
||||
return telemetrytypes.FieldKeyToMaterializedColumnName(&physicalKey), telemetrytypes.FieldKeyToMaterializedColumnNameForExists(&physicalKey)
|
||||
}
|
||||
return fmt.Sprintf("%s['%s']", columnName, member), fmt.Sprintf("mapContains(%s, '%s')", columnName, member)
|
||||
}
|
||||
|
||||
func (m *fieldMapper) getColumn(
|
||||
_ context.Context,
|
||||
_, _ uint64,
|
||||
@@ -330,27 +291,10 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
members := traceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
guards := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
// The String cast is required because ClickHouse does not allow
|
||||
// Variant/Dynamic values in GROUP BY.
|
||||
value := fmt.Sprintf("%s.`%s`::String", columnName, member)
|
||||
values = append(values, fmt.Sprintf("NULLIF(%s, '')", value))
|
||||
guards = append(guards, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, member))
|
||||
}
|
||||
// Missing Dynamic paths are NULL, so this family expression must
|
||||
// retain the same NULL result as a single JSON-path lookup.
|
||||
exprs = append(exprs, "COALESCE("+strings.Join(values, ", ")+")")
|
||||
existExprs = append(existExprs, "("+strings.Join(guards, " OR ")+")")
|
||||
} else {
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once ClickHouse is updated, check whether this cast can be removed.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, members[0]))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, members[0]))
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
@@ -375,40 +319,13 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumBool:
|
||||
members := traceSemconvMembers(key)
|
||||
if len(members) > 1 {
|
||||
guards := make([]string, 0, len(members))
|
||||
memberValues := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
valueExpression, existsExpression := traceSemconvMapMemberExpressions(columnName, key, member)
|
||||
memberValues = append(memberValues, valueExpression)
|
||||
guards = append(guards, existsExpression)
|
||||
}
|
||||
if valueType.GetType() == schema.ColumnTypeEnumString {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, memberValue := range memberValues {
|
||||
values = append(values, fmt.Sprintf("NULLIF(%s, '')", memberValue))
|
||||
}
|
||||
exprs = append(exprs, "COALESCE("+strings.Join(values, ", ")+", '')")
|
||||
} else {
|
||||
branches := make([]string, 0, len(members)*2)
|
||||
for i, memberValue := range memberValues {
|
||||
branches = append(branches, guards[i], memberValue)
|
||||
}
|
||||
// Numeric and boolean maps return zero for an absent key. If a
|
||||
// family of either type is enabled, this tail must become zero too.
|
||||
exprs = append(exprs, "multiIf("+strings.Join(branches, ", ")+", NULL)")
|
||||
}
|
||||
existExprs = append(existExprs, "("+strings.Join(guards, " OR ")+")")
|
||||
} else if key.Materialized {
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
physicalKey := *key
|
||||
physicalKey.Name = members[0]
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(&physicalKey))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(&physicalKey))
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, members[0]))
|
||||
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, '%s')", columnName, members[0]))
|
||||
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)
|
||||
@@ -612,25 +529,6 @@ func (m *fieldMapper) existsExpressionFor(
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
exists bool,
|
||||
) (string, error) {
|
||||
if isTraceSemconvFamily(key) {
|
||||
_, existExprs, _, err := m.resolveColumnExprs(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(existExprs) == 0 {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "no existence expression found for field %s", key.Name)
|
||||
}
|
||||
parts := make([]string, 0, len(existExprs))
|
||||
for _, expression := range existExprs {
|
||||
parts = append(parts, "("+expression+")")
|
||||
}
|
||||
combined := strings.Join(parts, " OR ")
|
||||
if exists {
|
||||
return combined, nil
|
||||
}
|
||||
return "NOT (" + combined + ")", nil
|
||||
}
|
||||
|
||||
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
Materialized: true,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
expectedResult: "multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (mapContains(resources_string, 'deployment.environment.name') OR `resource_string_deployment$$environment_exists`), COALESCE(NULLIF(resources_string['deployment.environment.name'], ''), NULLIF(`resource_string_deployment$$environment`, ''), ''), NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -120,63 +120,6 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldForResolvesCurrentTraceSemconvAttributeName(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
|
||||
expression, err := NewFieldMapper().FieldFor(context.Background(), valuer.UUID{}, 0, 0, &key)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], ''), '')", expression)
|
||||
}
|
||||
|
||||
func TestFieldForResolvesOldTraceSemconvAttributeName(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
|
||||
expression, err := NewFieldMapper().FieldFor(context.Background(), valuer.UUID{}, 0, 0, &key)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], ''), '')", expression)
|
||||
}
|
||||
|
||||
func TestFieldForPreservesResourceStorageDefaultsForSemconvFamily(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Materialized: true,
|
||||
Evolutions: MockEvolutionData(time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)),
|
||||
}
|
||||
start := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
end := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
|
||||
expression, err := NewFieldMapper().FieldFor(context.Background(), valuer.UUID{}, start, end, &key)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (`resource_string_deployment$$environment$$name_exists` OR mapContains(resources_string, 'deployment.environment')), COALESCE(NULLIF(`resource_string_deployment$$environment$$name`, ''), NULLIF(resources_string['deployment.environment'], ''), ''), NULL)", expression)
|
||||
}
|
||||
|
||||
func TestFieldForUsesAvailableTraceSemconvMember(t *testing.T) {
|
||||
key := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
SemconvMembers: []string{"deployment.environment.name"},
|
||||
}
|
||||
|
||||
expression, err := NewFieldMapper().FieldFor(context.Background(), valuer.UUID{}, 0, 0, &key)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "attributes_string['deployment.environment.name']", expression)
|
||||
}
|
||||
|
||||
func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
releaseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
@@ -233,7 +176,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
},
|
||||
tsStart: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, ''))",
|
||||
expectedResult: "resource.`deployment.environment`::String",
|
||||
},
|
||||
{
|
||||
name: "Window straddles release - materialized resource",
|
||||
@@ -246,7 +189,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "multiIf((resource.`deployment.environment.name` IS NOT NULL OR resource.`deployment.environment` IS NOT NULL), COALESCE(NULLIF(resource.`deployment.environment.name`::String, ''), NULLIF(resource.`deployment.environment`::String, '')), (mapContains(resources_string, 'deployment.environment.name') OR `resource_string_deployment$$environment_exists`), COALESCE(NULLIF(resources_string['deployment.environment.name'], ''), NULLIF(`resource_string_deployment$$environment`, ''), ''), NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,9 @@ var (
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
StorableAuthDomain
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
|
||||
@@ -39,12 +42,16 @@ type AuthNProviderInfo struct {
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type UpdatableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config AuthDomainConfig `json:"config" required:"true"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
@@ -57,36 +64,96 @@ type StorableAuthDomain struct {
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
|
||||
// for a discriminated union. OpenAPI's discriminator requires every oneOf
|
||||
// branch to be a $ref to a named component and a sibling property whose value
|
||||
// selects the variant. ssoType is already discriminator-shaped, but the
|
||||
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
|
||||
// oidcConfig) instead of being the payload itself, so no discriminator can
|
||||
// be attached. Refactor AuthDomainConfig into an envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
|
||||
// the payload and ssoType is the discriminator.
|
||||
type AuthDomainConfig struct {
|
||||
SSOEnabled bool `json:"ssoEnabled"`
|
||||
AuthNProvider AuthNProvider `json:"ssoType"`
|
||||
SAML *SamlConfig `json:"samlConfig"`
|
||||
Google *GoogleConfig `json:"googleAuthConfig"`
|
||||
OIDC *OIDCConfig `json:"oidcConfig"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
Kind AuthNProvider `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// authDomainConfigSAML is the OpenAPI schema for an AuthDomainConfig with kind=saml.
|
||||
type authDomainConfigSAML struct {
|
||||
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
|
||||
Spec SamlConfig `json:"spec" description:"The saml configuration." required:"true"`
|
||||
}
|
||||
|
||||
// authDomainConfigGoogle is the OpenAPI schema for an AuthDomainConfig with kind=google.
|
||||
type authDomainConfigGoogle struct {
|
||||
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
|
||||
Spec GoogleConfig `json:"spec" description:"The google auth configuration." required:"true"`
|
||||
}
|
||||
|
||||
// authDomainConfigOIDC is the OpenAPI schema for an AuthDomainConfig with kind=oidc.
|
||||
type authDomainConfigOIDC struct {
|
||||
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
|
||||
Spec OIDCConfig `json:"spec" description:"The oidc configuration." required:"true"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ jsonschema.OneOfExposer = AuthDomainConfig{}
|
||||
_ jsonschema.Preparer = AuthDomainConfig{}
|
||||
)
|
||||
|
||||
// JSONSchemaOneOf returns the oneOf variants for the AuthDomainConfig discriminated union.
|
||||
// Each variant represents a different authn provider kind with its corresponding spec schema.
|
||||
func (AuthDomainConfig) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
authDomainConfigSAML{},
|
||||
authDomainConfigGoogle{},
|
||||
authDomainConfigOIDC{},
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareJSONSchema marks the schema with x-signoz-discriminator;
|
||||
// signoz.attachDiscriminators promotes it to a real OpenAPI 3
|
||||
// discriminator after reflection.
|
||||
func (AuthDomainConfig) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
if schema.ExtraProperties == nil {
|
||||
schema.ExtraProperties = map[string]any{}
|
||||
}
|
||||
|
||||
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
|
||||
"propertyName": "kind",
|
||||
"mapping": map[string]string{
|
||||
AuthNProviderSAML.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigSAML",
|
||||
AuthNProviderGoogle.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigGoogle",
|
||||
AuthNProviderOIDC.StringValue(): "#/components/schemas/AuthtypesAuthDomainConfigOIDC",
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StorableAuthDomainConfig is the JSON document persisted in StorableAuthDomain.Data.
|
||||
// Its shape (and the shapes it nests) must stay backward compatible with existing rows.
|
||||
type StorableAuthDomainConfig struct {
|
||||
SSOEnabled bool `json:"ssoEnabled"`
|
||||
AuthNProvider AuthNProvider `json:"ssoType"`
|
||||
SAML *StorableSamlConfig `json:"samlConfig"`
|
||||
Google *GoogleConfig `json:"googleAuthConfig"`
|
||||
OIDC *OIDCConfig `json:"oidcConfig"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
// storableAuthNProviderGoogle is the value persisted in ssoType for google domains,
|
||||
// kept for compatibility with rows written before the provider was renamed.
|
||||
var storableAuthNProviderGoogle = AuthNProvider{valuer.NewString("google_auth")}
|
||||
|
||||
type AuthDomain struct {
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
authDomainConfig *AuthDomainConfig
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
storableAuthDomainConfig *StorableAuthDomainConfig
|
||||
}
|
||||
|
||||
func NewAuthDomainFromConfig(name string, config *AuthDomainConfig, orgID valuer.UUID) (*AuthDomain, error) {
|
||||
data, err := json.Marshal(config)
|
||||
func NewAuthDomainFromPostableAuthDomain(postableAuthDomain *PostableAuthDomain, orgID valuer.UUID) (*AuthDomain, error) {
|
||||
storableAuthDomainConfig, err := newStorableAuthDomainConfig(postableAuthDomain.Enabled, postableAuthDomain.Config, postableAuthDomain.RoleMapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewAuthDomain(name, string(data), orgID)
|
||||
data, err := json.Marshal(storableAuthDomainConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewAuthDomain(postableAuthDomain.Name, string(data), orgID)
|
||||
}
|
||||
|
||||
func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, error) {
|
||||
@@ -107,22 +174,85 @@ func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, er
|
||||
}
|
||||
|
||||
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
|
||||
authDomainConfig := new(AuthDomainConfig)
|
||||
if err := json.Unmarshal([]byte(storableAuthDomain.Data), authDomainConfig); err != nil {
|
||||
storableAuthDomainConfig := new(StorableAuthDomainConfig)
|
||||
if err := json.Unmarshal([]byte(storableAuthDomain.Data), storableAuthDomainConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthDomain{
|
||||
storableAuthDomain: storableAuthDomain,
|
||||
authDomainConfig: authDomainConfig,
|
||||
storableAuthDomain: storableAuthDomain,
|
||||
storableAuthDomainConfig: storableAuthDomainConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
|
||||
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) (*GettableAuthDomain, error) {
|
||||
config, err := newAuthDomainConfigFromStorableAuthDomainConfig(authDomain.StorableAuthDomainConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GettableAuthDomain{
|
||||
StorableAuthDomain: *authDomain.StorableAuthDomain(),
|
||||
Config: *authDomain.AuthDomainConfig(),
|
||||
Enabled: authDomain.StorableAuthDomainConfig().SSOEnabled,
|
||||
Config: config,
|
||||
RoleMapping: authDomain.StorableAuthDomainConfig().RoleMapping,
|
||||
AuthNProviderInfo: authNProviderInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newStorableAuthDomainConfig(enabled bool, config AuthDomainConfig, roleMapping *RoleMapping) (*StorableAuthDomainConfig, error) {
|
||||
storableAuthDomainConfig := &StorableAuthDomainConfig{
|
||||
SSOEnabled: enabled,
|
||||
AuthNProvider: config.Kind,
|
||||
RoleMapping: roleMapping,
|
||||
}
|
||||
|
||||
switch config.Kind {
|
||||
case AuthNProviderSAML:
|
||||
spec, ok := config.Spec.(SamlConfig)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
|
||||
}
|
||||
|
||||
samlConfig := StorableSamlConfig(spec)
|
||||
storableAuthDomainConfig.SAML = &samlConfig
|
||||
|
||||
case AuthNProviderGoogle:
|
||||
spec, ok := config.Spec.(GoogleConfig)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
|
||||
}
|
||||
|
||||
storableAuthDomainConfig.Google = &spec
|
||||
|
||||
case AuthNProviderOIDC:
|
||||
spec, ok := config.Spec.(OIDCConfig)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
|
||||
}
|
||||
|
||||
storableAuthDomainConfig.OIDC = &spec
|
||||
|
||||
default:
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", config.Kind.StringValue())
|
||||
}
|
||||
|
||||
return storableAuthDomainConfig, nil
|
||||
}
|
||||
|
||||
func newAuthDomainConfigFromStorableAuthDomainConfig(storableAuthDomainConfig *StorableAuthDomainConfig) (AuthDomainConfig, error) {
|
||||
switch storableAuthDomainConfig.AuthNProvider {
|
||||
case AuthNProviderSAML:
|
||||
return AuthDomainConfig{Kind: AuthNProviderSAML, Spec: SamlConfig(*storableAuthDomainConfig.SAML)}, nil
|
||||
|
||||
case AuthNProviderGoogle:
|
||||
return AuthDomainConfig{Kind: AuthNProviderGoogle, Spec: *storableAuthDomainConfig.Google}, nil
|
||||
|
||||
case AuthNProviderOIDC:
|
||||
return AuthDomainConfig{Kind: AuthNProviderOIDC, Spec: *storableAuthDomainConfig.OIDC}, nil
|
||||
|
||||
default:
|
||||
return AuthDomainConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", storableAuthDomainConfig.AuthNProvider.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,17 +260,22 @@ func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
|
||||
return typ.storableAuthDomain
|
||||
}
|
||||
|
||||
func (typ *AuthDomain) AuthDomainConfig() *AuthDomainConfig {
|
||||
return typ.authDomainConfig
|
||||
func (typ *AuthDomain) StorableAuthDomainConfig() *StorableAuthDomainConfig {
|
||||
return typ.storableAuthDomainConfig
|
||||
}
|
||||
|
||||
func (typ *AuthDomain) Update(config *AuthDomainConfig) error {
|
||||
data, err := json.Marshal(config)
|
||||
func (typ *AuthDomain) Update(updatableAuthDomain *UpdatableAuthDomain) error {
|
||||
storableAuthDomainConfig, err := newStorableAuthDomainConfig(updatableAuthDomain.Enabled, updatableAuthDomain.Config, updatableAuthDomain.RoleMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.authDomainConfig = config
|
||||
data, err := json.Marshal(storableAuthDomainConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.storableAuthDomainConfig = storableAuthDomainConfig
|
||||
typ.storableAuthDomain.Data = string(data)
|
||||
typ.storableAuthDomain.UpdatedAt = time.Now()
|
||||
return nil
|
||||
@@ -163,15 +298,84 @@ func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias AuthDomainConfig
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal auth domain config")
|
||||
}
|
||||
|
||||
kindData, ok := raw["kind"]
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "kind is required")
|
||||
}
|
||||
|
||||
var kind AuthNProvider
|
||||
if err := json.Unmarshal(kindData, &kind); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal kind")
|
||||
}
|
||||
|
||||
specData, ok := raw["spec"]
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "spec is required")
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case AuthNProviderSAML:
|
||||
spec := SamlConfig{}
|
||||
if err := json.Unmarshal(specData, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.Spec = spec
|
||||
|
||||
case AuthNProviderGoogle:
|
||||
spec := GoogleConfig{}
|
||||
if err := json.Unmarshal(specData, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.Spec = spec
|
||||
|
||||
case AuthNProviderOIDC:
|
||||
spec := OIDCConfig{}
|
||||
if err := json.Unmarshal(specData, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typ.Spec = spec
|
||||
|
||||
default:
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", kind.StringValue())
|
||||
}
|
||||
|
||||
typ.Kind = kind
|
||||
return nil
|
||||
}
|
||||
|
||||
func (typ StorableAuthDomainConfig) MarshalJSON() ([]byte, error) {
|
||||
type Alias StorableAuthDomainConfig
|
||||
|
||||
temp := Alias(typ)
|
||||
if temp.AuthNProvider == AuthNProviderGoogle {
|
||||
temp.AuthNProvider = storableAuthNProviderGoogle
|
||||
}
|
||||
|
||||
return json.Marshal(temp)
|
||||
}
|
||||
|
||||
func (typ *StorableAuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias StorableAuthDomainConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.AuthNProvider == storableAuthNProviderGoogle {
|
||||
temp.AuthNProvider = AuthNProviderGoogle
|
||||
}
|
||||
|
||||
switch temp.AuthNProvider {
|
||||
case AuthNProviderGoogleAuth:
|
||||
case AuthNProviderGoogle:
|
||||
if temp.Google == nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
|
||||
}
|
||||
@@ -190,17 +394,8 @@ func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
|
||||
}
|
||||
|
||||
*typ = AuthDomainConfig(temp)
|
||||
*typ = StorableAuthDomainConfig(temp)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (AuthDomainConfig) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
SamlConfig{},
|
||||
GoogleConfig{},
|
||||
OIDCConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
type AuthDomainStore interface {
|
||||
|
||||
@@ -12,10 +12,10 @@ const wildCardDomain = "*"
|
||||
|
||||
type GoogleConfig struct {
|
||||
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
|
||||
ClientID string `json:"clientId"`
|
||||
ClientID string `json:"clientId" required:"true"`
|
||||
|
||||
// It is the application's secret.
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
ClientSecret string `json:"clientSecret" required:"true"`
|
||||
|
||||
// What is the meaning of this? Should we remove this?
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type OIDCConfig struct {
|
||||
// It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".
|
||||
Issuer string `json:"issuer"`
|
||||
Issuer string `json:"issuer" required:"true"`
|
||||
|
||||
// Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail
|
||||
// This provides a way to override the Issuer url from the .well-known/openid-configuration issuer
|
||||
@@ -16,10 +16,10 @@ type OIDCConfig struct {
|
||||
IssuerAlias string `json:"issuerAlias"`
|
||||
|
||||
// It is the application's ID.
|
||||
ClientID string `json:"clientId"`
|
||||
ClientID string `json:"clientId" required:"true"`
|
||||
|
||||
// It is the application's secret.
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
ClientSecret string `json:"clientSecret" required:"true"`
|
||||
|
||||
// Mapping of claims to the corresponding fields in the token.
|
||||
ClaimMapping AttributeMapping `json:"claimMapping"`
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type SamlConfig struct {
|
||||
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">
|
||||
SamlEntity string `json:"samlEntity"`
|
||||
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entityId}">
|
||||
EntityID string `json:"entityId" required:"true"`
|
||||
|
||||
// The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>
|
||||
SamlIdp string `json:"samlIdp"`
|
||||
// The SSO endpoint of the SAML identity provider. It can typically be found in the Location attribute of the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{location}"/>
|
||||
Location string `json:"location" required:"true"`
|
||||
|
||||
// The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>
|
||||
SamlCert string `json:"samlCert"`
|
||||
// The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{certificate}</ds:X509Certificate></ds:X509Certificate>
|
||||
Certificate string `json:"certificate" required:"true"`
|
||||
|
||||
// Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
// For providers like jumpcloud, this should be set to true.
|
||||
@@ -25,6 +25,17 @@ type SamlConfig struct {
|
||||
AttributeMapping AttributeMapping `json:"attributeMapping"`
|
||||
}
|
||||
|
||||
// StorableSamlConfig is SamlConfig in its persisted shape. It differs from SamlConfig
|
||||
// only in JSON keys, which are kept for compatibility with rows written before the
|
||||
// keys were renamed.
|
||||
type StorableSamlConfig struct {
|
||||
EntityID string `json:"samlEntity"`
|
||||
Location string `json:"samlIdp"`
|
||||
Certificate string `json:"samlCert"`
|
||||
InsecureSkipAuthNRequestsSigned bool `json:"insecureSkipAuthNRequestsSigned"`
|
||||
AttributeMapping AttributeMapping `json:"attributeMapping"`
|
||||
}
|
||||
|
||||
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias SamlConfig
|
||||
|
||||
@@ -33,24 +44,51 @@ func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.SamlEntity == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlEntity is required")
|
||||
samlConfig := SamlConfig(temp)
|
||||
if err := samlConfig.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.SamlIdp == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlIdp is required")
|
||||
*config = samlConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *StorableSamlConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias StorableSamlConfig
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.SamlCert == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
|
||||
samlConfig := SamlConfig(StorableSamlConfig(temp))
|
||||
if err := samlConfig.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.AttributeMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
|
||||
*config = StorableSamlConfig(samlConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate also assigns the default attribute mapping when none is present.
|
||||
func (config *SamlConfig) validate() error {
|
||||
if config.EntityID == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "entityId is required")
|
||||
}
|
||||
|
||||
if config.Location == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "location is required")
|
||||
}
|
||||
|
||||
if config.Certificate == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "certificate is required")
|
||||
}
|
||||
|
||||
if config.AttributeMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &config.AttributeMapping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
*config = SamlConfig(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,8 +155,6 @@ var operatorInverseMapping = map[FilterOperator]FilterOperator{
|
||||
// doesn't have value "redis"
|
||||
// Since we don't know the intent, we don't add the exists filter. They are expected
|
||||
// to add exists filter themselves if exclusion is desired.
|
||||
// Negative predicates therefore include rows where the key is absent; value
|
||||
// expressions must preserve the storage column's absent-key default.
|
||||
//
|
||||
// For the positive predicates, the key existence is implied.
|
||||
func (f FilterOperator) AddDefaultExistsFilter() bool {
|
||||
|
||||
@@ -141,7 +141,7 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
|
||||
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
tracesFilters := []map[string]interface{}{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "name", "dataType": "string", "type": "tag"},
|
||||
@@ -166,13 +166,13 @@ func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
}
|
||||
|
||||
apiMonitoringFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
}
|
||||
|
||||
exceptionsFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package quickfiltertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultTraceQuickFiltersUseCurrentEnvironmentName(t *testing.T) {
|
||||
filters, err := NewDefaultQuickFilter(valuer.GenerateUUID())
|
||||
require.NoError(t, err)
|
||||
|
||||
traceSignals := map[string]bool{
|
||||
SignalTraces.StringValue(): true,
|
||||
SignalApiMonitoring.StringValue(): true,
|
||||
SignalExceptions.StringValue(): true,
|
||||
}
|
||||
for _, filter := range filters {
|
||||
if !traceSignals[filter.Signal.StringValue()] {
|
||||
continue
|
||||
}
|
||||
var keys []v3.AttributeKey
|
||||
require.NoError(t, json.Unmarshal([]byte(filter.Filter), &keys))
|
||||
found := false
|
||||
for _, key := range keys {
|
||||
if key.Key == "deployment.environment.name" {
|
||||
found = true
|
||||
}
|
||||
assert.NotEqual(t, "deployment.environment", key.Key)
|
||||
}
|
||||
assert.True(t, found, "missing environment quick filter for %s", filter.Signal.StringValue())
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,7 @@ type TelemetryFieldKey struct {
|
||||
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
|
||||
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
|
||||
|
||||
Evolutions []*EvolutionEntry `json:"-"`
|
||||
SemconvMembers []string `json:"-"`
|
||||
// SemconvMaterializedColumns maps a physical family spelling to its
|
||||
// materialized column name. It is populated only on resolved query keys.
|
||||
SemconvMaterializedColumns map[string]string `json:"-"`
|
||||
Evolutions []*EvolutionEntry `json:"-"`
|
||||
}
|
||||
|
||||
func (f *TelemetryFieldKey) KeyNameContainsArray() bool {
|
||||
@@ -132,8 +128,6 @@ func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
|
||||
f.Materialized = src.Materialized
|
||||
f.JSONPlan = src.JSONPlan
|
||||
f.Evolutions = src.Evolutions
|
||||
f.SemconvMembers = src.SemconvMembers
|
||||
f.SemconvMaterializedColumns = src.SemconvMaterializedColumns
|
||||
}
|
||||
|
||||
func (f *TelemetryFieldKey) Equal(key *TelemetryFieldKey) bool {
|
||||
|
||||
@@ -24,6 +24,7 @@ pytest_plugins = [
|
||||
"fixtures.browser",
|
||||
"fixtures.keycloak",
|
||||
"fixtures.idp",
|
||||
"fixtures.googleidp",
|
||||
"fixtures.notification_channel",
|
||||
"fixtures.maildev",
|
||||
"fixtures.alerts",
|
||||
@@ -34,7 +35,6 @@ pytest_plugins = [
|
||||
"fixtures.role",
|
||||
"fixtures.savedview",
|
||||
"fixtures.seed_golden_dataset",
|
||||
"fixtures.semconv",
|
||||
]
|
||||
|
||||
|
||||
|
||||
220
tests/fixtures/googleidp.py
vendored
Normal file
220
tests/fixtures/googleidp.py
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import reuse, tls, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The google callback authn hardcodes Google's issuer, so the mock must be
|
||||
# reachable as accounts.google.com over TLS from the signoz container: the
|
||||
# wiremock container joins the network under that alias and serves HTTPS on 443
|
||||
# with a certificate issued by the integration CA that signoz trusts.
|
||||
ISSUER = "https://accounts.google.com"
|
||||
ISSUER_HOST = "accounts.google.com"
|
||||
|
||||
|
||||
def perform_google_login(
|
||||
signoz: types.SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
get_session_context: Callable[[str], dict],
|
||||
email: str,
|
||||
) -> str:
|
||||
"""Drive the google login flow for email and return the final redirect URL.
|
||||
|
||||
The authorize URL points at https://accounts.google.com (resolvable only
|
||||
inside the docker network), so it is rewritten to the mock's host-mapped
|
||||
port, mirroring how the oidc suite rewrites keycloak URLs.
|
||||
"""
|
||||
session_context = get_session_context(email)
|
||||
|
||||
assert len(session_context["orgs"]) == 1
|
||||
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
|
||||
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
assert url.startswith(f"{ISSUER}/")
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
authorize_url = googleidp.host_configs["8080"].get(f"{parsed_url.path}?{parsed_url.query}")
|
||||
|
||||
response = requests.get(authorize_url, allow_redirects=False, timeout=5)
|
||||
assert response.status_code == HTTPStatus.FOUND
|
||||
|
||||
callback_url = response.headers["Location"]
|
||||
assert "/api/v1/complete/google" in callback_url
|
||||
|
||||
response = requests.get(callback_url, allow_redirects=False, timeout=30)
|
||||
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||
|
||||
return response.headers["Location"]
|
||||
|
||||
|
||||
def google_oidc_mappings(email: str, name: str, hd: str, audience: str, email_verified: bool = True) -> list[Mapping]:
|
||||
"""Wiremock mappings for one Google OIDC login: discovery, an auto-approving
|
||||
authorize redirect, a token response with an RS256 id_token for the given
|
||||
identity, and the JWKS the signoz container verifies it against. The signing
|
||||
key is ephemeral — the token and JWKS stubs are always installed together."""
|
||||
signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
def base64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": audience,
|
||||
"sub": f"google-oauth2|{email}",
|
||||
"email": email,
|
||||
"email_verified": email_verified,
|
||||
"name": name,
|
||||
"hd": hd,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
}
|
||||
signing_input = base64url(json.dumps({"alg": "RS256", "kid": "googleidp-integration", "typ": "JWT"}).encode()) + "." + base64url(json.dumps(claims).encode())
|
||||
signature = signing_key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
|
||||
id_token = signing_input + "." + base64url(signature)
|
||||
|
||||
public_numbers = signing_key.public_key().public_numbers()
|
||||
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/.well-known/openid-configuration"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/o/oauth2/v2/auth",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
"response_types_supported": ["code"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"scopes_supported": ["openid", "email", "profile"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
},
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/o/oauth2/v2/auth"),
|
||||
response=MappingResponse(
|
||||
status=302,
|
||||
headers={
|
||||
# Triple-stache: redirect_uri and state are URLs; handlebars
|
||||
# would otherwise HTML-escape their special characters.
|
||||
"Location": "{{{request.query.redirect_uri}}}?code=integration-test-code&state={{{request.query.state}}}",
|
||||
},
|
||||
transformers=["response-template"],
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path="/token"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"access_token": "integration-test-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": id_token,
|
||||
},
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/jwks"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "googleidp-integration",
|
||||
"n": base64url(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, "big")),
|
||||
"e": base64url(public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, "big")),
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="googleidp", scope="package")
|
||||
def googleidp(
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
"""Wiremock impersonating Google's OIDC provider. Stubs are installed per
|
||||
test via make_http_mocks with google_oidc_mappings; port 8080 serves the
|
||||
admin API and the authorize redirect to the test process."""
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
keystore_dir = tls.ensure_server_keystore(pytestconfig, ISSUER_HOST)
|
||||
|
||||
container = DockerContainer("wiremock/wiremock:2.35.1-1")
|
||||
container.with_command(f"--https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {tls.KEYSTORE_PASSWORD} --local-response-templating")
|
||||
container.with_volume_mapping(str(keystore_dir), "/certs", "ro")
|
||||
container.with_exposed_ports(8080)
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(ISSUER_HOST)
|
||||
container.start()
|
||||
|
||||
host = container.get_container_host_ip()
|
||||
host_port = container.get_exposed_port(8080)
|
||||
|
||||
for attempt in range(20):
|
||||
try:
|
||||
response = requests.get(f"http://{host}:{host_port}/__admin/mappings", timeout=2)
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
break
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.info("googleidp attempt %d: %s", attempt + 1, e)
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise TimeoutError("googleidp container did not become ready")
|
||||
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"8080": types.TestContainerUrlConfig("http", host, host_port),
|
||||
},
|
||||
container_configs={
|
||||
"443": types.TestContainerUrlConfig("https", ISSUER_HOST, 443),
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker) -> None:
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.containers.get(container_id=container.id).stop()
|
||||
client.containers.get(container_id=container.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info("googleidp container %s already gone", container.id)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"googleidp",
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
)
|
||||
4
tests/fixtures/idp.py
vendored
4
tests/fixtures/idp.py
vendored
@@ -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,
|
||||
)
|
||||
|
||||
66
tests/fixtures/semconv.py
vendored
66
tests/fixtures/semconv.py
vendored
@@ -1,66 +0,0 @@
|
||||
from collections.abc import Callable, Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
SEMCONV_PHASE1_CURRENT = "deployment.environment.name"
|
||||
SEMCONV_PHASE1_OLD = "deployment.environment"
|
||||
SEMCONV_PHASE1_PREFIX = "semconv-phase1"
|
||||
|
||||
|
||||
@pytest.fixture(name="semconv_phase1_data")
|
||||
def semconv_phase1_data(
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[datetime]:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=2)
|
||||
records = [
|
||||
(now - timedelta(seconds=5), "old", {SEMCONV_PHASE1_OLD: "production"}),
|
||||
(now - timedelta(seconds=4), "current", {SEMCONV_PHASE1_CURRENT: "production"}),
|
||||
(now - timedelta(seconds=3), "both", {SEMCONV_PHASE1_OLD: "production", SEMCONV_PHASE1_CURRENT: "production"}),
|
||||
(now - timedelta(seconds=2), "conflict", {SEMCONV_PHASE1_OLD: "staging", SEMCONV_PHASE1_CURRENT: "production"}),
|
||||
(now - timedelta(seconds=1), "staging", {SEMCONV_PHASE1_OLD: "staging"}),
|
||||
(now, "missing", {}),
|
||||
]
|
||||
traces = []
|
||||
for timestamp, suffix, environment in records:
|
||||
service = f"{SEMCONV_PHASE1_PREFIX}-{suffix}"
|
||||
traces.append(
|
||||
Traces(
|
||||
timestamp=timestamp,
|
||||
duration=timedelta(milliseconds=10),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=service,
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": service, **environment},
|
||||
attributes=dict(environment),
|
||||
)
|
||||
)
|
||||
insert_traces(traces)
|
||||
|
||||
# Service-map rows are derived by the collector in production. Seed the
|
||||
# derived table directly so this test isolates the backend alias allowlist;
|
||||
# the collector repository owns its write-path integration test.
|
||||
for environment, suffix in (("production", "production"), ("staging", "staging")):
|
||||
clickhouse.conn.command(
|
||||
f"""
|
||||
INSERT INTO signoz_traces.distributed_dependency_graph_minutes_v2
|
||||
(src, dest, duration_quantiles_state, error_count, total_count, timestamp,
|
||||
deployment_environment, k8s_cluster_name, k8s_namespace_name)
|
||||
SELECT
|
||||
'{SEMCONV_PHASE1_PREFIX}-map-{suffix}', '{SEMCONV_PHASE1_PREFIX}-map-child',
|
||||
quantilesState(0.5, 0.75, 0.9, 0.95, 0.99)(toFloat64(1000000)),
|
||||
toUInt64(0), toUInt64(1), toDateTime({int(now.timestamp())}),
|
||||
'{environment}', '', ''
|
||||
"""
|
||||
)
|
||||
|
||||
yield now
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
clickhouse.conn.command(f"ALTER TABLE signoz_traces.dependency_graph_minutes_v2 ON CLUSTER '{cluster}' DELETE WHERE startsWith(src, '{SEMCONV_PHASE1_PREFIX}-map-') SETTINGS mutations_sync = 1")
|
||||
9
tests/fixtures/signoz.py
vendored
9
tests/fixtures/signoz.py
vendored
@@ -11,7 +11,7 @@ import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures import reuse, tls, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -115,6 +115,13 @@ def create_signoz(
|
||||
"rw",
|
||||
)
|
||||
|
||||
# Trust the integration CA so tests can stand in for real TLS hosts
|
||||
# (e.g. the fake accounts.google.com); SSL_CERT_FILE replaces the Go
|
||||
# root pool, which is fine here since every other mocked upstream is
|
||||
# plain http.
|
||||
container.with_env("SSL_CERT_FILE", tls.CA_CONTAINER_PATH)
|
||||
container.with_volume_mapping(str(tls.ensure_ca(pytestconfig) / "ca.pem"), tls.CA_CONTAINER_PATH, "ro")
|
||||
|
||||
container.start()
|
||||
|
||||
def ready(container: DockerContainer) -> None:
|
||||
|
||||
90
tests/fixtures/tls.py
vendored
Normal file
90
tests/fixtures/tls.py
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
# The integration CA is mounted into every signoz container and trusted via
|
||||
# SSL_CERT_FILE (which replaces the Go root pool), so mocks that must be
|
||||
# reached over TLS under a real hostname (e.g. accounts.google.com) can serve
|
||||
# certificates issued by it. Material is persisted under .pytest_cache so
|
||||
# --reuse runs keep the chain the running containers already trust.
|
||||
CA_CONTAINER_PATH = "/etc/signoz-integration/ca.pem"
|
||||
KEYSTORE_PASSWORD = "password" # noqa: S105
|
||||
|
||||
|
||||
def ensure_ca(pytestconfig: pytest.Config) -> Path:
|
||||
"""Directory holding the integration CA (ca.pem + ca.key), created once."""
|
||||
ca_dir = pytestconfig.cache.mkdir("tls")
|
||||
if (ca_dir / "ca.pem").exists() and (ca_dir / "ca.key").exists():
|
||||
return ca_dir
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "signoz-integration-ca")])
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(ca_name)
|
||||
.issuer_name(ca_name)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
(ca_dir / "ca.pem").write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
(ca_dir / "ca.key").write_bytes(
|
||||
ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
|
||||
return ca_dir
|
||||
|
||||
|
||||
def ensure_server_keystore(pytestconfig: pytest.Config, hostname: str) -> Path:
|
||||
"""Directory holding a PKCS12 keystore (keystore.p12, password
|
||||
KEYSTORE_PASSWORD) with a certificate for hostname issued by the
|
||||
integration CA."""
|
||||
ca_dir = ensure_ca(pytestconfig)
|
||||
keystore_dir = pytestconfig.cache.mkdir(f"tls-{hostname}")
|
||||
if (keystore_dir / "keystore.p12").exists():
|
||||
return keystore_dir
|
||||
|
||||
ca_cert = x509.load_pem_x509_certificate((ca_dir / "ca.pem").read_bytes())
|
||||
ca_key = serialization.load_pem_private_key((ca_dir / "ca.key").read_bytes(), password=None)
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
|
||||
.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
(keystore_dir / "keystore.p12").write_bytes(
|
||||
pkcs12.serialize_key_and_certificates(
|
||||
name=hostname.encode(),
|
||||
key=leaf_key,
|
||||
cert=leaf_cert,
|
||||
cas=[ca_cert],
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(KEYSTORE_PASSWORD.encode()),
|
||||
)
|
||||
)
|
||||
|
||||
return keystore_dir
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
@@ -14,27 +15,33 @@ def test_create_and_get_domain(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Get domains which should be an empty list
|
||||
# Reruns against a reused stack find domains from previous runs; drop them
|
||||
# all so the suite starts from a clean slate.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 0
|
||||
for domain in response.json()["data"]:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Create a domain with google auth config
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "domain-google.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "google_auth",
|
||||
"googleAuthConfig": {
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"redirectURI": "redirect-uri",
|
||||
@@ -49,16 +56,16 @@ def test_create_and_get_domain(
|
||||
|
||||
# Create a domain with saml config
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "domain-saml.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -70,7 +77,7 @@ def test_create_and_get_domain(
|
||||
|
||||
# List the domains
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
@@ -86,7 +93,7 @@ def test_create_and_get_domain(
|
||||
"domain-google.integration.test",
|
||||
"domain-saml.integration.test",
|
||||
]
|
||||
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
|
||||
assert domain["config"]["kind"] in ["google", "saml"]
|
||||
|
||||
|
||||
def test_create_invalid(
|
||||
@@ -96,15 +103,15 @@ def test_create_invalid(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a domain with type saml and body for oidc, this should fail because oidcConfig is not allowed for saml
|
||||
# Create a domain with kind saml and a spec for oidc, this should fail because the spec does not match the kind
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "domain.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"oidcConfig": {
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"issuer": "issuer",
|
||||
@@ -117,18 +124,34 @@ def test_create_invalid(
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Create a domain with a kind but no spec
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "domain.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"kind": "saml",
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Create a domain with invalid name
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "$%^invalid",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -140,17 +163,17 @@ def test_create_invalid(
|
||||
|
||||
# Create a domain with no name
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
@@ -160,7 +183,7 @@ def test_create_invalid(
|
||||
|
||||
# Create a domain with no config
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "domain.integration.test",
|
||||
},
|
||||
@@ -180,21 +203,21 @@ def test_create_invalid_role_mapping(
|
||||
|
||||
# Create domain with invalid defaultRole
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "invalid-role-test.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "SUPERADMIN", # Invalid role
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "SUPERADMIN", # Invalid role
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
@@ -204,22 +227,22 @@ def test_create_invalid_role_mapping(
|
||||
|
||||
# Create domain with invalid role in groupMappings
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "invalid-group-role.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"admins": "SUPERUSER", # Invalid role
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"admins": "SUPERUSER", # Invalid role
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -231,23 +254,23 @@ def test_create_invalid_role_mapping(
|
||||
|
||||
# Valid role mapping should succeed
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "valid-role-mapping.integration.test",
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": "saml-entity",
|
||||
"samlIdp": "saml-idp",
|
||||
"samlCert": "saml-cert",
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "saml-idp",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "VIEWER",
|
||||
"groupMappings": {
|
||||
"signoz-admins": "ADMIN",
|
||||
"signoz-editors": "EDITOR",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -256,3 +279,288 @@ def test_create_invalid_role_mapping(
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "role_mapping", "expected_config", "expected_role_mapping"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "google",
|
||||
"spec": {"clientId": "client-id", "clientSecret": "client-secret"},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"redirectURI": "",
|
||||
"fetchGroups": False,
|
||||
"insecureSkipEmailVerified": False,
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="google_minimal",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"redirectURI": "https://redirect.integration.test",
|
||||
"fetchGroups": True,
|
||||
"serviceAccountJson": '{"type": "service_account"}',
|
||||
"domainToAdminEmail": {
|
||||
"roundtrip.integration.test": "admin@roundtrip.integration.test",
|
||||
"*": "fallback@roundtrip.integration.test",
|
||||
},
|
||||
"fetchTransitiveGroupMembership": True,
|
||||
"allowedGroups": ["group-one", "group-two"],
|
||||
"insecureSkipEmailVerified": True,
|
||||
},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"redirectURI": "https://redirect.integration.test",
|
||||
"fetchGroups": True,
|
||||
"serviceAccountJson": '{"type": "service_account"}',
|
||||
"domainToAdminEmail": {
|
||||
"roundtrip.integration.test": "admin@roundtrip.integration.test",
|
||||
"*": "fallback@roundtrip.integration.test",
|
||||
},
|
||||
"fetchTransitiveGroupMembership": True,
|
||||
"allowedGroups": ["group-one", "group-two"],
|
||||
"insecureSkipEmailVerified": True,
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="google_full",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
"insecureSkipAuthNRequestsSigned": False,
|
||||
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="saml_minimal",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
"insecureSkipAuthNRequestsSigned": True,
|
||||
"attributeMapping": {"email": "mail"},
|
||||
},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
"insecureSkipAuthNRequestsSigned": True,
|
||||
"attributeMapping": {"email": "mail", "name": "name", "groups": "groups", "role": "role"},
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="saml_partial_attribute_mapping",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "oidc",
|
||||
"spec": {
|
||||
"issuer": "https://issuer.integration.test",
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "oidc",
|
||||
"spec": {
|
||||
"issuer": "https://issuer.integration.test",
|
||||
"issuerAlias": "",
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"claimMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
|
||||
"insecureSkipEmailVerified": False,
|
||||
"getUserInfo": False,
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="oidc_minimal",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "oidc",
|
||||
"spec": {
|
||||
"issuer": "https://issuer.integration.test",
|
||||
"issuerAlias": "https://alias.integration.test",
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"claimMapping": {"email": "eml", "name": "nm", "groups": "grps", "role": "rl"},
|
||||
"insecureSkipEmailVerified": True,
|
||||
"getUserInfo": True,
|
||||
},
|
||||
},
|
||||
None,
|
||||
{
|
||||
"kind": "oidc",
|
||||
"spec": {
|
||||
"issuer": "https://issuer.integration.test",
|
||||
"issuerAlias": "https://alias.integration.test",
|
||||
"clientId": "client-id",
|
||||
"clientSecret": "client-secret",
|
||||
"claimMapping": {"email": "eml", "name": "nm", "groups": "grps", "role": "rl"},
|
||||
"insecureSkipEmailVerified": True,
|
||||
"getUserInfo": True,
|
||||
},
|
||||
},
|
||||
None,
|
||||
id="oidc_full",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
{
|
||||
"defaultRole": "EDITOR",
|
||||
"groupMappings": {"platform-team": "ADMIN"},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
"insecureSkipAuthNRequestsSigned": False,
|
||||
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"defaultRole": "signoz-editor",
|
||||
"groupMappings": {"platform-team": "signoz-admin"},
|
||||
"useRoleAttribute": False,
|
||||
},
|
||||
id="role_mapping_names_normalized",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
},
|
||||
},
|
||||
{"defaultRole": "VIEWER", "useRoleAttribute": True},
|
||||
{
|
||||
"kind": "saml",
|
||||
"spec": {
|
||||
"entityId": "saml-entity",
|
||||
"location": "https://idp.integration.test/sso",
|
||||
"certificate": "saml-cert",
|
||||
"insecureSkipAuthNRequestsSigned": False,
|
||||
"attributeMapping": {"email": "email", "name": "name", "groups": "groups", "role": "role"},
|
||||
},
|
||||
},
|
||||
{"defaultRole": "signoz-viewer", "groupMappings": None, "useRoleAttribute": True},
|
||||
id="role_mapping_null_group_mappings",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_domain_roundtrip( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
config: dict,
|
||||
role_mapping: dict | None,
|
||||
expected_config: dict,
|
||||
expected_role_mapping: dict | None,
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Drop a same-named leftover so reruns against a reused stack stay green.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
for domain in response.json()["data"]:
|
||||
if domain["name"] == "roundtrip.integration.test":
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": "roundtrip.integration.test",
|
||||
"enabled": True,
|
||||
"config": config,
|
||||
"roleMapping": role_mapping,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
domain_id = response.json()["data"]["id"]
|
||||
|
||||
# Clients (e.g. the terraform provider) read state back with a follow-up
|
||||
# GET after every write, so posted values must round-trip exactly; the
|
||||
# server-side defaulting and role-name normalization pinned here are part
|
||||
# of that contract.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
data = response.json()["data"]
|
||||
|
||||
assert data["name"] == "roundtrip.integration.test"
|
||||
assert data["enabled"] is True
|
||||
assert data["config"] == expected_config
|
||||
assert data["roleMapping"] == expected_role_mapping
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
@@ -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}"},
|
||||
|
||||
205
tests/integration/tests/callbackauthn/04_google.py
Normal file
205
tests/integration/tests/callbackauthn/04_google.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
assert_user_has_role,
|
||||
find_user_with_roles_by_email,
|
||||
)
|
||||
from fixtures.googleidp import google_oidc_mappings, perform_google_login
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
GOOGLE_DOMAIN = "google.integration.test"
|
||||
GOOGLE_CLIENT_ID = "google-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET = "google-client-secret"
|
||||
|
||||
|
||||
def test_create_auth_domain(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Reruns against a reused stack find the domain from the previous run;
|
||||
# drop it so creation always starts from a clean slate.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
for domain in response.json()["data"]:
|
||||
if domain["name"] == GOOGLE_DOMAIN:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": GOOGLE_DOMAIN,
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
|
||||
def test_google_authn(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
email = "viewer@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Viewer", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user["displayName"] == "Google Viewer"
|
||||
assert_user_has_role(found_user, "signoz-viewer")
|
||||
|
||||
|
||||
def test_google_authn_hd_mismatch(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
# The id_token carries a hosted-domain claim for a different workspace than
|
||||
# the auth domain; the callback must reject it and provision no user.
|
||||
email = "intruder@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Intruder", hd="other.workspace.test", audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "callbackauthnerr" in redirect_url
|
||||
assert "accessToken=" not in redirect_url
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert not any(user["email"] == email for user in response.json()["data"])
|
||||
|
||||
|
||||
def test_google_authn_unverified_email(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
email = "unverified@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Unverified", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID, email_verified=False))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "callbackauthnerr" in redirect_url
|
||||
|
||||
# Opting the domain into insecureSkipEmailVerified must let the same
|
||||
# unverified identity through.
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
domain = next(domain for domain in response.json()["data"] if domain["name"] == GOOGLE_DOMAIN)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
|
||||
json={
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
"insecureSkipEmailVerified": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
assert_user_has_role(found_user, "signoz-viewer")
|
||||
|
||||
|
||||
def test_google_role_mapping_default_role(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
domain = next(domain for domain in response.json()["data"] if domain["name"] == GOOGLE_DOMAIN)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain['id']}"),
|
||||
json={
|
||||
"enabled": True,
|
||||
"config": {
|
||||
"kind": "google",
|
||||
"spec": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "EDITOR",
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
email = "editor@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Editor", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
assert_user_has_role(found_user, "signoz-editor")
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Phase 1 end-to-end checks for semantic-convention name evolution."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import querier, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.semconv import SEMCONV_PHASE1_CURRENT as CURRENT
|
||||
from fixtures.semconv import SEMCONV_PHASE1_OLD as OLD
|
||||
from fixtures.semconv import SEMCONV_PHASE1_PREFIX as PREFIX
|
||||
|
||||
PRODUCTION_SPANS = {
|
||||
f"{PREFIX}-old",
|
||||
f"{PREFIX}-current",
|
||||
f"{PREFIX}-both",
|
||||
f"{PREFIX}-conflict",
|
||||
}
|
||||
STAGING_SPANS = {f"{PREFIX}-staging"}
|
||||
MISSING_SPANS = {f"{PREFIX}-missing"}
|
||||
|
||||
|
||||
def test_semconv_phase1_mixed_sdk_generations( # pylint: disable=too-many-statements
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
semconv_phase1_data: datetime,
|
||||
) -> None:
|
||||
now = semconv_phase1_data
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=2)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(minutes=1)).timestamp() * 1000)
|
||||
|
||||
# Resource and span-attribute paths share the same matrix. Run every
|
||||
# operator with both the saved-query (old) and current request spellings.
|
||||
for context in ("resource", "attribute"):
|
||||
for requested in (CURRENT, OLD):
|
||||
field = f"{context}.{requested}"
|
||||
query_cases = {
|
||||
"production": (f"{field} = 'production'", PRODUCTION_SPANS),
|
||||
"staging": (f"{field} = 'staging'", STAGING_SPANS),
|
||||
# Negative operators intentionally include rows where no family
|
||||
# member exists; explicit EXISTS is the opt-in presence filter.
|
||||
"negative": (f"{field} != 'production'", STAGING_SPANS | MISSING_SPANS),
|
||||
"exists": (f"{field} EXISTS", PRODUCTION_SPANS | STAGING_SPANS),
|
||||
"not_exists": (f"{field} NOT EXISTS", MISSING_SPANS),
|
||||
}
|
||||
matrix_response = querier.make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=querier.RequestType.RAW,
|
||||
queries=[
|
||||
querier.BuilderQuery(
|
||||
signal="traces",
|
||||
name=name,
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
select_fields=[querier.TelemetryFieldKey("span.name")],
|
||||
order=[querier.OrderBy(querier.TelemetryFieldKey("timestamp"), "asc")],
|
||||
).to_dict()
|
||||
for name, (expression, _) in query_cases.items()
|
||||
],
|
||||
)
|
||||
assert matrix_response.status_code == HTTPStatus.OK, matrix_response.text
|
||||
matrix_results = matrix_response.json()["data"]["data"]["results"]
|
||||
for name, (_, expected_names) in query_cases.items():
|
||||
result = querier.find_named_result(matrix_results, name)
|
||||
assert result is not None, name
|
||||
assert {row["data"]["name"] for row in (result.get("rows") or [])} == expected_names
|
||||
|
||||
grouped_response = querier.make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=querier.RequestType.SCALAR,
|
||||
queries=[
|
||||
querier.BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
filter_expression=f"{field} EXISTS",
|
||||
aggregations=[querier.Aggregation("count()")],
|
||||
group_by=[querier.TelemetryFieldKey(requested, "string", context)],
|
||||
order=[querier.OrderBy(querier.TelemetryFieldKey(requested, "string", context), "asc")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
assert grouped_response.status_code == HTTPStatus.OK, grouped_response.text
|
||||
grouped_results = grouped_response.json()["data"]["data"]["results"]
|
||||
assert len(grouped_results) == 1
|
||||
assert grouped_results[0]["columns"][0]["name"] == requested, "response identity must match the request spelling"
|
||||
assert grouped_results[0]["data"] == [["production", 4], ["staging", 1]]
|
||||
|
||||
for requested in (CURRENT, OLD):
|
||||
values_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
|
||||
timeout=5,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={
|
||||
"signal": "traces",
|
||||
"name": requested,
|
||||
"fieldContext": context,
|
||||
"fieldDataType": "string",
|
||||
},
|
||||
)
|
||||
assert values_response.status_code == HTTPStatus.OK, values_response.text
|
||||
assert set(values_response.json()["data"]["values"].get("stringValues") or []) == {"production", "staging"}
|
||||
|
||||
keys_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
|
||||
timeout=5,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": "traces", "searchText": OLD},
|
||||
)
|
||||
assert keys_response.status_code == HTTPStatus.OK, keys_response.text
|
||||
keys = keys_response.json()["data"]["keys"]
|
||||
assert CURRENT in keys
|
||||
assert OLD in keys
|
||||
|
||||
start_ns = str(int((now - timedelta(minutes=2)).timestamp() * 1_000_000_000))
|
||||
end_ns = str(int((now + timedelta(minutes=1)).timestamp() * 1_000_000_000))
|
||||
for requested in (CURRENT, OLD):
|
||||
services_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/services"),
|
||||
timeout=30,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": start_ns,
|
||||
"end": end_ns,
|
||||
"tags": [
|
||||
{
|
||||
"Key": requested,
|
||||
"Operator": "In",
|
||||
"StringValues": ["production"],
|
||||
"TagType": "ResourceAttribute",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert services_response.status_code == HTTPStatus.OK, services_response.text
|
||||
services = {item["serviceName"] for item in services_response.json()["data"]}
|
||||
assert services == PRODUCTION_SPANS
|
||||
|
||||
map_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/dependency_graph"),
|
||||
timeout=30,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": start_ns,
|
||||
"end": end_ns,
|
||||
"tags": [
|
||||
{
|
||||
"key": requested,
|
||||
"operator": "In",
|
||||
"stringValues": ["production"],
|
||||
"tagType": "ResourceAttribute",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert map_response.status_code == HTTPStatus.OK, map_response.text
|
||||
assert {edge["parent"] for edge in map_response.json()} == {f"{PREFIX}-map-production"}
|
||||
Reference in New Issue
Block a user