Compare commits

..

11 Commits

Author SHA1 Message Date
swapnil-signoz
bc33827221 feat: adding mysql GCP service 2026-08-11 18:53:25 +05:30
Naman Verma
848046de91 fix: allow deletion of legacy dashboards via delete api (#12500)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

If a dashboard failed to migrate to the new schema, currently the delete
API does not delete them. This PR changes it to be able to delete those
un-migrated dashboards as well.

#### Issues closed by this PR

Closes https://github.com/SigNoz/signoz/issues/12390
2026-08-11 04:26:41 +00:00
Swapnil Nakade
68e61af0be test: adding tests for covering GCP integrations across APIs (#12501)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Added missing tests for covering GCP integrations API, which initially
was just covering AWS

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
https://github.com/SigNoz/platform-pod/issues/2899
2026-08-10 21:49:23 +00:00
Tushar Vats
84780acee1 test(integration): drop the 10k filter-expression fuzz test (#12505)
#### Description

- Removes `test_filter_expressions_no_server_error`, which fires all
9,999 lines of `filter_expressions_10000.txt` at the logs endpoint, one
request at a time. It dominates a local integration run, enough that the
habit is to comment it out first.
- It asserted almost nothing: both `200` and `400` pass, so all it
checked was that the server didn't crash.
- Its corpus has no `[*]` array paths, so it missed `body.<path>[*] IN
[...]` returning a 500 — exactly the failure it exists to catch. That
one is fixed in #12504, with a targeted case that asserts the rows
returned.
- Deletes the corpus file too; nothing else reads it.
`test_not_filter_expression` is untouched and its 11 cases still pass.

#### Additional Information

This does give up cheap crash-fuzzing breadth in CI, where runtime
matters less than it does locally. If that breadth is worth keeping, the
alternative is a much smaller curated corpus that includes the shapes
this one misses — happy to do that instead.
2026-08-10 19:52:39 +00:00
Gaurav Tewari
c70b2be4d5 feat(frontend): save recent searches from summary and infra detail pages (#12054)
## Pull Request

---

### 📄 Summary

The "Recent searches" dropdown (introduced in #11523) is built into the
`QuerySearch` editor and reads from per-signal localStorage buckets, but
entries were only ever **saved** by the QueryBuilder provider's
`handleRunQuery` — a path only the Logs/Traces/Metrics explorers go
through. Every other page embedding `QuerySearch` runs queries through
local handlers, so searches run there displayed explorer recents but
were never captured themselves.

This PR closes that gap for:

| Route | Surface | Save triggers |
|---|---|---|
| `/metrics-explorer/summary` | Metrics Summary search bar | Run button,
Cmd+Enter |
| `/infrastructure-monitoring/hosts` | Host details drawer → Logs /
Traces tabs | Run button, Cmd+Enter |
| `/infrastructure-monitoring/kubernetes` | Entity details drawer → Logs
/ Traces / Events tabs | Run button, Cmd+Enter |

**Approach:** rather than fabricating a fake composite query on each
page, a new expression-level helper
`saveRecentQueryByExpression(dataSource, expression, source?)` is added
to `lib/recentQueries`. It owns the shared policy (trim →
`validateQuery` → signal check → save), and the existing composite-level
`saveRecentQuery` now delegates to it, so validation rules live in
exactly one place. Saves land in the same per-signal buckets the
dropdown already reads, so recents are shared with the explorers both
ways.

Deliberate choices:
- **Infra entity tabs** save the user-typed expression only (not the
combined entity-scoped one), matching what the recents dropdown inserts
back into the editor.
- Expressions are now stored trimmed; dedup was already
trim-insensitive, so this only cleans up display labels.

#### Screenshots / Screen Recordings (if applicable)



https://github.com/user-attachments/assets/852c6273-c797-4281-ac8c-be57eedf5d77


#### Issues closed by this PR

Closes
https://github.com/orgs/SigNoz/projects/39/views/11?filterQuery=assignee%3Atewarig&pane=issue&itemId=210386109&issue=SigNoz%7Cengineering-pod%7C5650
---

###  Change Type
_Select all that apply_

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

---

### 🐛 Bug Context

---

### 🧪 Testing Strategy

- Tests: existing `lib/recentQueries` unit tests still pass (37/37) —
`saveRecentQuery` now routes through the new helper, so its behaviour
stays covered.
- Manual verification: `tsgo --noEmit`, `oxlint` (no new warnings), and
production build all pass.
- Edge cases covered: invalid/partial expressions are rejected by
`validateQuery` before saving; empty/whitespace-only expressions and
unsupported data sources are no-ops.

---

### ⚠️ Risk & Impact Assessment

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | Searches run on Metrics Summary and Infra Monitoring
entity detail tabs (Logs/Traces/Events) now appear in the "Recent
searches" dropdown, shared with the explorers. |

---

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

---

## 👀 Notes for Reviewers


---

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-10 18:45:18 +00:00
Vikrant Gupta
cda2955b93 feat(members): assign member roles through the user_roles API (#12498)
#### Description

- Member role assignment used the deprecated `POST` / `DELETE
/api/v2/users/{id}/roles`. It now uses `POST` and `DELETE
/api/v2/user_roles`, which had no consumers until now.
- Roles are read from `useGetUser` instead of `useGetRolesByUserID`,
because the delete route is keyed by the `user_role` join row and only
that response carries its id.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2918

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/4c0790fb-a93e-4c37-8608-51fe03d4d962


#### Additional Information

- `EditMemberDrawer` already issues the same `useGetUser` query, so the
two share one request and the component itself needed no change.
2026-08-10 23:43:31 +05:30
Aditya Singh
1a293652f7 fix(sentry): drop benign aborted/cancelled requests from error reporting (#12495)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR filters network request aborts via fetch and axios in beforeSend
so they stop surfacing as Sentry issues.
- axios: ECONNABORTED ("Request aborted"), ERR_CANCELED
- native fetch: AbortError

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

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

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-10 16:13:14 +00:00
Vikrant Gupta
0f3fb71067 feat(reset-password): use the v2 endpoint on the reset password page (#12492)
#### Description

- The reset password page still called the deprecated `POST
/api/v1/resetPassword`. It now uses the generated `useResetPassword`
hook, which targets `POST /api/v2/factor_password/reset`.
- Deletes the hand-written v1 client and its types; nothing else
referenced them.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/9804f619-5928-4c72-83a2-2aae16855e7f


#### Additional Information

- Manual `loading` and `errorMessage` state give way to the hook's
`isLoading` and `convertToApiError`, matching how `ForgotPassword`
consumes its generated hook.
- Once this merges, `POST /api/v1/resetPassword` has no callers left in
the product.
2026-08-10 15:17:06 +00:00
Aditya Singh
b4f5b3eddf fix(query-builder): normalise doc length in codemirror fixing Selection points outside of document error (#12496)
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.


<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
To fix the above mentioned problem, we now switch the cursor position
from `value.length` (which is not yet normalized by CodeMirror) to
`changes.newLength`, which is the normalized length.
Added test case


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

Closes https://github.com/SigNoz/engineering-pod/issues/5869

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before


https://github.com/user-attachments/assets/ec7a3182-177f-4545-9bae-83ee0c3a61db

After


https://github.com/user-attachments/assets/06350698-1960-47c2-b65e-81ea2d10b15d
2026-08-10 14:14:28 +00:00
Srikanth Chekuri
b9f4fcd681 chore(telemetrytypes): introduce LogicalField (#12499)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Possible options

1. The compatibility keys maps (the approach already in the code).

`backward_compat_keys.go` makes an alias key at metadata time. We
rejected this option because of evidence. The alias key resolves, but it
reads the wrong data. It prepares to `attributes_string['<alias>']`, and
that physical key does not hold the data.

2. The flat multi-key.

`GetKeys` returns multiple keys in order, and the downstream code uses
the list. The option fails on semantics. It removes one piece of
information that the downstream must have. The downstream must know the
difference between two cases:

- Two keys are the same field with two spellings so we can merge them
into one expression.
- Two keys are different fields with the same name. The condition
builder must make one condition for each key. The operator connects the
conditions.

Three failures show the problem:

- Negative operators connect with OR across the keys. A row that has
only one spelling then always matches. Example: `env != 'prod'` matches
each row that does not have one of the two keys.
- A row that has both spellings with different values gets no clear
result.
- A value position (group-by, select) needs exactly one expression for
one field. A flat list cannot point to that expression.

The information must live somewhere.

3. Annotations on `TelemetryFieldKey`

Maintain the `SemconvMembers` and `SemconvMaterializedColumns` fields on
the keys. The information is the same as in option 4. But it's awkward
because "these N keys are one family, in this order" lives in N copies,
one copy on each key.

4. introduce `LogicalField`

The information is the same as in option 3, but the structure holds it:

- The slice is the ambiguity.
- The group is the family.
- The member order is the precedence. The code sorts the members one
time, by family rank, at construction.
- The members point to the metadata entries. The code copies nothing and
changes nothing.
- The identity (signal, context, data type) is on the group. A merge
across contexts or data types is not possible. The design does not avoid
that merge; the design cannot express it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:40:37 +00:00
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
87 changed files with 4147 additions and 13306 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

@@ -376,7 +376,19 @@ function App(): JSX.Element {
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {
beforeSend(event, hint) {
const error = hint?.originalException as
| { name?: string; code?: string | number }
| undefined;
// Ignore benign aborted/cancelled requests (axios + fetch).
if (error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED') {
return null;
}
if (error?.name === 'AbortError') {
return null;
}
// Drop the event if its level is 'warning' or 'info'
if (event.level === 'warning' || event.level === 'info') {
return null;

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

@@ -1,31 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
/**
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/resetPassword`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default resetPassword;

View File

@@ -5,10 +5,9 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useGetRolesByUserID,
useCreateUserRole,
useDeleteUserRole,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
@@ -25,15 +24,14 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useDeleteUserRole: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useCreateUserRole: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
getGetUserQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}`,
],
}));
@@ -194,11 +192,7 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
(useDeleteUserRole as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
@@ -210,7 +204,7 @@ describe('EditMemberDrawer', () => {
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
(useCreateUserRole as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
@@ -312,12 +306,12 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('adding a new role calls setRole without removing existing ones', async () => {
it('adding a new role creates a user role without removing existing ones', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
(useCreateUserRole as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
@@ -334,15 +328,14 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
data: { userId: 'user-1', roleId: managedRoles[1].id },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('deselecting a role calls removeRole with the role id', async () => {
it('deselecting a role deletes the user role by its assignment id', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -361,7 +354,7 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
pathParams: { id: 'ur-1' },
});
expect(onComplete).toHaveBeenCalled();
});

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

@@ -22,6 +22,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -116,6 +117,7 @@ function EntityEventsContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -29,6 +29,7 @@ import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
@@ -132,6 +133,7 @@ function EntityLogsContent({
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -22,6 +22,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -98,6 +99,7 @@ function EntityTracesContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.TRACES, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
@@ -23,12 +24,14 @@ function MetricsSearch({
);
const handleStageAndRunQuery = useCallback(() => {
saveRecentQueryByExpression(DataSource.METRICS, currentQueryFilterExpression);
onChange(currentQueryFilterExpression);
onRunQuery?.();
}, [currentQueryFilterExpression, onChange, onRunQuery]);
const handleRunQuery = useCallback(
(expression: string): void => {
saveRecentQueryByExpression(DataSource.METRICS, expression);
setCurrentQueryFilterExpression(expression);
onChange(expression);
},

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

@@ -25,7 +25,6 @@ jest.mock('@signozhq/ui/switch', () => ({
import SSOEnforcementToggle from '../SSOEnforcementToggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
@@ -58,7 +57,7 @@ describe('SSOEnforcementToggle', () => {
isDefaultChecked={false}
record={{
...mockGoogleAuthDomain,
enabled: false,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
}}
/>,
);
@@ -96,42 +95,13 @@ describe('SSOEnforcementToggle', () => {
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
config: mockGoogleAuthDomain.config,
config: expect.objectContaining({
ssoEnabled: false,
}),
}),
);
});
// The toggle sends a full replacement, so anything it fails to echo back is
// dropped from the domain — role mappings included.
it('echoes the existing role mapping when toggling enforcement', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
mockUpdateAPI(await req.json());
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockDomainWithRoleMapping}
/>,
);
await user.click(screen.getByRole('switch'));
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
expect(mockUpdateAPI).toHaveBeenCalledWith({
enabled: false,
config: mockDomainWithRoleMapping.config,
roleMapping: mockDomainWithRoleMapping.roleMapping,
});
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

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

@@ -33,7 +33,7 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
const RESET_PASSWORD_ENDPOINT = '*/api/v2/factor_password/reset';
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Form, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useResetPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
@@ -14,7 +15,6 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
import { Label } from 'pages/SignUp/styles';
import APIError from 'types/api/error';
import { FormContainer } from './styles';
@@ -26,40 +26,41 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<APIError | null>();
const [isValidPassword, setIsValidPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
const { notifications } = useNotifications();
const {
mutate: resetPassword,
isLoading,
error: mutationError,
} = useResetPassword();
const errorMessage = useMemo(
() => convertToApiError(mutationError),
[mutationError],
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
await resetPasswordApi({
password,
token: token || '',
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
setLoading(false);
} catch (error) {
setLoading(false);
setErrorMessage(error as APIError);
}
resetPassword(
{ data: { password, token: token || '' } },
{
onSuccess: (): void => {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
},
},
);
};
const validatePassword = (): boolean => {
@@ -222,7 +223,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || loading}
disabled={!isValidPassword || isLoading}
className="reset-password-submit-button"
suffix={<ArrowRight size={16} />}
>

View File

@@ -1,19 +1,22 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
AuthtypesGettableRoleDTO,
AuthtypesUserRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useCreateUserRole,
useDeleteUserRole,
useGetUser,
} from 'api/generated/services/users';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
// Stable identity so the memos below do not recompute on every render.
const EMPTY_USER_ROLES: AuthtypesUserRoleDTO[] = [];
export interface MemberRoleUpdateFailure {
roleName: string;
error: unknown;
@@ -33,31 +36,31 @@ export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const queryClient = useQueryClient();
const { data, isLoading } = useGetRolesByUserID(
const { data, isLoading } = useGetUser(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const userRoles = data?.data?.userRoles ?? EMPTY_USER_ROLES;
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
() => userRoles.map((userRole) => userRole.role),
[userRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
[userId, queryClient],
// DELETE /api/v2/user_roles/{id} is keyed by the user_role join row, not the role.
const assignmentIdByRoleId = useMemo(
() => new Map(userRoles.map((userRole) => [userRole.roleId, userRole.id])),
[userRoles],
);
const { mutateAsync: createUserRole } = useCreateUserRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteUserRole } = useDeleteUserRole({
mutation: { retry: retryOn429 },
});
const applyDiff = useCallback(
async (
localRoleIds: string[],
@@ -80,30 +83,33 @@ export function useMemberRoleManager(
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof removeRole> =>
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
run: (): ReturnType<typeof createUserRole> =>
createUserRole({ data: { userId, roleId: role.id ?? '' } }),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteUserRole> =>
deleteUserRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -113,7 +119,6 @@ export function useMemberRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -121,7 +126,7 @@ export function useMemberRoleManager(
return failures;
},
[userId, currentRoles, setRole, removeRole, invalidateRoles],
[userId, currentRoles, assignmentIdByRoleId, createUserRole, deleteUserRole],
);
return { currentRoles, isLoading, applyDiff };

View File

@@ -19,6 +19,30 @@ type CompositeWithBuilder = {
builder?: { queryData?: IBuilderQuery[] };
};
export function saveRecentQueryByExpression(
dataSource: IBuilderQuery['dataSource'],
expression: string | null | undefined,
source = '',
): void {
const trimmed = expression?.trim();
if (!trimmed) {
return;
}
const validation = validateQuery(trimmed);
if (!validation.isValid) {
return;
}
const signal = toSignal(dataSource);
if (!signal) {
return;
}
store.save({
signal,
source,
filter: { expression: trimmed },
});
}
// Persists each builder query in the composite as a recent entry. Call this
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
// other derived state pollutes recents with navigation/refresh/go-to traffic.
@@ -31,22 +55,10 @@ export function saveRecentQuery(
}
queryData.forEach((q) => {
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
source: q.source ?? '',
filter: q.filter ?? { expression: '' },
});
saveRecentQueryByExpression(
q.dataSource,
q.filter?.expression,
q.source ?? '',
);
});
}

View File

@@ -1,9 +0,0 @@
export interface Props {
token: string;
password: string;
}
export interface PayloadProps {
data: string;
status: string;
}

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_SQL_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="4.67 10.44 4.67 13.45 12 17.35 12 14.34 4.67 10.44"/><polygon class="cls-1" points="4.67 15.09 4.67 18.1 12 22 12 18.99 4.67 15.09"/><polygon class="cls-2" points="12 17.35 19.33 13.45 19.33 10.44 12 14.34 12 17.35"/><polygon class="cls-2" points="12 22 19.33 18.1 19.33 15.09 12 18.99 12 22"/><polygon class="cls-3" points="19.33 8.91 19.33 5.9 12 2 12 5.01 19.33 8.91"/><polygon class="cls-2" points="12 2 4.67 5.9 4.67 8.91 12 5.01 12 2"/><polygon class="cls-1" points="4.67 5.87 4.67 8.89 12 12.79 12 9.77 4.67 5.87"/><polygon class="cls-2" points="12 12.79 19.33 8.89 19.33 5.87 12 9.77 12 12.79"/></g></g></svg>

After

Width:  |  Height:  |  Size: 933 B

View File

@@ -0,0 +1,136 @@
{
"id": "cloudsql_mysql",
"title": "GCP Cloud SQL for MySQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "cloudsql.googleapis.com/database/up",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/instance_state",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/replication/replica_lag",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/network/connections",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/queries",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/dml_operations_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/threads",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_reads_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_read_requests_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/slow_queries_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/aborted_connects_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/deadlocks_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/mysql/innodb/row_lock_waits_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/read_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/write_ops_count",
"unit": "Count",
"type": "Sum",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL for MySQL Overview",
"description": "Overview of GCP Cloud SQL for MySQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Cloud SQL for MySQL with SigNoz
Collect key GCP Cloud SQL for MySQL metrics and view them with an out of the box dashboard.

View File

@@ -784,40 +784,57 @@
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"kind": "signoz/CompositeQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
"queries": [
{
"metricName": "cloudsql.googleapis.com/database/cpu/utilization",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND database_id in $database_id AND gcp.resource_type = 'cloudsql_database' "
},
"groupBy": [
"type": "builder_query",
"spec": {
"name": "A",
"stepInterval": 0,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "cloudsql.googleapis.com/database/cpu/utilization",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": ""
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND database_id in $database_id AND gcp.resource_type = 'cloudsql_database' "
},
"groupBy": [
{
"name": "database_id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": null,
"selectFields": null,
"secondaryAggregations": null,
"functions": null,
"legend": "{{database_id}}"
}
},
{
"name": "database_id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "100 * A",
"disabled": false,
"order": null,
"functions": null,
"legend": "{{database_id}}"
}
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{database_id}}"
]
}
}
}
@@ -1417,4 +1434,4 @@
"refreshInterval": "",
"links": []
}
}
}

View File

@@ -245,11 +245,13 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
existing, err := module.GetV2(ctx, orgID, id)
// Read the storable, not the decoded v2 dashboard: deleting must work even
// when the stored data is corrupt or never migrated off the v1 schema.
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.ErrIfNotDeletable(); err != nil {
if err := storable.ErrIfNotDeletable(); err != nil {
return err
}

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,189 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"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
}
// Documents written before the provider enum became a valuer.String spell
// the discriminator uppercase ("SAML", "GOOGLE_AUTH"); reads lowercase it
// through valuer.NewString, so those rows still carry the original casing
// on disk. Every lookup and spec rewrite below keys off this value.
ssoType = strings.ToLower(strings.TrimSpace(ssoType))
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

@@ -1,127 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"log/slog"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
_ "modernc.org/sqlite"
)
func TestRestructureAuthDomainConfig(t *testing.T) {
testCases := []struct {
name string
data string
expectedData string
}{
{
// Rows written before the provider enum became a valuer.String spell
// the discriminator uppercase and were never rewritten on disk.
name: "UppercaseSAML",
data: `{"ssoEnabled":true,"ssoType":"SAML","samlConfig":{"samlEntity":"entity","samlIdp":"https://idp.example.com/sso","samlCert":"cert"},"roleMapping":{"defaultRole":"signoz-admin"}}`,
expectedData: `{"enabled":true,"config":{"kind":"saml","spec":{"entityId":"entity","location":"https://idp.example.com/sso","certificate":"cert"}},"roleMapping":{"defaultRole":"signoz-admin"}}`,
},
{
name: "UppercaseGoogleAuth",
data: `{"ssoEnabled":true,"ssoType":"GOOGLE_AUTH","googleAuthConfig":{"clientId":"cid","clientSecret":"secret","redirectURI":"https://example.com/callback"}}`,
expectedData: `{"enabled":true,"config":{"kind":"google","spec":{"clientId":"cid","clientSecret":"secret"}}}`,
},
{
name: "MixedCaseWithSurroundingWhitespace",
data: `{"ssoEnabled":false,"ssoType":" Saml ","samlConfig":{"samlEntity":"entity","samlIdp":"location","samlCert":"cert"}}`,
expectedData: `{"enabled":false,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert"}}}`,
},
{
name: "LowercaseSAML",
data: `{"ssoEnabled":true,"ssoType":"saml","samlConfig":{"samlEntity":"entity","samlIdp":"location","samlCert":"cert","samlSkipSigning":true}}`,
expectedData: `{"enabled":true,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert","samlSkipSigning":true}}}`,
},
{
name: "LowercaseGoogleAuthWithWorkspaceGroups",
data: `{"ssoEnabled":true,"ssoType":"google_auth","googleAuthConfig":{"clientId":"cid","clientSecret":"secret","redirectURI":"https://example.com/callback","fetchGroups":true,"serviceAccountJson":"{}","domainToAdminEmail":{"*":"admin@example.com"},"allowedGroups":["eng@example.com"]},"roleMapping":{"defaultRole":"signoz-viewer","groupMappings":{"eng":"signoz-editor"}}}`,
expectedData: `{"enabled":true,"config":{"kind":"google","spec":{"clientId":"cid","clientSecret":"secret","fetchGroups":true,"serviceAccountJson":"{}","domainToAdminEmail":{"*":"admin@example.com"},"allowedGroups":["eng@example.com"]}},"roleMapping":{"defaultRole":"signoz-viewer","groupMappings":{"eng":"signoz-editor"}}}`,
},
{
name: "LowercaseOIDC",
data: `{"ssoEnabled":true,"ssoType":"oidc","oidcConfig":{"clientId":"cid","clientSecret":"secret","issuer":"https://issuer.example.com"}}`,
expectedData: `{"enabled":true,"config":{"kind":"oidc","spec":{"clientId":"cid","clientSecret":"secret","issuer":"https://issuer.example.com"}}}`,
},
{
name: "MissingSSOEnabledDefaultsToDisabled",
data: `{"ssoType":"saml","samlConfig":{"samlEntity":"entity","samlIdp":"location","samlCert":"cert"}}`,
expectedData: `{"enabled":false,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert"}}}`,
},
{
name: "NullRoleMappingIsDropped",
data: `{"ssoEnabled":true,"ssoType":"saml","samlConfig":{"samlEntity":"entity","samlIdp":"location","samlCert":"cert"},"roleMapping":null}`,
expectedData: `{"enabled":true,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert"}}}`,
},
{
name: "AlreadyRestructured",
data: `{"enabled":true,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert"}}}`,
expectedData: `{"enabled":true,"config":{"kind":"saml","spec":{"entityId":"entity","location":"location","certificate":"cert"}}}`,
},
{
name: "UnknownSSOType",
data: `{"ssoEnabled":true,"ssoType":"ldap","samlConfig":{"samlEntity":"entity"}}`,
expectedData: `{"ssoEnabled":true,"ssoType":"ldap","samlConfig":{"samlEntity":"entity"}}`,
},
{
name: "NullProviderConfig",
data: `{"ssoEnabled":true,"ssoType":"saml","samlConfig":null}`,
expectedData: `{"ssoEnabled":true,"ssoType":"saml","samlConfig":null}`,
},
{
name: "UnreadableData",
data: `not json`,
expectedData: `not json`,
},
}
ctx := context.Background()
sqldb, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "test.db"))
require.NoError(t, err)
defer sqldb.Close()
db := bun.NewDB(sqldb, sqlitedialect.New())
// Only the two columns the migration reads and writes.
_, err = db.ExecContext(ctx, `CREATE TABLE auth_domain (id TEXT PRIMARY KEY, data TEXT NOT NULL)`)
require.NoError(t, err)
for _, testCase := range testCases {
_, err := db.NewInsert().
Model(&restructureAuthDomainRow{ID: testCase.name, Data: testCase.data}).
Exec(ctx)
require.NoError(t, err)
}
migration := &restructureAuthDomainConfig{logger: slog.New(slog.DiscardHandler)}
// Running twice pins idempotency: a restructured document carries no
// ssoType, so the second pass must leave every row untouched.
for range 2 {
require.NoError(t, migration.Up(ctx, db))
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
row := new(restructureAuthDomainRow)
require.NoError(t, db.NewSelect().Model(row).Where("id = ?", testCase.name).Scan(ctx))
if testCase.name == "UnreadableData" {
assert.Equal(t, testCase.expectedData, row.Data)
return
}
assert.JSONEq(t, testCase.expectedData, row.Data)
})
}
}
}

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

@@ -1,7 +1,6 @@
package authtypes
import (
"bytes"
"context"
"encoding/json"
"regexp"
@@ -10,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"
)
@@ -30,81 +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.
//
// rejectUnknownSpecFields decodes into a method-less alias of the spec: a type
// carrying its own UnmarshalJSON consumes the bytes itself, which would bypass
// DisallowUnknownFields. It only sees the spec's own fields, not those of
// nested objects that unmarshal themselves.
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
},
rejectUnknownSpecFields: func(data []byte) error {
type alias SamlConfig
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
return decoder.Decode(new(alias))
},
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
},
rejectUnknownSpecFields: func(data []byte) error {
type alias GoogleConfig
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
return decoder.Decode(new(alias))
},
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
},
rejectUnknownSpecFields: func(data []byte) error {
type alias OIDCConfig
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
return decoder.Decode(new(alias))
},
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"`
}
@@ -113,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 {
@@ -135,47 +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)
rejectUnknownSpecFields func(data []byte) 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 {
@@ -203,270 +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
}
// rejectForeignSpecFields rejects a request whose config.spec carries fields the
// declared kind does not define. Without it a spec belonging to another provider
// decodes as a partial config of the declared kind whenever their required
// fields overlap — an oidc spec sent as kind google loses its issuer and points
// the domain at Google instead of the intended provider.
//
// This is a request-shape check by design: stored documents keep decoding
// leniently so a rollback can still read a document a newer binary wrote, and so
// removing a field later does not need a migration.
func rejectForeignSpecFields(body []byte, kind AuthNProvider) error {
var raw struct {
Config struct {
Spec json.RawMessage `json:"spec"`
} `json:"config"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "failed to unmarshal auth domain config")
}
for _, variant := range authDomainConfigVariants {
if variant.kind != kind {
continue
}
if err := variant.rejectUnknownSpecFields(raw.Config.Spec); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid %q spec", kind.StringValue())
}
}
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")
}
if err := rejectForeignSpecFields(data, temp.Config.Kind); err != nil {
return err
}
*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")
}
if err := rejectForeignSpecFields(data, temp.Config.Kind); err != nil {
return err
}
*typ = UpdatableAuthDomain(temp)
return nil
}

View File

@@ -1,109 +0,0 @@
package authtypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostableAuthDomainSpecFields(t *testing.T) {
testCases := []struct {
name string
body string
expectedError bool
}{
{
name: "SAMLSpec",
body: `{"name":"a.test","enabled":true,"config":{"kind":"saml","spec":{"entityId":"e","location":"l","certificate":"c"}}}`,
expectedError: false,
},
{
name: "SAMLSpecWithAttributeMapping",
body: `{"name":"a.test","enabled":true,"config":{"kind":"saml","spec":{"entityId":"e","location":"l","certificate":"c","insecureSkipAuthNRequestsSigned":true,"attributeMapping":{"email":"mail"}}}}`,
expectedError: false,
},
{
name: "GoogleSpec",
body: `{"name":"a.test","enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s","insecureSkipEmailVerified":false}}}`,
expectedError: false,
},
{
name: "GoogleSpecWithWorkspaceGroups",
body: `{"name":"a.test","enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s","fetchGroups":true,"serviceAccountJson":"{}","domainToAdminEmail":{"*":"admin@a.test"},"fetchTransitiveGroupMembership":true,"allowedGroups":["g@a.test"]}}}`,
expectedError: false,
},
{
name: "OIDCSpec",
body: `{"name":"a.test","enabled":true,"config":{"kind":"oidc","spec":{"issuer":"https://issuer.a.test","clientId":"c","clientSecret":"s"}}}`,
expectedError: false,
},
{
// clientId and clientSecret are google's only required fields, so an
// oidc spec satisfies them and would otherwise be accepted with its
// issuer silently dropped.
name: "OIDCSpecUnderGoogleKind",
body: `{"name":"a.test","enabled":true,"config":{"kind":"google","spec":{"issuer":"https://issuer.a.test","clientId":"c","clientSecret":"s","claimMapping":{"email":"mail"}}}}`,
expectedError: true,
},
{
name: "GoogleSpecUnderOIDCKind",
body: `{"name":"a.test","enabled":true,"config":{"kind":"oidc","spec":{"issuer":"https://issuer.a.test","clientId":"c","clientSecret":"s","fetchGroups":true}}}`,
expectedError: true,
},
{
name: "SAMLSpecUnderGoogleKind",
body: `{"name":"a.test","enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s","entityId":"e"}}}`,
expectedError: true,
},
{
name: "UnrecognizedFieldInGoogleSpec",
body: `{"name":"a.test","enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s","redirectURI":"https://a.test/cb"}}}`,
expectedError: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
postable := new(PostableAuthDomain)
err := json.Unmarshal([]byte(testCase.body), postable)
if testCase.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}
// The enforcement toggle and the edit modal both PUT a full replacement, so the
// update body needs the same check as the create body.
func TestUpdatableAuthDomainSpecFields(t *testing.T) {
updatable := new(UpdatableAuthDomain)
err := json.Unmarshal([]byte(`{"enabled":true,"config":{"kind":"google","spec":{"issuer":"https://issuer.a.test","clientId":"c","clientSecret":"s"}}}`), updatable)
assert.Error(t, err)
updatable = new(UpdatableAuthDomain)
err = json.Unmarshal([]byte(`{"enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s"}}}`), updatable)
assert.NoError(t, err)
}
// Reads stay lenient on purpose: a document written by a newer binary has to keep
// loading after a rollback, and dropping a field must not need a migration.
func TestNewAuthDomainFromStorableAuthDomainKeepsUnknownSpecFields(t *testing.T) {
storable := &StorableAuthDomain{
Data: `{"enabled":true,"config":{"kind":"google","spec":{"clientId":"c","clientSecret":"s","fieldFromANewerVersion":"x"}}}`,
}
authDomain, err := NewAuthDomainFromStorableAuthDomain(storable)
require.NoError(t, err)
google, err := authDomain.Config().GoogleConfig()
require.NoError(t, err)
assert.Equal(t, AuthNProviderGoogle, authDomain.Kind())
assert.Equal(t, "c", google.ClientID)
}

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

@@ -46,6 +46,7 @@ var (
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
GCPServiceGKE = ServiceID{valuer.NewString("gke")}
GCPServiceCloudStorage = ServiceID{valuer.NewString("cloudstorage")}
GCPServiceCloudSQLMySQL = ServiceID{valuer.NewString("cloudsql_mysql")}
)
func (ServiceID) Enum() []any {
@@ -82,6 +83,7 @@ func (ServiceID) Enum() []any {
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
GCPServiceCloudSQLMySQL,
}
}
@@ -124,6 +126,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
GCPServiceCloudSQLMySQL,
},
}

View File

@@ -201,6 +201,18 @@ func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
return widgetIds
}
// ErrIfNotDeletable gates deletion on the columns alone, never on Data, so a
// dashboard whose data is corrupt or stuck on the v1 schema stays deletable.
func (storable StorableDashboard) ErrIfNotDeletable() error {
if storable.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !storable.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", storable.Source)
}
return nil
}
func (dashboard *Dashboard) ErrIfNotMutable() error {
if dashboard.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")

View File

@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
@@ -81,3 +82,64 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
})
}
}
func TestStorableDashboardErrIfNotDeletable(t *testing.T) {
testCases := []struct {
subtestName string
locked bool
source Source
data StorableDashboardData
expectDeletable bool
}{
{
subtestName: "user dashboard on the v2 schema",
source: SourceUser,
data: StorableDashboardData{"metadata": map[string]any{"schemaVersion": SchemaVersion}},
expectDeletable: true,
},
{
subtestName: "user dashboard still on the v1 schema",
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: true,
},
{
subtestName: "user dashboard with unreadable data",
source: SourceUser,
data: StorableDashboardData{"metadata": "not-an-object"},
expectDeletable: true,
},
{
subtestName: "locked user dashboard",
locked: true,
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "system dashboard",
source: SourceSystem,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "integration dashboard",
source: SourceIntegration,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
}
for _, tc := range testCases {
t.Run(tc.subtestName, func(t *testing.T) {
storable := StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: valuer.GenerateUUID(),
Locked: tc.locked,
Source: tc.source,
Data: tc.data,
}
assert.Equal(t, tc.expectDeletable, storable.ErrIfNotDeletable() == nil)
})
}
}

View File

@@ -129,16 +129,6 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !d.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", d.Source)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)

View File

@@ -0,0 +1,78 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -0,0 +1,50 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}

View File

@@ -1,188 +0,0 @@
import { expect, type Locator, 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;
}
/** Names of every auth domain in the org, across all list pages. */
export async function listAuthDomainNamesViaApi(page: Page): Promise<string[]> {
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<{ name: string }> | null;
};
return (json.data ?? []).map((domain) => domain.name);
}
/** 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();
}
/**
* Locate the list row for `name`, advancing through the pager when needed. The
* table paginates, and a shared stack holds domains this suite did not seed, so
* a freshly created row is not necessarily on the page currently shown.
*/
export async function findAuthDomainRow(
page: Page,
name: string,
): Promise<Locator> {
const row = page.getByTestId(`auth-domain-row-${name}`);
const nextPage = page.locator('.auth-domain-list .ant-pagination-next');
// Bounded: a pager that never reports itself disabled must not spin forever.
for (let visited = 0; visited < 25; visited += 1) {
try {
await row.waitFor({ state: 'attached', timeout: 2_000 });
return row;
} catch {
// Not on the page currently shown; fall through to the pager.
}
if (
(await nextPage.count()) === 0 ||
(await nextPage.getAttribute('aria-disabled')) === 'true' ||
(await nextPage.evaluate((node) =>
node.classList.contains('ant-pagination-disabled'),
))
) {
break;
}
await nextPage.click();
}
throw new Error(`auth domain row not found in the list: ${name}`);
}
/** Open the Configure (edit) modal for the domain row named `name`. */
export async function openConfigureAuthDomain(
page: Page,
name: string,
): Promise<void> {
const row = await findAuthDomainRow(page, name);
await row.getByTestId('auth-domain-configure').click();
await expect(page.getByTestId('auth-domain-form')).toBeVisible();
}

View File

@@ -1,275 +0,0 @@
import { expect, test } from '../../fixtures/auth';
import {
createGoogleAuthDomainViaApi,
deleteAuthDomainByNameViaApi,
findAuthDomainRow,
gotoAuthDomains,
listAuthDomainNamesViaApi,
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 = await findAuthDomainRow(page, domain);
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 = (await findAuthDomainRow(page, 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();
const reloadedRow = await findAuthDomainRow(page, domain);
await expect(reloadedRow.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);
const row = await findAuthDomainRow(page, domain);
await row.getByTestId('auth-domain-delete').click();
await page.getByTestId('auth-domain-delete-confirm').click();
await expect(page.getByText('Domain deleted successfully')).toBeVisible();
// Asserted against the list rather than the rendered page: an absent row
// proves nothing while the table paginates.
expect(await listAuthDomainNamesViaApi(page)).not.toContain(domain);
});
test('TC-08 enforce sso toggle preserves the role mapping', async ({
authedPage: page,
}) => {
const domain = 'sso-toggle-rolemap.example.com';
cleanupNames.push(domain);
await deleteAuthDomainByNameViaApi(page, domain);
await createGoogleAuthDomainViaApi(page, {
name: domain,
enabled: false,
roleMapping: {
defaultRole: 'signoz-editor',
groupMappings: { engineers: 'signoz-editor', support: 'signoz-viewer' },
},
});
await gotoAuthDomains(page);
// The toggle PUTs a full replacement, so a role mapping it fails to echo
// back is silently dropped.
const putResponse = page.waitForResponse(
(res) =>
res.request().method() === 'PUT' &&
res.url().includes('/api/v2/auth_domains/'),
);
const row = await findAuthDomainRow(page, domain);
await row.getByTestId('auth-domain-enforce-sso').click();
expect((await putResponse).status()).toBe(204);
await page.reload();
await openConfigureAuthDomain(page, domain);
await page.getByTestId('role-mapping-header').click();
const rows = page.getByTestId('role-mapping-row');
await expect(rows).toHaveCount(2);
await expect(rows.nth(0).getByTestId('role-mapping-group-name')).toHaveValue(
'engineers',
);
await expect(rows.nth(1).getByTestId('role-mapping-group-name')).toHaveValue(
'support',
);
});

View File

@@ -26,14 +26,14 @@ class ProviderAccountSpec:
provider: str
# params for the account created by default.
initial_params: dict
# params for the config an update (PUT) test sends.
updated_params: dict
# params -> the provider-keyed `config` block for a POST/PUT body.
build_config: Callable[[dict], dict]
# params -> the full config block the API is expected to return under
# config[provider] on GET/list. This may differ from what build_config sends:
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
expected_config: Callable[[dict], dict]
# only the suites that exercise updates need to supply it.
updated_params: dict = field(default_factory=dict)
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
@@ -42,6 +42,29 @@ class ProviderAccountSpec:
object.__setattr__(self, "id", self.provider)
# Per-provider service shape.
@dataclass(frozen=True)
class ProviderServiceSpec:
provider: str
service_id: str
# GCP ships every service with supportedSignals.logs false, so a logs block
# is neither required on write nor persisted.
supports_logs: bool
account_config: dict
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
def __post_init__(self) -> None:
if not self.id:
object.__setattr__(self, "id", self.provider)
def build_service_config(self, metrics_enabled: bool, logs_enabled: bool | None = None) -> dict:
config: dict = {"metrics": {"enabled": metrics_enabled}}
if self.supports_logs:
config["logs"] = {"enabled": metrics_enabled if logs_enabled is None else logs_enabled}
return {self.provider: config}
@pytest.fixture(scope="function")
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,

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

File diff suppressed because it is too large Load Diff

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,58 +117,18 @@ def test_create_invalid(
assert response.status_code == HTTPStatus.BAD_REQUEST
# The reverse direction: an oidc spec under kind google satisfies google's
# required clientId and clientSecret, so only the foreign issuer and
# claimMapping fields distinguish it.
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": "domain.integration.test",
"enabled": True,
"config": {
"kind": "google",
"spec": {
"issuer": "https://issuer.integration.test",
"clientId": "client-id",
"clientSecret": "client-secret",
"claimMapping": {"email": "mail"},
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
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",
},
},
},
@@ -186,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,
@@ -206,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",
},
@@ -226,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}"},
@@ -250,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
},
},
},
},
@@ -277,411 +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
role_mapping = {
"defaultRole": "signoz-editor",
"groupMappings": {"admin-group": "signoz-admin", "dev-team": "signoz-editor"},
"useRoleAttribute": False,
}
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",
},
},
"roleMapping": role_mapping,
},
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 and role mapping must both survive the
# round trip, since the UI resends them verbatim on every toggle.
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",
},
},
"roleMapping": role_mapping,
},
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"},
},
}
assert data["roleMapping"] == role_mapping
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT

View File

@@ -50,16 +50,16 @@ def test_create_auth_domain(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/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}"},

View File

@@ -1,14 +1,52 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import ProviderAccountSpec
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
signoz: types.SigNoz,
@@ -20,19 +58,19 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_create_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "aws"
data = create_cloud_integration_account(
admin_token,
cloud_provider,
deployment_region="us-east-1",
regions=["us-east-1", "us-west-2"],
spec.provider,
config=spec.build_config(spec.initial_params),
)
assert "id" in data, "Response data should contain 'id' field"
@@ -40,12 +78,17 @@ def test_create_account(
assert "connectionArtifact" in data, "Response data should contain 'connectionArtifact' field"
artifact = data["connectionArtifact"]
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert "region=us-east-1" in connection_url, "connectionUrl should contain the deployment region"
if spec.provider == "aws":
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert f"region={spec.initial_params['deployment_region']}" in connection_url, "connectionUrl should contain the deployment region"
else:
# GCP is a manual flow: no one-click install artifact.
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
def test_create_account_unsupported_provider(
@@ -76,3 +119,36 @@ def test_create_account_unsupported_provider(
response_data = response.json()
assert "error" in response_data, "Response should contain 'error' field"
def test_create_gcp_account_without_project_ids(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""GCP account config requires at least one project ID to monitor."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": [],
}
},
"credentials": {
"sigNozApiURL": "https://test.signoz.cloud",
"sigNozApiKey": "test-key",
"ingestionUrl": "https://ingest.test.signoz.cloud",
"ingestionKey": "test-ingestion-key",
},
},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 for empty projectIds, got {response.status_code}: {response.text}"
assert "error" in response.json(), "Response should contain 'error' field"

View File

@@ -2,14 +2,53 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.cloudintegrations import (
ProviderAccountSpec,
simulate_agent_checkin,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
@@ -22,22 +61,28 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_agent_check_in(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
signoz,
admin_token,
CLOUD_PROVIDER,
spec.provider,
account_id,
provider_account_id,
data={"version": "v0.0.8"},
@@ -47,57 +92,63 @@ def test_agent_check_in(
data = response.json()["data"]
# New camelCase fields
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
assert data["providerAccountId"] == provider_account_id, "providerAccountId should match"
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
assert data["removedAt"] is None, "removedAt should be null for a live account"
# Backward-compat snake_case fields
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
if spec.provider == "aws":
# Backward compat for agents deployed before the camelCase response; AWS only.
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
# integrationConfig should reflect the configured regions
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == ["us-east-1"], "enabledRegions should match account config"
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == spec.initial_params["regions"], "enabledRegions should match account config"
else:
# GCP is a manual flow: the agent carries its own configuration.
assert data["integrationConfig"].get("gcp") is None, f"GCP should not return an integration config, got: {data['integrationConfig']}"
@provider_spec
def test_agent_check_in_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
fake_id = str(uuid.uuid4())
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4()))
response = simulate_agent_checkin(signoz, admin_token, spec.provider, fake_id, str(uuid.uuid4()))
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}: {response.text}"
@provider_spec
def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Test that two different accounts cannot check in with the same providerAccountId."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account1 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
account2 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
same_provider_account_id = str(uuid.uuid4())
# First check-in: account1 claims the provider account ID
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account1["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.OK, f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
# Second check-in: account2 tries to claim the same provider account ID → 409
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account2["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"

View File

@@ -2,18 +2,47 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from sqlalchemy import bindparam, sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.cloudintegrations import (
ProviderServiceSpec,
simulate_agent_checkin,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
SERVICE_ID = "rds"
AWS_SERVICE_SPEC = ProviderServiceSpec(
provider="aws",
service_id="rds",
supports_logs=True,
account_config={"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}},
)
GCP_SERVICE_SPEC = ProviderServiceSpec(
provider="gcp",
service_id="cloudsql_postgres",
supports_logs=False,
account_config={
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": ["signoz-test-project"],
}
},
)
PROVIDER_SERVICE_SPECS = [AWS_SERVICE_SPEC, GCP_SERVICE_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_SERVICE_SPECS,
ids=[s.id for s in PROVIDER_SERVICE_SPECS],
)
def test_apply_license(
@@ -26,16 +55,18 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_list_services_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""List the cloud provider's supported services"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -53,35 +84,37 @@ def test_list_services_without_account(
assert "icon" in service, "Service should have 'icon' field"
assert "enabled" in service, "Service should have 'enabled' field"
EC2_SERVICE_ID = "ec2"
listed_ids = {s["id"] for s in data["services"]}
assert spec.service_id in listed_ids, f"'{spec.service_id}' should be listed for {spec.provider}"
@provider_spec
def test_list_account_services(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
"""ListAccountServicesMetadata reflects enabled state per service."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
json={"config": spec.build_service_config(True)},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable {spec.service_id} failed: {put_response.status_code}: {put_response.text}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -92,21 +125,28 @@ def test_list_account_services(
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
enabled_service = next((s for s in data["services"] if s["id"] == spec.service_id), None)
assert enabled_service is not None, f"Service '{spec.service_id}' not found in services list"
assert enabled_service["enabled"] is True, f"Service should be enabled, got: {enabled_service['enabled']}"
# The listing must report state per service, not blanket-enable or echo the write.
untouched_service = next((s for s in data["services"] if s["id"] != spec.service_id), None)
assert untouched_service is not None, "Expected more than one service in the listing"
assert untouched_service["enabled"] is False, f"Service '{untouched_service['id']}' was never enabled, got: {untouched_service['enabled']}"
@provider_spec
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get full service definition without specifying an account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -114,31 +154,36 @@ def test_get_service_details_without_account(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert "title" in data, "Service should have 'title'"
assert "overview" in data, "Service should have 'overview' (markdown)"
assert "assets" in data, "Service should have 'assets'"
assert isinstance(data["assets"]["dashboards"], list), "assets.dashboards should be a list"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
assert data["supportedSignals"]["metrics"] is True, "metrics should be a supported signal"
assert data["supportedSignals"]["logs"] is spec.supports_logs, f"logs support should be {spec.supports_logs} for {spec.provider}"
@provider_spec
def test_get_account_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service for a specific account — all disabled by default."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -146,20 +191,22 @@ def test_get_account_service(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any config is set"
@provider_spec
def test_get_service_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/non-existent-service"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -167,32 +214,34 @@ def test_get_service_not_found(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable a service and verify the config is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
json={"config": spec.build_service_config(True)},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -201,33 +250,39 @@ def test_update_service_config(
data = get_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after UpdateService"
assert svc["config"]["aws"]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
assert svc["config"][spec.provider]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["cloudIntegrationId"] == account_id, "cloudIntegrationId should match the account"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
else:
assert svc["config"][spec.provider].get("logs") is None, f"logs should not be stored for {spec.provider}, got: {svc['config'][spec.provider]}"
@provider_spec
def test_update_service_config_disable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable then disable a service — config change is persisted."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
# Enable
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
json={"config": spec.build_service_config(True)},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {r.status_code}: {r.text}"
@@ -236,13 +291,13 @@ def test_update_service_config_disable(
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
json={"config": spec.build_service_config(False)},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -250,28 +305,57 @@ def test_update_service_config_disable(
assert get_response.status_code == HTTPStatus.OK
svc = get_response.json()["data"]["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should still be present after disable"
assert svc["config"]["aws"]["metrics"]["enabled"] is False, "metrics should be disabled"
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
assert svc["config"][spec.provider]["metrics"]["enabled"] is False, "metrics should be disabled"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
@provider_spec
def test_update_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""PUT with a non-existent account UUID returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
json={"config": spec.build_service_config(True)},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
def test_update_gcp_service_without_metrics_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""GCP services support metrics only, so a config omitting metrics is rejected."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services/{GCP_SERVICE_SPEC.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"gcp": {"logs": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 when metrics config is missing, got {response.status_code}: {response.text}"
def test_list_services_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
@@ -289,30 +373,32 @@ def test_list_services_unsupported_provider(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_list_services_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""List services for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -320,30 +406,32 @@ def test_list_services_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_get_service_details_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service details for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -351,64 +439,68 @@ def test_get_service_details_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_update_service_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""PUT service config for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
json={"config": spec.build_service_config(True)},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_enable_metrics_provisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enabling metrics provisions dashboards visible in GetService and present in the DB."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
json={"config": spec.build_service_config(True, logs_enabled=False)},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
# Assertion 1: GetService returns provisioned dashboard UUIDs
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -417,7 +509,7 @@ def test_enable_metrics_provisions_dashboards(
data = get_svc_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after enabling metrics"
assert svc["config"]["aws"]["metrics"]["enabled"] is True
assert svc["config"][spec.provider]["metrics"]["enabled"] is True
dashboards_in_service = data["assets"]["dashboards"]
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
@@ -445,35 +537,37 @@ def test_enable_metrics_provisions_dashboards(
assert provisioned_ids == db_ids, f"Dashboards {provisioned_ids - db_ids} are missing from the DB"
@provider_spec
def test_disable_metrics_deprovisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Disabling metrics removes provisioned dashboards from both GetService and the dashboards list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
# Enable metrics to provision dashboards first
enable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
json={"config": spec.build_service_config(True, logs_enabled=False)},
timeout=10,
)
assert enable_response.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {enable_response.status_code}: {enable_response.text}"
# Capture the provisioned dashboard IDs before disabling
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -485,14 +579,14 @@ def test_disable_metrics_deprovisions_dashboards(
disable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
json={"config": spec.build_service_config(False)},
timeout=10,
)
assert disable_response.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {disable_response.status_code}: {disable_response.text}"
# Assertion 1: GetService no longer returns UUID dashboard IDs
get_svc_after = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -1,20 +1,14 @@
import os
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import get_column_data_from_response, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
FILTER_EXPRESSIONS_FILE = os.path.join(TESTDATA_DIR, "filter_expressions_10000.txt")
@pytest.mark.parametrize(
"expression,expected_logs",
@@ -180,101 +174,3 @@ def test_not_filter_expression(
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected_logs
def test_filter_expressions_no_server_error(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
insert_logs,
get_token: Callable[[str, str], str],
) -> None:
"""
Reads every line from filter_expressions_10000.txt and fires it as a filter
expression against the logs query endpoint.
Expressions may be valid (200) or invalid (400) — both are acceptable.
A 500 means the server crashed on the input and is a test failure.
All failing expressions are collected before asserting so the full list is
visible in one run.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=5),
body="alpha-log",
resources={
"f1": "v10",
"f2": "v20",
"f3": "v30",
},
attributes={
"f4": 40,
"f5": 50,
"f6": 60,
},
),
Logs(
timestamp=now - timedelta(seconds=3),
body="beta-log",
resources={
"f4": "v41",
"f5": "v51",
"f6": "v61",
},
attributes={
"f1": 11,
"f2": 21,
"f3": 31,
},
),
]
)
def _make_raw_logs_query(
signoz: types.SigNoz,
token: str,
filter_expression: str,
) -> requests.Response:
"""Helper to query raw logs with a filter expression over the last 30 seconds."""
now = datetime.now(tz=UTC)
return make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": filter_expression},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
failures: list[str] = []
with ThreadPoolExecutor(max_workers=40) as executor:
with open(FILTER_EXPRESSIONS_FILE, encoding="utf-8") as f:
futures = {executor.submit(_make_raw_logs_query, signoz, token, expr.rstrip("\n")): expr.rstrip("\n") for expr in f}
for future in as_completed(futures):
expr = futures[future]
if future.result().status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
failures.append(expr)
assert len(failures) <= 0, f"{len(failures)} expression(s) caused HTTP 500:\n" + "\n".join(f" {expr!r}" for expr in failures)