Compare commits

..

3 Commits

Author SHA1 Message Date
Aditya Singh
6e2b0e58fc Merge branch 'main' into fix/codemirror-length-normalisation 2026-08-10 18:19:09 +05:30
aks07
3acb9f8d3d fix(query-builder): anchor editor selection to normalised doc length
Setting the query expression to a value containing CRLF line breaks crashed
the search bar with "RangeError: Selection points outside of document".

CodeMirror normalises CRLF to LF when building a change, so the resulting
document is shorter than the raw string. The selection anchor used
value.length (pre-normalisation), which pointed past the end of the document.

Build the ChangeSet first and anchor the selection at changes.newLength, the
actual post-change document length. Adds a regression test.

Ref: SigNoz/engineering-pod#5869
2026-08-10 17:47:38 +05:30
Aditya Singh
0dd9a156b9 feat(log-details): add highlights section to log details drawer [2/3] (#12425)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

**What it does:** Slice 2 of the log-details drawer revamp, a new
**Highlights** row at the
top of the drawer that surfaces a log's key fields as chips. Gated
behind `isLogDetailsV2`
(ships off). Stacked on the header PR (#12310 /
`feat/log-detail-revamp`); the DataViewer
lands in the next PR.

**Change points**

- New Highlight section added. Check screenshot
- Driven by config.
- Severity chip color
- Trace id click opens trace details page in new tab
- Tests updated


#### Screenshots / Screen Recordings (if applicable)
<img width="2158" height="834" alt="image"
src="https://github.com/user-attachments/assets/9b9af5b0-8437-4a6d-8b34-e97b0d55de26"
/>

<img width="2308" height="920" alt="image"
src="https://github.com/user-attachments/assets/69e3702f-cc25-4570-87d2-67a000925705"
/>



#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

---

###  Change Type
_Select all that apply_

- [x]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🐛 Bug Context
> Required if this PR fixes a bug

#### Root Cause
> What caused the issue?  
> Regression, faulty assumption, edge case, refactor, etc.

#### Fix Strategy
> How does this PR address the root cause?

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated:
- Manual verification:
- Edge cases covered:

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius:
- Potential regressions:
- Rollback plan:

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature / Bug Fix / Maintenance |
| Description | User-facing summary |

---

### 📋 Checklist
- [ ] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered

---

## 👀 Notes for Reviewers

<!-- Anything reviewers should keep in mind while reviewing -->

---
2026-08-10 12:05:35 +00:00
55 changed files with 1391 additions and 2449 deletions

View File

@@ -464,50 +464,27 @@ 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/AuthtypesAuthDomainConfigSAML'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
type: object
AuthtypesAuthDomainConfigGoogle:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigOIDC:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigSAML:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
required:
- kind
- spec
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google
- google_auth
- saml
- email_password
- oidc
@@ -554,16 +531,12 @@ 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
@@ -628,7 +601,6 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
domainToAdminEmail:
additionalProperties:
@@ -640,12 +612,10 @@ components:
type: boolean
insecureSkipEmailVerified:
type: boolean
serviceAccountJson:
format: password
redirectURI:
type: string
serviceAccountJson:
type: string
required:
- clientId
- clientSecret
type: object
AuthtypesOIDCConfig:
properties:
@@ -654,7 +624,6 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
getUserInfo:
type: boolean
@@ -664,10 +633,6 @@ components:
type: string
issuerAlias:
type: string
required:
- issuer
- clientId
- clientSecret
type: object
AuthtypesOrgSessionContext:
properties:
@@ -689,15 +654,8 @@ 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:
@@ -804,18 +762,14 @@ components:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
certificate:
type: string
entityId:
type: string
insecureSkipAuthNRequestsSigned:
type: boolean
location:
samlCert:
type: string
samlEntity:
type: string
samlIdp:
type: string
required:
- entityId
- location
- certificate
type: object
AuthtypesSessionContext:
properties:
@@ -855,12 +809,6 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- config
type: object
AuthtypesUpdatableRole:
properties:
@@ -10654,6 +10602,275 @@ 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
@@ -14456,275 +14673,6 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/auth_domains:
get:
deprecated: false
description: This endpoint lists all auth domains
operationId: ListAuthDomains
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List all auth domains
tags:
- authdomains
post:
deprecated: false
description: This endpoint creates an auth domain
operationId: CreateAuthDomain
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create auth domain
tags:
- authdomains
/api/v2/auth_domains/{id}:
delete:
deprecated: false
description: This endpoint deletes an auth domain
operationId: DeleteAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
operationId: UpdateAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update auth domain
tags:
- authdomains
/api/v2/dashboard_views:
get:
deprecated: false

View File

@@ -61,37 +61,31 @@ type Channel struct {
```go
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
}
type StorableAuthDomain struct {
bun.BaseModel `bun:"table:auth_domain"`
types.Identifiable
Name string `bun:"name"`
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
OrgID valuer.UUID `bun:"org_id"`
types.TimeAuditable
}
type PostableAuthDomain struct {
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
}
type UpdatableAuthDomain struct {
Enabled bool `json:"enabled"` // Name intentionally absent
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
type UpdateableAuthDomain struct {
Config AuthDomainConfig `json:"config"` // Name intentionally absent
}
type GettableAuthDomain struct {
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
*StorableAuthDomain
*AuthDomainConfig
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
```
@@ -99,11 +93,11 @@ 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; `AuthDomainConfig` is a kind/spec envelope.
- `UpdatableAuthDomain` 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.
- `UpdateableAuthDomain` 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 `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)` and `Patch(patchable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
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`.
## Sum types: the kind/spec envelope

View File

@@ -53,6 +53,10 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
if err != nil {
return "", err
@@ -81,11 +85,6 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
if err != nil {
return nil, err
@@ -107,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
if claims == nil && oidcConfig.GetUserInfo {
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[oidcConfig.ClaimMapping.Email].(string)
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -124,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 !oidcConfig.InsecureSkipEmailVerified {
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
@@ -136,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := oidcConfig.ClaimMapping.Name; nameClaim != "" {
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := oidcConfig.ClaimMapping.Groups; groupsClaim != "" {
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -162,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := oidcConfig.ClaimMapping.Role; roleClaim != "" {
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -178,16 +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) {
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, nil, err
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
}
if oidcConfig.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, oidcConfig.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if err != nil {
return nil, nil, err
}
@@ -195,13 +189,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.RoleMapping() != nil && len(authDomain.RoleMapping().GroupMappings) > 0 {
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: oidcConfig.ClientID,
ClientSecret: oidcConfig.ClientSecret,
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -218,12 +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")
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())

View File

@@ -40,6 +40,10 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
sp, err := a.serviceProvider(siteURL, authDomain)
if err != nil {
return "", err
@@ -69,11 +73,6 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
sp, err := a.serviceProvider(state.URL, authDomain)
if err != nil {
return nil, err
@@ -102,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := samlConfig.AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := samlConfig.AttributeMapping.Groups; groupAttribute != "" {
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := samlConfig.AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -132,12 +131,7 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
certStore, err := a.getCertificateStore(samlConfig)
certStore, err := a.getCertificateStore(authDomain)
if err != nil {
return nil, err
}
@@ -148,32 +142,32 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: samlConfig.Location,
IdentityProviderIssuer: samlConfig.EntityID,
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !samlConfig.InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
}, nil
}
func (a *AuthN) getCertificateStore(samlConfig authtypes.SamlConfig) (dsig.X509CertificateStore, error) {
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
certStore := &dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{},
}
var certBytes []byte
if strings.Contains(samlConfig.Certificate, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(samlConfig.Certificate))
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
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(samlConfig.Certificate)
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}

View File

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

View File

@@ -1861,19 +1861,8 @@ 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
*/
@@ -1881,21 +1870,17 @@ export interface AuthtypesSamlConfigDTO {
/**
* @type string
*/
location: string;
}
export interface AuthtypesAuthDomainConfigSAMLDTO {
samlCert?: string;
/**
* @type string
* @enum saml
*/
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
spec: AuthtypesSamlConfigDTO;
samlEntity?: string;
/**
* @type string
*/
samlIdp?: string;
}
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
google = 'google',
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
[key: string]: string;
};
@@ -1908,12 +1893,11 @@ export interface AuthtypesGoogleConfigDTO {
/**
* @type string
*/
clientId: string;
clientId?: string;
/**
* @type string
* @format password
*/
clientSecret: string;
clientSecret?: string;
/**
* @type object
*/
@@ -1932,34 +1916,24 @@ export interface AuthtypesGoogleConfigDTO {
insecureSkipEmailVerified?: boolean;
/**
* @type string
* @format password
*/
redirectURI?: string;
/**
* @type string
*/
serviceAccountJson?: string;
}
export interface AuthtypesAuthDomainConfigGoogleDTO {
/**
* @type string
* @enum google
*/
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
spec: AuthtypesGoogleConfigDTO;
}
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
oidc = 'oidc',
}
export interface AuthtypesOIDCConfigDTO {
claimMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
clientId: string;
clientId?: string;
/**
* @type string
* @format password
*/
clientSecret: string;
clientSecret?: string;
/**
* @type boolean
*/
@@ -1971,33 +1945,79 @@ export interface AuthtypesOIDCConfigDTO {
/**
* @type string
*/
issuer: string;
issuer?: string;
/**
* @type string
*/
issuerAlias?: string;
}
export interface AuthtypesAuthDomainConfigOIDCDTO {
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
* @type string
* @enum oidc
*/
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
spec: AuthtypesOIDCConfigDTO;
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
}
export type AuthtypesAuthDomainConfigDTO =
| AuthtypesAuthDomainConfigSAMLDTO
| AuthtypesAuthDomainConfigGoogleDTO
| AuthtypesAuthDomainConfigOIDCDTO;
export enum AuthtypesAuthNProviderDTO {
google = 'google',
google_auth = 'google_auth',
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
@@ -2035,31 +2055,6 @@ 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;
@@ -2068,10 +2063,6 @@ export interface AuthtypesGettableAuthDomainDTO {
* @format date-time
*/
createdAt?: string;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
@@ -2084,7 +2075,6 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
/**
* @type string
* @format date-time
@@ -2281,16 +2271,11 @@ export interface AuthtypesOrgSessionContextDTO {
}
export interface AuthtypesPostableAuthDomainDTO {
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
config?: AuthtypesAuthDomainConfigDTO;
/**
* @type string
*/
name: string;
roleMapping?: AuthtypesRoleMappingDTO;
name?: string;
}
export interface AuthtypesPostableEmailPasswordSessionDTO {
@@ -2423,12 +2408,7 @@ export interface AuthtypesTransactionDTO {
}
export interface AuthtypesUpdatableAuthDomainDTO {
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
roleMapping?: AuthtypesRoleMappingDTO;
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
@@ -10545,6 +10525,42 @@ 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
@@ -11218,42 +11234,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**

View File

@@ -0,0 +1,46 @@
.highlights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 16px;
padding: 12px 0;
// Constrain each KeyValueLabel (the grid items) to its cell.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.valueBadge {
--badge-font-size: 13px;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
}
// Truncating text inside a badge
.badgeText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.serviceDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
.traceLink {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-primary);
}

View File

@@ -0,0 +1,36 @@
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { ILog } from 'types/api/logs/log';
import { LOG_HIGHLIGHTS } from './config';
import styles from './LogHighlights.module.scss';
interface LogHighlightsProps {
log: ILog;
}
function LogHighlights({ log }: LogHighlightsProps): JSX.Element | null {
const fields = LOG_HIGHLIGHTS.map((field) => ({
key: field.key,
label: field.label,
value: field.render(log),
})).filter((field) => field.value != null);
if (fields.length === 0) {
return null;
}
return (
<div className={styles.highlights} data-testid="log-details-highlights">
{fields.map((field) => (
<KeyValueLabel
key={field.key}
badgeKey={field.label}
badgeValue={field.value}
direction="column"
/>
))}
</div>
);
}
export default LogHighlights;

View File

@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
import styles from './LogHighlights.module.scss';
interface TraceIdFieldProps {
traceId: string;
}
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
return (
<Link
to={{ pathname: `/trace/${traceId}` }}
target="_blank"
rel="noreferrer"
className={styles.traceLink}
title={traceId}
>
{traceId}
</Link>
);
}
export default TraceIdField;

View File

@@ -0,0 +1,102 @@
import { ReactNode } from 'react';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { ILog } from 'types/api/logs/log';
import styles from './LogHighlights.module.scss';
import TraceIdField from './TraceIdField';
// Severity badge color mirrors the LogStateIndicator bar
const SEVERITY_COLOR: Record<string, BadgeColor> = {
[LogType.TRACE]: 'forest',
[LogType.DEBUG]: 'aqua',
[LogType.INFO]: 'robin',
[LogType.WARN]: 'amber',
[LogType.ERROR]: 'cherry',
[LogType.FATAL]: 'sakura',
};
export interface LogHighlightConfig {
key: string;
label: string;
render: (log: ILog) => ReactNode | null;
}
// Resource/attribute lookup (keys like `service.name` live in resources_string,
// occasionally attributes_string). Typed loosely as these are string maps.
const getAttr = (log: ILog, key: string): string =>
(log.resources_string as unknown as Record<string, string>)?.[key] ||
(log.attributes_string as unknown as Record<string, string>)?.[key] ||
'';
const valueBadge = (
value: string,
options?: { prefix?: ReactNode; color?: BadgeColor },
): ReactNode => (
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
{options?.prefix}
<span className={styles.badgeText} title={value}>
{value}
</span>
</Badge>
);
export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
{
key: 'service',
label: 'SERVICE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.name');
return value
? valueBadge(value, {
prefix: <span className={styles.serviceDot} />,
})
: null;
},
},
{
key: 'severity',
label: 'SEVERITY',
render: (log): ReactNode | null => {
if (!log.severity_text) {
return null;
}
return valueBadge(log.severity_text, {
color: SEVERITY_COLOR[getLogIndicatorType(log)] ?? 'vanilla',
});
},
},
{
key: 'namespace',
label: 'NAMESPACE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.namespace');
return value ? valueBadge(value) : null;
},
},
{
key: 'environment',
label: 'ENVIRONMENT',
render: (log): ReactNode | null => {
const value = getAttr(log, 'deployment.environment');
return value ? valueBadge(value) : null;
},
},
{
key: 'traceId',
label: 'TRACE ID',
render: (log): ReactNode | null => {
const traceId = log.trace_id || log.traceId;
return traceId ? <TraceIdField traceId={traceId} /> : null;
},
},
{
key: 'spanId',
label: 'SPAN ID',
render: (log): ReactNode | null => {
const spanId = log.span_id || log.spanID;
return spanId ? valueBadge(spanId) : null;
},
},
];

View File

@@ -115,6 +115,45 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
});
it('renders Highlights for fields present on the log, omitting absent ones', () => {
const logWithMeta = {
...mockLog,
severity_text: 'ERROR',
trace_id: 'trace-abc',
resources_string: {
'service.name': 'checkout',
'deployment.environment': 'production',
},
} as unknown as ILog;
renderDrawer({ log: logWithMeta });
const highlights = screen.getByTestId('log-details-highlights');
expect(highlights).toHaveTextContent('SEVERITY');
expect(highlights).toHaveTextContent('ERROR');
expect(highlights).toHaveTextContent('SERVICE');
expect(highlights).toHaveTextContent('checkout');
expect(highlights).toHaveTextContent('ENVIRONMENT');
expect(highlights).toHaveTextContent('production');
expect(highlights).toHaveTextContent('TRACE ID');
// Absent fields are omitted (no namespace / span id on this log).
expect(highlights).not.toHaveTextContent('NAMESPACE');
expect(highlights).not.toHaveTextContent('SPAN ID');
});
it('links the trace id highlight to the trace detail in a new tab', () => {
const logWithTrace = {
...mockLog,
trace_id: 'trace-abc',
} as unknown as ILog;
renderDrawer({ log: logWithTrace });
const link = screen.getByRole('link', { name: 'trace-abc' });
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('href')).toContain('/trace/trace-abc');
});
it('navigates to the next / previous log with the Down / Up arrow keys', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logs = [makeLog('log-0'), makeLog('log-1'), makeLog('log-2')];

View File

@@ -55,6 +55,7 @@ import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import './LogDetails.styles.scss';
@@ -399,6 +400,8 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"

View File

@@ -183,15 +183,14 @@ function QuerySearch({
isProgrammaticChangeRef.current = true;
}
const changes = view.state.changes({
from: 0,
to: currentValue.length,
insert: value,
});
view.dispatch({
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
selection: {
anchor: value.length,
},
changes,
selection: { anchor: changes.newLength },
});
},
[],

View File

@@ -301,6 +301,66 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
dispatchSpy.mockRestore();
});
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialExpression = "service.name = 'frontend'";
// Filtering on a multi-line log value (CRLF) used to throw
// "RangeError: Selection points outside of document".
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
const baseQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: initialExpression },
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={baseQueryData}
dataSource={DataSource.LOGS}
/>,
);
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent.textContent || '').toBe(initialExpression);
},
{ timeout: 3000 },
);
rerender(
<QuerySearch
onChange={onChange}
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
dataSource={DataSource.LOGS}
/>,
);
// The programmatic replace dispatched without throwing, and the selection anchor
// stayed within the CRLF-normalized document (the bug set it past the end).
await waitFor(() => {
const spec = dispatchSpy.mock.calls
.map(
(call) =>
call[0] as {
selection?: { anchor?: number };
changes?: { newLength?: number };
},
)
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
expect(spec).toBeDefined();
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
spec?.changes?.newLength as number,
);
});
dispatchSpy.mockRestore();
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions

View File

@@ -16,7 +16,7 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: AuthtypesAuthNProviderDTO.google,
key: AuthtypesAuthNProviderDTO.google_auth,
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <SolidGoogle size={37} />,
@@ -78,7 +78,6 @@ function AuthnProviderSelector({
<Button
onClick={(): void => setAuthnProvider(provider.key)}
type="primary"
data-testid={`authn-provider-configure-${provider.key}`}
>
Configure
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ function SSOEnforcementToggle({
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id || !record.config) {
if (!record.id) {
return;
}
@@ -41,9 +41,14 @@ function SSOEnforcementToggle({
{
pathParams: { id: record.id },
data: {
enabled: checked,
config: record.config,
roleMapping: record.roleMapping,
config: {
ssoEnabled: checked,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
},
},
{
@@ -60,12 +65,7 @@ function SSOEnforcementToggle({
};
return (
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
);
}

View File

@@ -58,7 +58,7 @@ describe('AuthDomain', () => {
});
});
it('reflects the enabled state in each row toggle', async () => {
it('reflects ssoEnabled state from nested config 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 → enabled: true
// [1] example.com → enabled: false
// [2] corp.io → enabled: true
// [0] signoz.io → config.ssoEnabled: true
// [1] example.com → config.ssoEnabled: false
// [2] corp.io → config.ssoEnabled: true
const switches = await screen.findAllByRole('switch');
expect(switches).toHaveLength(3);
expect(switches[0]).toBeChecked();

View File

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

View File

@@ -188,8 +188,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(editorRole.id);
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.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().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(viewerRole.id);
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.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().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.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().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.groupMappings).toStrictEqual({
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { HTMLAttributes, useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -26,7 +26,7 @@ import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
export const SSOType = new Map<string, string>([
['google', 'Google Auth'],
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
@@ -121,8 +121,8 @@ function AuthDomain(): JSX.Element {
},
{
title: 'Enforce SSO',
dataIndex: 'enabled',
key: 'enabled',
dataIndex: ['config', 'ssoEnabled'],
key: 'ssoEnabled',
width: 80,
render: (
value: boolean,
@@ -157,15 +157,13 @@ function AuthDomain(): JSX.Element {
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
testId="auth-domain-configure"
>
Configure {SSOType.get(record.config?.kind || '')}
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
testId="auth-domain-delete"
>
Delete
</Button>
@@ -179,9 +177,7 @@ function AuthDomain(): JSX.Element {
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title" data-testid="auth-domain-title">
Authenticated Domains
</h3>
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
@@ -190,7 +186,6 @@ function AuthDomain(): JSX.Element {
variant="solid"
size="sm"
color="primary"
testId="auth-domain-add"
>
Add Domain
</Button>
@@ -200,14 +195,7 @@ function AuthDomain(): JSX.Element {
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
onRow={undefined}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
@@ -240,7 +228,6 @@ function AuthDomain(): JSX.Element {
onClick={hideDeleteModal}
className="cancel-btn"
prefix={<X size={16} />}
testId="auth-domain-delete-cancel"
>
Cancel
</Button>,
@@ -250,7 +237,6 @@ function AuthDomain(): JSX.Element {
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
testId="auth-domain-delete-confirm"
>
Delete Domain
</Button>,

View File

@@ -10,7 +10,7 @@ import (
)
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
if err := router.Handle("/api/v1/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/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
if err := router.Handle("/api/v1/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/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
if err := router.Handle("/api/v1/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/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
if err := router.Handle("/api/v1/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/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
if err := router.Handle("/api/v1/domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
ID: "DeleteAuthDomain",
Tags: []string{"authdomains"},
Summary: "Delete auth domain",

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
return
}
authDomain, err := authtypes.NewAuthDomainFromPostableAuthDomain(body, valuer.MustNewUUID(claims.OrgID))
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
@@ -154,7 +154,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = authDomain.Update(body)
err = authDomain.Update(&body.Config)
if err != nil {
render.Error(rw, err)
return

View File

@@ -33,7 +33,7 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
}
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
if callbackAuthN, ok := module.authNs[domain.Kind()].(authn.CallbackAuthN); ok {
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().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.Kind().StringValue() + ".count"
key := "authdomain." + domain.AuthDomainConfig().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.RoleMapping().RoleNames()
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
if len(roleNames) == 0 {
return nil
}

View File

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

View File

@@ -152,7 +152,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", err
}
roleMapping := authDomain.RoleMapping()
roleMapping := authDomain.AuthDomainConfig().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.Enabled() {
if !authDomain.AuthDomainConfig().SSOEnabled {
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
}
provider, err := getProvider[authn.CallbackAuthN](authDomain.Kind(), module.authNs)
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().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.Kind(), loginURL), nil
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
}
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {

View File

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

View File

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

View File

@@ -1,182 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type restructureAuthDomainConfig struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type restructureAuthDomainRow struct {
bun.BaseModel `bun:"table:auth_domain"`
ID string `bun:"id"`
Data string `bun:"data"`
}
// The legacy document keyed the discriminator as ssoType with the chosen
// provider's config in a sibling field; the restructured document is
// {enabled, config: {kind, spec}, roleMapping} with renamed saml spec keys.
var legacySSOTypeToKind = map[string]string{
"google_auth": "google",
"saml": "saml",
"oidc": "oidc",
}
var legacySSOTypeToConfigKey = map[string]string{
"google_auth": "googleAuthConfig",
"saml": "samlConfig",
"oidc": "oidcConfig",
}
var legacySamlKeyToKey = map[string]string{
"samlEntity": "entityId",
"samlIdp": "location",
"samlCert": "certificate",
}
func NewRestructureAuthDomainConfigFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("restructure_auth_domain_config"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &restructureAuthDomainConfig{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *restructureAuthDomainConfig) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *restructureAuthDomainConfig) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*restructureAuthDomainRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
legacy := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &legacy); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable data", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
ssoTypeRaw, ok := legacy["ssoType"]
if !ok {
continue
}
var ssoType string
if err := json.Unmarshal(ssoTypeRaw, &ssoType); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable ssoType", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
kind, ok := legacySSOTypeToKind[ssoType]
if !ok {
migration.logger.WarnContext(ctx, "skipping auth domain with unknown ssoType", slog.String("auth_domain_id", row.ID), slog.String("sso_type", ssoType))
continue
}
spec, ok := legacy[legacySSOTypeToConfigKey[ssoType]]
if !ok || string(spec) == "null" {
migration.logger.WarnContext(ctx, "skipping auth domain with missing provider config", slog.String("auth_domain_id", row.ID), slog.String("sso_type", ssoType))
continue
}
if ssoType == "saml" {
samlSpec := make(map[string]json.RawMessage)
if err := json.Unmarshal(spec, &samlSpec); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable saml config", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
for legacyKey, key := range legacySamlKeyToKey {
if value, ok := samlSpec[legacyKey]; ok {
samlSpec[key] = value
delete(samlSpec, legacyKey)
}
}
if spec, err = json.Marshal(samlSpec); err != nil {
return err
}
}
if ssoType == "google_auth" {
googleSpec := make(map[string]json.RawMessage)
if err := json.Unmarshal(spec, &googleSpec); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable google config", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
delete(googleSpec, "redirectURI")
if spec, err = json.Marshal(googleSpec); err != nil {
return err
}
}
kindRaw, err := json.Marshal(kind)
if err != nil {
return err
}
config, err := json.Marshal(map[string]json.RawMessage{
"kind": kindRaw,
"spec": spec,
})
if err != nil {
return err
}
restructured := map[string]json.RawMessage{
"enabled": json.RawMessage("false"),
"config": config,
}
if enabled, ok := legacy["ssoEnabled"]; ok {
restructured["enabled"] = enabled
}
if roleMapping, ok := legacy["roleMapping"]; ok && string(roleMapping) != "null" {
restructured["roleMapping"] = roleMapping
}
newData, err := json.Marshal(restructured)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*restructureAuthDomainRow)(nil)).
Set("data = ?", string(newData)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *restructureAuthDomainConfig) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -16,7 +16,7 @@ var (
)
var (
AuthNProviderGoogle = AuthNProvider{valuer.NewString("google")}
AuthNProviderGoogleAuth = AuthNProvider{valuer.NewString("google_auth")}
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{
AuthNProviderGoogle,
AuthNProviderGoogleAuth,
AuthNProviderSAML,
AuthNProviderEmailPassword,
AuthNProviderOIDC,

View File

@@ -9,7 +9,6 @@ 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"
)
@@ -29,58 +28,9 @@ var (
ErrCodeAuthDomainAlreadyExists = errors.MustNewCode("auth_domain_already_exists")
)
// authDomainConfigVariants is the single registry of authn provider kinds:
// UnmarshalJSON, JSONSchemaOneOf and the discriminator mapping all derive from
// it, so a new provider is one entry here plus its authn registration.
var authDomainConfigVariants = []authDomainConfigVariant{
{
kind: AuthNProviderSAML,
decodeSpec: func(data []byte) (any, error) {
spec := SamlConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigSAML{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigSAML",
},
{
kind: AuthNProviderGoogle,
decodeSpec: func(data []byte) (any, error) {
spec := GoogleConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigGoogle{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigGoogle",
},
{
kind: AuthNProviderOIDC,
decodeSpec: func(data []byte) (any, error) {
spec := OIDCConfig{}
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
return spec, nil
},
schema: authDomainConfigOIDC{},
schemaRef: "#/components/schemas/AuthtypesAuthDomainConfigOIDC",
},
}
var (
_ jsonschema.OneOfExposer = AuthDomainConfig{}
_ jsonschema.Preparer = AuthDomainConfig{}
)
type GettableAuthDomain struct {
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -89,16 +39,12 @@ type AuthNProviderInfo struct {
}
type PostableAuthDomain struct {
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
}
type UpdatableAuthDomain struct {
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
Config AuthDomainConfig `json:"config"`
}
type StorableAuthDomain struct {
@@ -111,46 +57,150 @@ type StorableAuthDomain struct {
types.TimeAuditable
}
// StorableAuthDomainConfig is the JSON document persisted in StorableAuthDomain.Data.
type StorableAuthDomainConfig struct {
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
// 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 {
Kind AuthNProvider `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// authDomainConfigSAML is the OpenAPI schema for an AuthDomainConfig with kind=saml.
type authDomainConfigSAML struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec SamlConfig `json:"spec" description:"The saml configuration." required:"true"`
}
// authDomainConfigGoogle is the OpenAPI schema for an AuthDomainConfig with kind=google.
type authDomainConfigGoogle struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec GoogleConfig `json:"spec" description:"The google auth configuration." required:"true"`
}
// authDomainConfigOIDC is the OpenAPI schema for an AuthDomainConfig with kind=oidc.
type authDomainConfigOIDC struct {
Kind AuthNProvider `json:"kind" description:"The kind of authn provider." required:"true"`
Spec OIDCConfig `json:"spec" description:"The oidc configuration." required:"true"`
}
type authDomainConfigVariant struct {
kind AuthNProvider
decodeSpec func(data []byte) (any, error)
schema any
schemaRef string
SSOEnabled bool `json:"ssoEnabled"`
AuthNProvider AuthNProvider `json:"ssoType"`
SAML *SamlConfig `json:"samlConfig"`
Google *GoogleConfig `json:"googleAuthConfig"`
OIDC *OIDCConfig `json:"oidcConfig"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
}
func NewAuthDomainFromConfig(name string, config *AuthDomainConfig, orgID valuer.UUID) (*AuthDomain, error) {
data, err := json.Marshal(config)
if err != nil {
return nil, err
}
return NewAuthDomain(name, string(data), orgID)
}
func NewAuthDomain(name string, data string, orgID valuer.UUID) (*AuthDomain, error) {
storableAuthDomain := &StorableAuthDomain{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Name: name,
Data: data,
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
return NewAuthDomainFromStorableAuthDomain(storableAuthDomain)
}
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
authDomainConfig := new(AuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), authDomainConfig); err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: storableAuthDomain,
authDomainConfig: authDomainConfig,
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
Config: *authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}
func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
return typ.storableAuthDomain
}
func (typ *AuthDomain) AuthDomainConfig() *AuthDomainConfig {
return typ.authDomainConfig
}
func (typ *AuthDomain) Update(config *AuthDomainConfig) error {
data, err := json.Marshal(config)
if err != nil {
return err
}
typ.authDomainConfig = config
typ.storableAuthDomain.Data = string(data)
typ.storableAuthDomain.UpdatedAt = time.Now()
return nil
}
func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias PostableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !authDomainNameRegex.MatchString(temp.Name) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
}
*typ = PostableAuthDomain(temp)
return nil
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
type Alias AuthDomainConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
switch temp.AuthNProvider {
case AuthNProviderGoogleAuth:
if temp.Google == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
}
case AuthNProviderSAML:
if temp.SAML == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
}
case AuthNProviderOIDC:
if temp.OIDC == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
}
default:
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
}
*typ = AuthDomainConfig(temp)
return nil
}
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
SamlConfig{},
GoogleConfig{},
OIDCConfig{},
}
}
type AuthDomainStore interface {
@@ -178,230 +228,3 @@ type AuthDomainStore interface {
// Delete by orgID and id.
Delete(context.Context, valuer.UUID, valuer.UUID) error
}
func NewAuthDomainFromPostableAuthDomain(postableAuthDomain *PostableAuthDomain, orgID valuer.UUID) (*AuthDomain, error) {
storableAuthDomainConfig := &StorableAuthDomainConfig{
Enabled: postableAuthDomain.Enabled,
Config: postableAuthDomain.Config,
RoleMapping: postableAuthDomain.RoleMapping,
}
data, err := json.Marshal(storableAuthDomainConfig)
if err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: &StorableAuthDomain{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Name: postableAuthDomain.Name,
Data: string(data),
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
storableAuthDomainConfig: storableAuthDomainConfig,
}, nil
}
func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain) (*AuthDomain, error) {
storableAuthDomainConfig := new(StorableAuthDomainConfig)
if err := json.Unmarshal([]byte(storableAuthDomain.Data), storableAuthDomainConfig); err != nil {
return nil, err
}
return &AuthDomain{
storableAuthDomain: storableAuthDomain,
storableAuthDomainConfig: storableAuthDomainConfig,
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
Enabled: authDomain.Enabled(),
Config: authDomain.Config(),
RoleMapping: authDomain.RoleMapping(),
AuthNProviderInfo: authNProviderInfo,
}
}
// JSONSchemaOneOf returns the oneOf variants for the AuthDomainConfig discriminated union.
// Each variant represents a different authn provider kind with its corresponding spec schema.
func (AuthDomainConfig) JSONSchemaOneOf() []any {
oneOf := make([]any, len(authDomainConfigVariants))
for i, variant := range authDomainConfigVariants {
oneOf[i] = variant.schema
}
return oneOf
}
// PrepareJSONSchema marks the schema with x-signoz-discriminator;
// signoz.attachDiscriminators promotes it to a real OpenAPI 3
// discriminator after reflection.
func (AuthDomainConfig) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
mapping := make(map[string]string, len(authDomainConfigVariants))
for _, variant := range authDomainConfigVariants {
mapping[variant.kind.StringValue()] = variant.schemaRef
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "kind",
"mapping": mapping,
}
return nil
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal auth domain config")
}
kindData, ok := raw["kind"]
if !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "kind is required")
}
var kind AuthNProvider
if err := json.Unmarshal(kindData, &kind); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal kind")
}
specData, ok := raw["spec"]
if !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "spec is required")
}
for _, variant := range authDomainConfigVariants {
if variant.kind != kind {
continue
}
spec, err := variant.decodeSpec(specData)
if err != nil {
return err
}
typ.Kind = kind
typ.Spec = spec
return nil
}
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", kind.StringValue())
}
func (config AuthDomainConfig) SamlConfig() (SamlConfig, error) {
spec, ok := config.Spec.(SamlConfig)
if !ok {
return SamlConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not saml")
}
return spec, nil
}
func (config AuthDomainConfig) GoogleConfig() (GoogleConfig, error) {
spec, ok := config.Spec.(GoogleConfig)
if !ok {
return GoogleConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not google")
}
return spec, nil
}
func (config AuthDomainConfig) OIDCConfig() (OIDCConfig, error) {
spec, ok := config.Spec.(OIDCConfig)
if !ok {
return OIDCConfig{}, errors.Newf(errors.TypeInternal, ErrCodeAuthDomainMismatch, "auth domain config is not oidc")
}
return spec, nil
}
func (typ *AuthDomain) StorableAuthDomain() *StorableAuthDomain {
return typ.storableAuthDomain
}
func (typ *AuthDomain) Enabled() bool {
return typ.storableAuthDomainConfig.Enabled
}
func (typ *AuthDomain) Kind() AuthNProvider {
return typ.storableAuthDomainConfig.Config.Kind
}
func (typ *AuthDomain) Config() AuthDomainConfig {
return typ.storableAuthDomainConfig.Config
}
func (typ *AuthDomain) RoleMapping() *RoleMapping {
return typ.storableAuthDomainConfig.RoleMapping
}
func (typ *AuthDomain) Update(updatableAuthDomain *UpdatableAuthDomain) error {
storableAuthDomainConfig := &StorableAuthDomainConfig{
Enabled: updatableAuthDomain.Enabled,
Config: updatableAuthDomain.Config,
RoleMapping: updatableAuthDomain.RoleMapping,
}
data, err := json.Marshal(storableAuthDomainConfig)
if err != nil {
return err
}
typ.storableAuthDomainConfig = storableAuthDomainConfig
typ.storableAuthDomain.Data = string(data)
typ.storableAuthDomain.UpdatedAt = time.Now()
return nil
}
func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias PostableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !authDomainNameRegex.MatchString(temp.Name) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
}
// A present config always carries a kind (its UnmarshalJSON rejects
// anything else), so a zero kind means the key was absent.
if temp.Config.Kind.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "config is required")
}
*typ = PostableAuthDomain(temp)
return nil
}
func (typ *UpdatableAuthDomain) UnmarshalJSON(data []byte) error {
type Alias UpdatableAuthDomain
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// A present config always carries a kind (its UnmarshalJSON rejects
// anything else), so a zero kind means the key was absent.
if temp.Config.Kind.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "config is required")
}
*typ = UpdatableAuthDomain(temp)
return nil
}

View File

@@ -12,10 +12,13 @@ const wildCardDomain = "*"
type GoogleConfig struct {
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
ClientID string `json:"clientId" required:"true"`
ClientID string `json:"clientId"`
// It is the application's secret.
ClientSecret string `json:"clientSecret" required:"true" format:"password"`
ClientSecret string `json:"clientSecret"`
// What is the meaning of this? Should we remove this?
RedirectURI string `json:"redirectURI"`
// Whether to fetch the Google workspace groups (required additional API scopes)
FetchGroups bool `json:"fetchGroups"`
@@ -23,7 +26,7 @@ type GoogleConfig struct {
// Service Account creds JSON stored for Google Admin SDK access
// This is content of the JSON file stored directly into db as string
// Required if FetchGroups is true (unless running on GCE with default credentials)
ServiceAccountJSON string `json:"serviceAccountJson,omitempty" format:"password"`
ServiceAccountJSON string `json:"serviceAccountJson,omitempty"`
// Map of workspace domain to admin email for service account impersonation
// The service account will impersonate this admin to call the directory API

View File

@@ -8,7 +8,7 @@ import (
type OIDCConfig struct {
// It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".
Issuer string `json:"issuer" required:"true"`
Issuer string `json:"issuer"`
// 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" required:"true"`
ClientID string `json:"clientId"`
// It is the application's secret.
ClientSecret string `json:"clientSecret" required:"true" format:"password"`
ClientSecret string `json:"clientSecret"`
// Mapping of claims to the corresponding fields in the token.
ClaimMapping AttributeMapping `json:"claimMapping"`

View File

@@ -7,14 +7,14 @@ import (
)
type SamlConfig struct {
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entityId}">
EntityID string `json:"entityId" required:"true"`
// 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 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 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 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"`
// 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"`
// Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
// For providers like jumpcloud, this should be set to true.
@@ -33,34 +33,24 @@ func (config *SamlConfig) UnmarshalJSON(data []byte) error {
return err
}
samlConfig := SamlConfig(temp)
if err := samlConfig.validate(); err != nil {
return err
if temp.SamlEntity == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlEntity is required")
}
*config = samlConfig
return nil
}
// validate also assigns the default attribute mapping when none is present.
func (config *SamlConfig) validate() error {
if config.EntityID == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "entityId is required")
if temp.SamlIdp == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlIdp is required")
}
if config.Location == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "location is required")
if temp.SamlCert == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert 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 {
if temp.AttributeMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
return err
}
}
*config = SamlConfig(temp)
return nil
}

View File

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

View File

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

View File

@@ -74,7 +74,7 @@ def perform_google_login(
def get_google_domain(signoz: types.SigNoz, admin_token: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
signoz.self.host_configs["8080"].get("/api/v1/domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,16 +50,16 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "saml.integration.test",
"enabled": True,
"config": {
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": 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/v2/auth_domains"),
signoz.self.host_configs["8080"].get("/api/v1/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/v2/auth_domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"attributeMapping": {
"name": "givenName",
"groups": "groups",
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"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/v2/auth_domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "saml",
"spec": {
"entityId": settings["entityID"],
"location": settings["singleSignOnServiceLocation"],
"certificate": settings["certificate"],
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
"attributeMapping": {
"name": "displayName",
"groups": "groups",
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -48,13 +48,13 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "oidc.integration.test",
"enabled": True,
"config": {
"kind": "oidc",
"spec": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"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/v2/auth_domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "oidc",
"spec": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"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",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"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/v2/auth_domains/{domain['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"enabled": True,
"config": {
"kind": "oidc",
"spec": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"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",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

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