mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
3 Commits
main
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cffe99d3c | ||
|
|
3cb50873bb | ||
|
|
f118f78d9c |
@@ -515,13 +515,6 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
@@ -667,20 +660,17 @@ components:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
roleId:
|
||||
type: string
|
||||
userId:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
- roleId
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
@@ -4261,8 +4251,6 @@ components:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- clusterName
|
||||
- clusterCPU
|
||||
@@ -4271,7 +4259,6 @@ components:
|
||||
- clusterMemoryAllocatable
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesClusters:
|
||||
@@ -4327,8 +4314,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- daemonSetName
|
||||
- daemonSetCPU
|
||||
@@ -4340,7 +4325,6 @@ components:
|
||||
- desiredNodes
|
||||
- currentNodes
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDaemonSets:
|
||||
@@ -4396,8 +4380,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- deploymentName
|
||||
- deploymentCPU
|
||||
@@ -4409,7 +4391,6 @@ components:
|
||||
- desiredPods
|
||||
- availablePods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDeployments:
|
||||
@@ -4542,8 +4523,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
successfulPods:
|
||||
type: integer
|
||||
required:
|
||||
@@ -4559,7 +4538,6 @@ components:
|
||||
- failedPods
|
||||
- successfulPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesJobs:
|
||||
@@ -4650,14 +4628,11 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- namespaceName
|
||||
- namespaceCPU
|
||||
- namespaceMemory
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
@@ -4723,14 +4698,11 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
@@ -4776,64 +4748,6 @@ components:
|
||||
- failed
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesPodCountsByStatus:
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
containerCannotRun:
|
||||
type: integer
|
||||
containerCreating:
|
||||
type: integer
|
||||
crashLoopBackOff:
|
||||
type: integer
|
||||
createContainerConfigError:
|
||||
type: integer
|
||||
errImagePull:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
evicted:
|
||||
type: integer
|
||||
failed:
|
||||
type: integer
|
||||
imagePullBackOff:
|
||||
type: integer
|
||||
nodeAffinity:
|
||||
type: integer
|
||||
nodeLost:
|
||||
type: integer
|
||||
oomKilled:
|
||||
type: integer
|
||||
pending:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
shutdown:
|
||||
type: integer
|
||||
unexpectedAdmissionError:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
required:
|
||||
- pending
|
||||
- running
|
||||
- failed
|
||||
- unknown
|
||||
- crashLoopBackOff
|
||||
- imagePullBackOff
|
||||
- errImagePull
|
||||
- createContainerConfigError
|
||||
- containerCreating
|
||||
- oomKilled
|
||||
- completed
|
||||
- error
|
||||
- containerCannotRun
|
||||
- evicted
|
||||
- nodeAffinity
|
||||
- nodeLost
|
||||
- shutdown
|
||||
- unexpectedAdmissionError
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
@@ -4864,8 +4778,6 @@ components:
|
||||
type: number
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
@@ -4877,11 +4789,6 @@ components:
|
||||
type: number
|
||||
podPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podRestarts:
|
||||
format: int64
|
||||
type: integer
|
||||
podStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
podUID:
|
||||
type: string
|
||||
required:
|
||||
@@ -4894,34 +4801,9 @@ components:
|
||||
- podMemoryLimit
|
||||
- podPhase
|
||||
- podCountsByPhase
|
||||
- podStatus
|
||||
- podCountsByStatus
|
||||
- podRestarts
|
||||
- podAge
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesPodStatus:
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- failed
|
||||
- unknown
|
||||
- crashloopbackoff
|
||||
- imagepullbackoff
|
||||
- errimagepull
|
||||
- createcontainerconfigerror
|
||||
- containercreating
|
||||
- oomkilled
|
||||
- completed
|
||||
- error
|
||||
- containercannotrun
|
||||
- evicted
|
||||
- nodeaffinity
|
||||
- nodelost
|
||||
- shutdown
|
||||
- unexpectedadmissionerror
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesPods:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
@@ -5220,8 +5102,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
statefulSetCPU:
|
||||
format: double
|
||||
type: number
|
||||
@@ -5253,7 +5133,6 @@ components:
|
||||
- desiredPods
|
||||
- currentPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesStatefulSets:
|
||||
@@ -7551,13 +7430,6 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -7614,13 +7486,10 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
@@ -12383,188 +12252,6 @@ paths:
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
/api/v1/service_account_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
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
|
||||
"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:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_account_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
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
|
||||
"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:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing service account role
|
||||
operationId: GetServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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
|
||||
"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:
|
||||
- serviceaccount:read
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
summary: Get service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -13134,9 +12821,9 @@ paths:
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
operationId: CreateServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -13147,7 +12834,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -13199,9 +12886,9 @@ paths:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -22303,184 +21990,6 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a user
|
||||
operationId: CreateUserRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
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
|
||||
"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: Create user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/user_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a user
|
||||
operationId: DeleteUserRole
|
||||
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
|
||||
"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: Delete user role
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing user role
|
||||
operationId: GetUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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
|
||||
"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 user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -22934,7 +22443,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
@@ -22985,7 +22494,7 @@ paths:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# Authz
|
||||
|
||||
SigNoz uses [OpenFGA](https://openfga.dev/), a relationship-based access control (ReBAC) system, to authorize every request. Transactions are never attached to users directly — they are attached to **roles** (as relationship tuples in OpenFGA), and users or service accounts (principals) are made **assignees** of those roles. The central interface is `AuthZ` in [pkg/authz/authz.go](/pkg/authz/authz.go), backed by an embedded OpenFGA server in [pkg/authz/openfgaserver](/pkg/authz/openfgaserver/server.go).
|
||||
|
||||
As a feature author, you will rarely touch OpenFGA directly. You interact with two layers:
|
||||
|
||||
1. **The registries** in [pkg/types/coretypes](/pkg/types/coretypes) — where every resource, verb, and managed-role permission is declared in code.
|
||||
2. **The route wiring** in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver) — where each route declares what resource it touches and which verb it needs, and the middleware turns that into a check.
|
||||
|
||||
## What are the building blocks?
|
||||
|
||||
### Type
|
||||
|
||||
A `Type` is the coarse FGA type of a resource. The set is finite and defined in [pkg/types/coretypes/registry_type.go](/pkg/types/coretypes/registry_type.go): `user`, `serviceaccount`, `anonymous`, `role`, `organization`, `metaresource`, and `telemetryresource`. Each type carries a selector regex (what a valid ID looks like) and the verbs allowed on it.
|
||||
|
||||
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines, ...) or a `telemetryresource` (logs, traces, metrics). You should almost never need a new type.
|
||||
|
||||
### Verb
|
||||
|
||||
A `Verb` is the action being authorized. All verbs are defined in [pkg/types/coretypes/registry_verb.go](/pkg/types/coretypes/registry_verb.go): `create`, `read`, `update`, `delete`, `list`, `assignee`, `attach`, and `detach`.
|
||||
|
||||
- `assignee` is special: it is the membership relation between a subject and a role ("user X is an assignee of role Y"), not an action a route checks for.
|
||||
- `attach`/`detach` authorize linking two resources together (e.g. assigning a role to a service account).
|
||||
|
||||
### Kind
|
||||
|
||||
A `Kind` is the fine-grained name of your resource — `dashboard`, `rule`, `quick-filter`. Kinds are registered in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go). A kind rides on top of an existing type, so adding one does **not** require any OpenFGA schema change.
|
||||
|
||||
### Resource
|
||||
|
||||
A `Resource` ties a type and a kind together and knows how to render itself as an FGA object string. The interface lives in [pkg/types/coretypes/resource.go](/pkg/types/coretypes/resource.go):
|
||||
|
||||
```go
|
||||
type Resource interface {
|
||||
Type() Type
|
||||
Kind() Kind
|
||||
Prefix(orgId valuer.UUID) string // metaresource:organization/<orgID>/dashboard
|
||||
Object(orgId valuer.UUID, selector string) string
|
||||
Scope(verb Verb) string // dashboard:read
|
||||
AllowedVerbs() []Verb
|
||||
}
|
||||
```
|
||||
|
||||
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
|
||||
|
||||
### Selector
|
||||
|
||||
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
|
||||
|
||||
- `WildcardSelector` — the check is against all instances of the resource (`create`, `list`).
|
||||
- `IDSelector` — the check is against the specific instance *or* the wildcard (`read`, `update`, `delete` of one object). A subject authorized on `*` is authorized on every instance.
|
||||
|
||||
When the ID in the request is not what FGA needs (e.g. routes receive a role UUID but FGA objects use role names), write a custom `SelectorFunc` — see `roleSelector` in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
### Roles, transactions, and tuples
|
||||
|
||||
SigNoz ships four managed roles, declared in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go): `signoz-admin`, `signoz-editor`, `signoz-viewer`, and `signoz-anonymous`. Their permissions are declared in code as `Transaction`s (a verb on an object) in `ManagedRoleToTransactions` — this map is the single source of truth for what each managed role can do.
|
||||
|
||||
At organization bootstrap, `CreateManagedRoles` and `CreateManagedUserRoleTransactions` (see [pkg/authz/authz.go](/pkg/authz/authz.go)) persist the role rows and write one OpenFGA tuple per transaction, linking `role:organization/<orgID>/role/<name>#assignee` to each permitted object. Users and service accounts are then granted roles via `Grant`/`Revoke`, which writes `assignee` tuples. Custom roles (enterprise) are managed through the roles API in [pkg/authz/signozauthzapi/handler.go](/pkg/authz/signozauthzapi/handler.go).
|
||||
|
||||
### Schema
|
||||
|
||||
The OpenFGA authorization model is a hand-written DSL, embedded at build time: [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) for community and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) for enterprise. The community model only supports role assignment; the enterprise model defines per-verb relations on every type, enabling genuine per-resource checks. This split is why `CheckWithTupleCreation` behaves differently per edition (see [How does a check work at runtime?](#how-does-a-check-work-at-runtime)). You only touch these files when introducing a brand-new **type** — never for a new kind.
|
||||
|
||||
## How do I add authz to my feature?
|
||||
|
||||
### 1. Register the kind
|
||||
|
||||
Add your kind in [pkg/types/coretypes/registry_kind.go](/pkg/types/coretypes/registry_kind.go) and append it to `Kinds`:
|
||||
|
||||
```go
|
||||
KindThing = MustNewKind("thing")
|
||||
```
|
||||
|
||||
### 2. Register the resource
|
||||
|
||||
Add the resource in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) and append it to `Resources`:
|
||||
|
||||
```go
|
||||
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
|
||||
```
|
||||
|
||||
Pass an explicit verb list to `NewResourceMetaResource` only if your resource supports fewer verbs than the type default.
|
||||
|
||||
### 3. Grant permissions to managed roles
|
||||
|
||||
Decide what each managed role can do with your resource and add the transactions in [pkg/types/coretypes/registry_managed_role.go](/pkg/types/coretypes/registry_managed_role.go):
|
||||
|
||||
```go
|
||||
// thing — editors manage, viewers read
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
|
||||
```
|
||||
|
||||
The convention so far: admin gets everything, editor gets CRUD on day-to-day observability resources, viewer gets `read`/`list`, anonymous gets nothing (except public dashboards).
|
||||
|
||||
### 4. Wire the route
|
||||
|
||||
Register the route in [pkg/apiserver/signozapiserver](/pkg/apiserver/signozapiserver), wrapping your handler with `CheckResources` and declaring what the route touches via a `ResourceDef` ([pkg/http/handler/resourcedef.go](/pkg/http/handler/resourcedef.go)). A complete example from [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go):
|
||||
|
||||
```go
|
||||
router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
// ...
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost)
|
||||
```
|
||||
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
### 5. Backfill existing organizations (only if needed)
|
||||
|
||||
Managed-role tuples are written from the registry at organization creation, so **new resources need no migration for new organizations**. If existing organizations must get the new permissions, add a migration in [pkg/sqlmigration](/pkg/sqlmigration) that inserts the tuples — see [083_add_role_crud_tuples.go](/pkg/sqlmigration/083_add_role_crud_tuples.go) for the pattern.
|
||||
|
||||
## How does a check work at runtime?
|
||||
|
||||
1. The resource middleware ([pkg/http/middleware/resource.go](/pkg/http/middleware/resource.go)) runs on every request. It reads the matched handler's `ResourceDef`s, extracts the resource IDs from path/body, and stores the resolved resources in the request context.
|
||||
2. `CheckResources` reads them back, runs each `SelectorFunc`, and calls `AuthZ.CheckWithTupleCreation(ctx, claims, orgID, relation, resource, selectors, roleSelectors)`.
|
||||
3. What happens next depends on the edition:
|
||||
- **Community** ([pkg/authz/openfgaserver/server.go](/pkg/authz/openfgaserver/server.go)) ignores the resource and selectors and only checks whether the subject is an `assignee` of one of the allowed roles — a plain role gate.
|
||||
- **Enterprise** ([ee/authz/openfgaserver/server.go](/ee/authz/openfgaserver/server.go)) builds tuples via `authtypes.NewTuples` — subject `user:organization/<orgID>/user/<userID>`, relation `create`, object `serviceaccount:organization/<orgID>/serviceaccount/*` — and batch-checks them against OpenFGA: genuine per-resource authorization, including custom roles.
|
||||
|
||||
Because both paths go through the same middleware and the same `ResourceDef` declarations, a route wired once works correctly in both editions.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
|
||||
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
|
||||
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
|
||||
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.
|
||||
- Linking routes check **both** sides: peers via `AttachDetachSiblingResourceDef` (same verb on both), parent-child via a `BasicResourceDef` on the child (`create`/`delete`) paired with an `AttachDetachParentChildResourceDef` on the parent (`attach`/`detach`).
|
||||
- Changing `ManagedRoleToTransactions` only affects organizations created afterwards — add a [pkg/sqlmigration](/pkg/sqlmigration) migration to backfill existing ones.
|
||||
@@ -11,7 +11,6 @@ We adhere to three primary style guides as our foundation:
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
|
||||
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Authz](authz.md) - Authorization, roles, and access control
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
|
||||
@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -22,16 +22,12 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRole200,
|
||||
GetServiceAccountRolePathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
@@ -39,7 +35,6 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -51,272 +46,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_account_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing service account role
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const getServiceAccountRole = (
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccountRole200>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolePathParameters) => {
|
||||
return [`/api/v1/service_account_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
> = ({ signal }) => getServiceAccountRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
>;
|
||||
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRole<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const invalidateGetServiceAccountRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the service accounts for an organisation
|
||||
* @summary List service accounts
|
||||
@@ -1255,46 +984,45 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1304,66 +1032,62 @@ export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
@@ -1373,23 +1097,23 @@ export const deleteServiceAccountRoleDeprecated = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1399,47 +1123,44 @@ export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
|
||||
@@ -2048,13 +2048,6 @@ export interface AuthtypesAuthNSupportDTO {
|
||||
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2294,6 +2287,13 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2310,18 +2310,7 @@ export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
userRoles?: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
@@ -5704,81 +5693,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodCountsByStatusDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
completed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCannotRun: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
containerCreating: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
crashLoopBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
createContainerConfigError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
errImagePull: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
error: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
evicted: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
imagePullBackOff: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
nodeAffinity: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
nodeLost: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
oomKilled: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pending: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
shutdown: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unexpectedAdmissionError: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesClusterRecordDTO {
|
||||
/**
|
||||
* @type number
|
||||
@@ -5810,7 +5724,6 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
meta: InframonitoringtypesClusterRecordDTOMeta;
|
||||
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
@@ -5914,7 +5827,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDaemonSetsDTO {
|
||||
@@ -5992,7 +5904,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDeploymentRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDeploymentsDTO {
|
||||
@@ -6159,7 +6070,6 @@ export interface InframonitoringtypesJobRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesJobRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6213,7 +6123,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
*/
|
||||
namespaceName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNamespacesDTO {
|
||||
@@ -6280,7 +6189,6 @@ export interface InframonitoringtypesNodeRecordDTO {
|
||||
*/
|
||||
nodeName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
@@ -6318,27 +6226,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
|
||||
export type InframonitoringtypesPodRecordDTOMeta =
|
||||
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesPodStatusDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
evicted = 'evicted',
|
||||
nodeaffinity = 'nodeaffinity',
|
||||
nodelost = 'nodelost',
|
||||
shutdown = 'shutdown',
|
||||
unexpectedadmissionerror = 'unexpectedadmissionerror',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
@@ -6365,7 +6252,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podCPURequest: number;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -6382,12 +6268,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podMemoryRequest: number;
|
||||
podPhase: InframonitoringtypesPodPhaseDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
podRestarts: number;
|
||||
podStatus: InframonitoringtypesPodStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6705,7 +6585,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -8629,13 +8508,6 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8705,11 +8577,7 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -10475,28 +10343,6 @@ export type UpdateRoutePolicy200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRole200 = {
|
||||
data: ServiceaccounttypesServiceAccountRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10580,10 +10426,10 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -10591,7 +10437,7 @@ export type CreateServiceAccountRoleDeprecated201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
@@ -11751,28 +11597,6 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRole200 = {
|
||||
data: AuthtypesUserRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -19,15 +19,12 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
CreateUserRole201,
|
||||
DeleteUserDeprecatedPathParameters,
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
@@ -40,8 +37,6 @@ import type {
|
||||
GetUserDeprecated200,
|
||||
GetUserDeprecatedPathParameters,
|
||||
GetUserPathParameters,
|
||||
GetUserRole200,
|
||||
GetUserRolePathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
@@ -1159,267 +1154,6 @@ export const invalidateGetUsersByRoleID = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a user
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const createUserRole = (
|
||||
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUserRole201>({
|
||||
url: `/api/v2/user_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUserRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUserRole>>
|
||||
>;
|
||||
export type CreateUserRoleMutationBody =
|
||||
| BodyType<AuthtypesPostableUserRoleDTO>
|
||||
| undefined;
|
||||
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const useCreateUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a user
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const deleteUserRole = (
|
||||
{ id }: DeleteUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
{ pathParams: DeleteUserRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteUserRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>
|
||||
>;
|
||||
|
||||
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const useDeleteUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing user role
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const getUserRole = (
|
||||
{ id }: GetUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetUserRole200>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
|
||||
return [`/api/v2/user_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
|
||||
signal,
|
||||
}) => getUserRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUserRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUserRole>>
|
||||
>;
|
||||
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
|
||||
export function useGetUserRole<
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const invalidateGetUserRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetUserRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all users for the organization
|
||||
* @summary List users v2
|
||||
@@ -2131,7 +1865,6 @@ export const invalidateGetRolesByUserID = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
@@ -2203,7 +1936,6 @@ export type SetRoleByUserIDMutationBody =
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
@@ -2232,7 +1964,6 @@ export const useSetRoleByUserID = <
|
||||
};
|
||||
/**
|
||||
* This endpoint removes a role from the user by user id and role id
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const removeUserRoleByUserIDAndRoleID = (
|
||||
@@ -2291,7 +2022,6 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#1287b1" viewBox="0 0 24 24"><title>Apache Cassandra</title><path d="M10.374 10.53a3 3 0 0 1-.428-.222l.555.143c0 .02-.01.036-.01.055l-.117.025zm-.283 1.506-.315.253.852-1.079-1.078.391c.002.017.009.033.009.05a.57.57 0 0 1-.184.42q.154.327.375.616a3.2 3.2 0 0 1 .34-.651zm.717-2.347-.652-.82a.43.43 0 0 1-.506.162c-.054.073-.083.162-.13.24l1.258.463zm-1.666.444c-.07.314-.087.637-.05.956a.57.57 0 0 1 .451.475l.946-.606c-.067-.022-.126-.06-.191-.088l-1.119-.08.64-.14a3.2 3.2 0 0 1-.668-.554zM20.1 11.648c-.164.202.833 1.022.833 1.022s-1.654-1.022-2.234-.72c-.278.144.574.811 1.175 1.242-.428-.274-.982-.571-1.175-.408-.328.277 1.565 2.549 1.565 2.549s-2.145-2.322-2.36-2.209c-.214.114.593 1.224.593 1.224s-1.06-1.16-1.35-.959c-.29.202 1.514 3.218 1.514 3.218s-1.956-3.091-2.763-2.574c1.268 2.782.795 3.18.795 3.18s-.162-2.839-1.742-2.764c-.795.038.379 2.12.379 2.12s-1.08-1.902-1.8-1.864c1.326 2.51.854 3.53.854 3.53s.219-2.143-1.58-3.336c.682.606-.427 3.336-.427 3.336s.976-4.023-.719-3.256c-.268.121-.019 2.007-.019 2.007s-.34-2.158-.851-2.045c-.298.066-1.893 2.99-1.893 2.99s1.306-3.16.908-3.027c-.29.096-.833 1.4-.833 1.4s.265-1.287 0-1.363c-.264-.075-1.74 1.363-1.74 1.363s1.097-1.287.908-1.552c-.287-.402-.623-.42-1.022-.265-.581.226-1.363 1.287-1.363 1.287s.78-1.074.643-1.476c-.219-.647-2.46 1.249-2.46 1.249s1.325-1.25 1.022-1.514c-.303-.265-1.947-.183-2.46-.185-1.515-.004-2.039-.36-2.498-.724 1.987.997 3.803-.151 6.094.494l.21.06c-1.3-.558-2.144-1.378-2.226-2.354-.036-.416.074-.827.297-1.222.619-.4 1.29-.773 2.06-1.095a4 4 0 0 0-.064.698c0 2.44 2.203 4.417 4.92 4.417s4.92-1.977 4.92-4.417c0-.45-.083-.881-.223-1.29 1.431.404 2.45.968 3.132 1.335.022.092.045.184.053.279.024.274-.018.547-.11.814.095-.147.198-.288.28-.445.367-.997 1.855.227 1.855.227s-1.085-.454-1.06-.24c.026.215 1.628.96 1.628.96s-1.45-.455-1.362-.114c.088.34 1.817 1.703 1.817 1.703s-1.956-1.489-2.12-1.287zm-7.268 2.65.042-.008-.06.01zM9.256 9.753c.12.13.26.234.396.343l.927-.029-1.064-.788c-.093.154-.195.303-.26.474Zm10.62 3.44c.3.215.54.373.54.373s-.24-.181-.54-.374zM7.507 8.617c-.14.229-.214.492-.215.76a3.99 3.99 0 0 0 2.358 3.64q0-.008.003-.014a3.2 3.2 0 0 1-.58-.788c-.648.099-.926-.794-.336-1.08a3.2 3.2 0 0 1 .138-1.388 3.16 3.16 0 0 1-.52-1.36q-.444.105-.848.23m1.488.82q.163-.36.402-.661a.435.435 0 0 1 .568-.557c.077-.059.166-.099.248-.15a16 16 0 0 0-1.727.284c.114.388.272.76.509 1.084m2.285 3.928c1.4 0 2.633-.723 3.344-1.816a3.4 3.4 0 0 0-1.265-.539l-.297-.023.916.9-1.197-.467.704 1.078-1.074-.832-.012.006.347 1.278-.596-1.134-.098 1.33-.401-1.326-.472 1.261.114-1.359-.015-.008-.814 1.154.286-1.067c-.34.322-.605.713-.781 1.146q.143.153.303.29.484.126 1.008.128m10.145-4.434c.971-.567 1.716-1.955 1.716-1.955s-1.893 1.955-3.205 1.665c1.186-.934 1.766-2.549 1.766-2.549s-1.506 2.325-2.448 2.423c1.086-.959 1.54-2.322 1.54-2.322s-1.237 1.817-2.196 1.944c1.287-1.161 1.338-1.893 1.338-1.893s-1.781 2.302-2.499 1.943c.858-.934 1.439-2.12 1.439-2.12s-1.489 2.019-1.893 1.69c-.277-.05.454-.958.454-.958s-.908.807-1.16.606c.454-.278 1.236-1.64 1.236-1.64S16 7.505 15.621 7.304l.731-1.483s-.73 1.483-1.715 1.23c.454-.58.63-1.112.63-1.112s-.756 1.213-1.69.885c-.22-.077.273-.635.273-.635s-.626.61-1.055.534c-.43-.076.025-.858.025-.858s-.757 1.186-.908 1.136.075-.833.075-.833-.555.908-.858.858 0-.934 0-.934-.328.984-.58.909c-.252-.076-.303-.656-.303-.656s-.068.788-.429.858c-2.725.53-5.728 1.69-9.489 5.45C3.887 10.738 5.3 7.91 11.962 7.659c5.044-.191 7.399 2.137 8.177 2.17C22.51 9.93 24 7.633 24 7.633s-1.489 1.716-2.574 1.3zm-7.74.872-.608.464v.001l.054.003a3.4 3.4 0 0 0 .554-.468m1.583-.426c0-.536-.237-.929-.594-1.217a3.2 3.2 0 0 1-.165.825.393.393 0 0 1-.328.681c-.154.233-.34.445-.549.63l.661.034-.995.237c-.025.018-.045.041-.07.058a3.2 3.2 0 0 1 1.536.691c.32-.574.504-1.235.504-1.94zM10.99 7.996a3.5 3.5 0 0 0-.785.46.43.43 0 0 1-.013.357l.885.643.023-.016-.36-1.262.627 1.12c.018-.006.04-.006.058-.011l-.02-1.251.398 1.163.477-1.15.016 1.268.012.007.713-1.005-.363 1.218.009.01 1.04-.69-.759 1.05.002.005.95-.34.041-.045a.395.395 0 0 1 .394-.632 3.4 3.4 0 0 0 .27-.784 14 14 0 0 0-2.798-.168c-.286.011-.55.033-.817.053"/></svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 -2.25 262.5 364"><defs><style>.cls-1{fill:#326ce5}.cls-3{fill:none}</style></defs><path d="M59.724 97.778a10.183 10.183 0 0 1 0-17.073l114.07-74.16a10.18 10.18 0 0 1 11.1 0l114.07 74.16a10.183 10.183 0 0 1 0 17.073l-114.07 74.16a10.18 10.18 0 0 1-11.1 0z" class="cls-1"/><path fill="#c1d2f7" d="M197.356 110.866h7.912a6.003 6.003 0 0 0 5.2-9.005l-25.924-44.902a6.004 6.004 0 0 0-10.399 0l-25.924 44.902a6.003 6.003 0 0 0 5.2 9.005h7.912a6.003 6.003 0 0 1 6.004 6.003v51.257l5.31 3.452a12.29 12.29 0 0 0 13.395 0l5.31-3.452v-51.257a6.003 6.003 0 0 1 6.004-6.003"/><path d="M173.793 353.271a10.1 10.1 0 0 0 3.457 1.402 119 119 0 0 0-6.623-3.46zm-6.456-161.195-11.316 7.356a111 111 0 0 0 11.316 6.6zm24.015 23.716c9.739 3.115 19.813 5.648 30.11 8.186 10.92 2.692 21.973 5.427 32.818 9.009l-35.416-23.025a216 216 0 0 1-27.512-9.41zm0 62.191v.586c0 2.57-2.688 4.652-6.003 4.652H173.34c-3.315 0-6.003-2.083-6.003-4.652v-6.605c-24.906-6.402-49.873-14.485-70.86-33.82l-14.529 9.445c22.468 22.05 50.28 28.933 79.715 36.189 27.178 6.698 55.18 13.651 78.685 33.442l14.68-9.544c-18.532-16.504-40.41-23.687-63.676-29.693M93.315 300.95l38.908 25.295c20.064 5.113 40.2 11.248 58.31 23.36l14.901-9.687c-19.752-13.672-42.55-19.313-66.419-25.197-15.287-3.767-30.835-7.616-45.7-13.771m74.022-82.254a124.3 124.3 0 0 1-21.534-12.62l-14.805 9.625a124.5 124.5 0 0 0 36.339 18.708zm51.41 16.294c-9.106-2.245-18.304-4.524-27.395-7.299v13.682q5.051 1.286 10.177 2.538c29.934 7.379 60.888 15.01 85.708 39.829.61.61 1.162 1.24 1.752 1.857l9.974-6.484a10.1 10.1 0 0 0 3.475-3.8 101 101 0 0 0-3.289-3.486c-22.585-22.585-50.669-29.508-80.402-36.837m-19.933 19.933q-3.722-.918-7.462-1.855v13.21c25.817 6.561 51.803 14.688 73.455 35.04l14.521-9.44-.11-.118c-22.586-22.585-50.67-29.509-80.404-36.837m-31.477-8.587c-16.28-5.278-32.118-12.397-46.344-24.131l-14.682 9.545c17.87 15.557 38.784 22.673 61.026 28.5z" class="cls-3"/><path d="M73.24 254.96c-.348-.348-.658-.71-1-1.06l-12.514 8.136a10 10 0 0 0-1.644 1.397c1.053 1.157 2.115 2.31 3.245 3.44 22.585 22.585 50.67 29.509 80.403 36.837 25.343 6.247 51.401 12.717 73.865 29.603l14.806-9.626c-20.865-16.42-45.53-22.509-71.453-28.898-29.934-7.379-60.888-15.01-85.708-39.83" class="cls-3"/><path d="m218.864 209.962-27.512-17.886v8.476a216 216 0 0 0 27.512 9.41m-27.512 5.83v11.9c9.091 2.774 18.289 5.053 27.395 7.298 29.733 7.329 57.817 14.252 80.402 36.837a101 101 0 0 1 3.29 3.486 10.193 10.193 0 0 0-3.476-13.277l-44.683-29.05c-10.845-3.58-21.898-6.316-32.818-9.008-10.297-2.538-20.371-5.071-30.11-8.186m-24.015-9.76a111 111 0 0 1-11.316-6.6l-10.218 6.644a124.3 124.3 0 0 0 21.534 12.62zm34.192 37.88q-5.125-1.264-10.177-2.539v11.695q3.737.938 7.462 1.855c29.733 7.328 57.818 14.252 80.403 36.837.039.039.073.079.111.118l9.66-6.28c-.589-.618-1.142-1.248-1.751-1.858-24.82-24.82-55.774-32.45-85.708-39.829m-34.192-9.503a124.5 124.5 0 0 1-36.34-18.708l-10.004 6.504c14.226 11.734 30.064 18.853 46.344 24.13zm0 25.842c-22.242-5.828-43.156-12.944-61.026-28.5l-9.834 6.392c20.987 19.336 45.954 27.42 70.86 33.821zm24.015 17.732c23.266 6.006 45.144 13.19 63.675 29.693l9.78-6.358c-21.652-20.352-47.638-28.479-73.455-35.04zM81.948 247.59l-9.707 6.311c.341.35.652.712 1 1.06 24.82 24.82 55.773 32.45 85.707 39.829 25.922 6.39 50.588 12.478 71.452 28.898l9.948-6.467c-23.505-19.791-51.507-26.744-78.685-33.442-29.436-7.256-57.247-14.138-79.715-36.189m-20.621 19.284c-1.13-1.13-2.192-2.283-3.245-3.44a10.154 10.154 0 0 0 1.644 15.68l33.589 21.837c14.865 6.155 30.413 10.004 45.7 13.771 23.869 5.884 46.667 11.525 66.419 25.197l10.16-6.605c-22.463-16.886-48.521-23.356-73.864-29.603-29.733-7.328-57.818-14.252-80.403-36.837m109.3 84.34a119 119 0 0 1 6.623 3.46 10.16 10.16 0 0 0 7.645-1.402l5.639-3.666c-18.11-12.112-38.247-18.247-58.311-23.36z" class="cls-1"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><title>PlanetScale</title><path d="M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a12 12 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.96 11.96 0 0 1 0 12m12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24"/></svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -1,11 +1,10 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
route: string;
|
||||
Component: ComponentType;
|
||||
Component: () => JSX.Element;
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -177,13 +177,7 @@ export default function ConversationView({
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
onSendSuggestedPrompt={(text): void => {
|
||||
handleSend(
|
||||
text,
|
||||
undefined,
|
||||
autoContexts.length > 0 ? autoContexts : undefined,
|
||||
);
|
||||
}}
|
||||
onSendSuggestedPrompt={(text): void => handleSend(text)}
|
||||
/>
|
||||
{showDisclaimer && (
|
||||
<div className={disclaimerClass} role="note" aria-live="polite">
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
|
||||
const CHIP_ID = 'recent-errors';
|
||||
const CHIP_TEXT = 'Show me recent errors';
|
||||
|
||||
// Auto-derived page context that a normal (typed) send would attach. The chip
|
||||
// send must forward the exact same array.
|
||||
const mockAutoContexts: MessageContext[] = [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: 'dashboard-123',
|
||||
resourceName: 'Checkout dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/getAutoContexts', () => ({
|
||||
getAutoContexts: jest.fn(() => mockAutoContexts),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
normalizePage: (page: string): string => page,
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
|
||||
}));
|
||||
|
||||
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
|
||||
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
|
||||
// ConversationView imports for its dismissed-context filter.
|
||||
jest.mock('container/AIAssistant/components/ChatInput', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="chat-input" />,
|
||||
autoContextKey: (): string => '',
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
// VirtualizedMessages renders the real empty-state chips; stub only its
|
||||
// never-rendered-in-empty-state children and the virtual list.
|
||||
jest.mock('components/Noz/Noz', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="noz" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
__esModule: true,
|
||||
Virtuoso: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
|
||||
() => ({
|
||||
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
|
||||
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ConversationView from '../ConversationView';
|
||||
|
||||
const CONVERSATION_ID = 'conv-1';
|
||||
|
||||
function renderView(variant: 'panel' | 'page' | 'modal'): {
|
||||
getByTestId: (id: string) => HTMLElement;
|
||||
} {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
|
||||
<VariantContext.Provider value={variant}>
|
||||
<ConversationView conversationId={CONVERSATION_ID} />
|
||||
</VariantContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ConversationView — empty-state chip context', () => {
|
||||
let sendMessage: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sendMessage = jest.fn();
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
[CONVERSATION_ID]: {
|
||||
id: CONVERSATION_ID,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: CONVERSATION_ID,
|
||||
isLoadingThread: false,
|
||||
sendMessage,
|
||||
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
|
||||
});
|
||||
|
||||
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
|
||||
const { getByTestId } = renderView('panel');
|
||||
|
||||
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
|
||||
|
||||
// The chip send must carry the same auto-contexts a typed message would.
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
CHIP_TEXT,
|
||||
undefined,
|
||||
mockAutoContexts,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
|
||||
const { getByTestId } = renderView('page');
|
||||
|
||||
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
|
||||
});
|
||||
});
|
||||
@@ -1625,9 +1625,6 @@ export const getHostQueryPayload = (
|
||||
const diskPendingKey = dotMetricsEnabled
|
||||
? 'system.disk.pending_operations'
|
||||
: 'system_disk_pending_operations';
|
||||
const fsUsageKey = dotMetricsEnabled
|
||||
? 'system.filesystem.usage'
|
||||
: 'system_filesystem_usage';
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -2660,155 +2657,6 @@ export const getHostQueryPayload = (
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_filesystem_usage--float64--Gauge--true',
|
||||
|
||||
key: fsUsageKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: true,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'fs_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
{
|
||||
id: 'fs_f2',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'state--string--tag--false',
|
||||
|
||||
key: 'state',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'used',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'mountpoint--string--tag--false',
|
||||
|
||||
key: 'mountpoint',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${fsUsageKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{mountpoint}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_filesystem_usage--float64--Gauge--true',
|
||||
|
||||
key: fsUsageKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: true,
|
||||
expression: 'B',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'fs_f3',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'mountpoint--string--tag--false',
|
||||
|
||||
key: 'mountpoint',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${fsUsageKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{mountpoint}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [
|
||||
{
|
||||
disabled: false,
|
||||
expression: 'A/B',
|
||||
legend: '{{mountpoint}}',
|
||||
queryName: 'F1',
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2884,5 +2732,4 @@ export const hostWidgetInfo = [
|
||||
{ title: 'System disk operations/s', yAxisUnit: 'short' },
|
||||
{ title: 'Queue size', yAxisUnit: 'short' },
|
||||
{ title: 'System disk operation time/s', yAxisUnit: 's' },
|
||||
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
|
||||
];
|
||||
|
||||
@@ -20,7 +20,6 @@ import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
@@ -52,7 +51,6 @@ import externalApiMonitoringUrl from '@/assets/Logos/external-api-monitoring.svg
|
||||
import fluentbitUrl from '@/assets/Logos/fluentbit.svg';
|
||||
import fluentdUrl from '@/assets/Logos/fluentd.svg';
|
||||
import flutterMonitoringUrl from '@/assets/Logos/flutter-monitoring.svg';
|
||||
import fluxcdUrl from '@/assets/Logos/fluxcd.svg';
|
||||
import flyIoUrl from '@/assets/Logos/fly-io.svg';
|
||||
import fromLogFileUrl from '@/assets/Logos/from-log-file.svg';
|
||||
import gcpAppEngineUrl from '@/assets/Logos/gcp-app-engine.svg';
|
||||
@@ -96,7 +94,6 @@ import langtraceUrl from '@/assets/Logos/langtrace.svg';
|
||||
import litellmUrl from '@/assets/Logos/litellm.svg';
|
||||
import livekitUrl from '@/assets/Logos/livekit.svg';
|
||||
import llamaindexUrl from '@/assets/Logos/llamaindex.svg';
|
||||
import llmMonitoringUrl from '@/assets/Logos/llm-monitoring.svg';
|
||||
import logrusUrl from '@/assets/Logos/logrus.svg';
|
||||
import logsUrl from '@/assets/Logos/logs.svg';
|
||||
import logstashUrl from '@/assets/Logos/logstash.svg';
|
||||
@@ -123,7 +120,6 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
|
||||
import phpUrl from '@/assets/Logos/php.svg';
|
||||
import pinoUrl from '@/assets/Logos/pino.svg';
|
||||
import pipecatUrl from '@/assets/Logos/pipecat.svg';
|
||||
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
|
||||
import postgresqlUrl from '@/assets/Logos/postgresql.svg';
|
||||
import prometheusUrl from '@/assets/Logos/prometheus.svg';
|
||||
import pydanticAiUrl from '@/assets/Logos/pydantic-ai.svg';
|
||||
@@ -1530,28 +1526,6 @@ const onboardingConfigWithLinks = [
|
||||
id: 'nginx-tracing',
|
||||
link: '/docs/instrumentation/opentelemetry-nginx/',
|
||||
},
|
||||
{
|
||||
dataSource: 'nginx-ingress-controller',
|
||||
label: 'NGINX Ingress Controller',
|
||||
imgUrl: nginxUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'ingress',
|
||||
'ingress controller',
|
||||
'kubernetes ingress',
|
||||
'monitoring',
|
||||
'nginx ingress',
|
||||
'nginx ingress controller',
|
||||
'nginx ingress metrics',
|
||||
'nginx ingress monitoring',
|
||||
'nginx ingress observability',
|
||||
'observability',
|
||||
'opentelemetry nginx ingress',
|
||||
],
|
||||
id: 'nginx-ingress-controller',
|
||||
link: '/docs/metrics-management/nginx-ingress-controller/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cloudflare',
|
||||
label: 'Cloudflare Tracing',
|
||||
@@ -1612,119 +1586,6 @@ const onboardingConfigWithLinks = [
|
||||
id: 'opentelemetry-cloudflare-logs',
|
||||
link: '/docs/logs-management/send-logs/cloudflare-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudflare-workers',
|
||||
label: 'Cloudflare Workers',
|
||||
imgUrl: cloudflareUrl,
|
||||
tags: ['apm/traces'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'cloudflare',
|
||||
'cloudflare workers',
|
||||
'cloudflare workers monitoring',
|
||||
'cloudflare workers observability',
|
||||
'cloudflare workers otlp',
|
||||
'edge computing monitoring',
|
||||
'monitor cloudflare workers',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry cloudflare workers',
|
||||
'otlp',
|
||||
'serverless monitoring',
|
||||
],
|
||||
id: 'cloudflare-workers',
|
||||
link: '/docs/integrations/outposts/cloudflare-workers/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-cassandra',
|
||||
label: 'Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'cassandra',
|
||||
'cassandra database',
|
||||
'cassandra logs',
|
||||
'cassandra metrics',
|
||||
'cassandra monitoring',
|
||||
'cassandra observability',
|
||||
'database',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'opentelemetry cassandra',
|
||||
],
|
||||
id: 'opentelemetry-cassandra',
|
||||
link: '/docs/integrations/opentelemetry-cassandra/',
|
||||
},
|
||||
{
|
||||
dataSource: 'fluxcd',
|
||||
label: 'FluxCD',
|
||||
imgUrl: fluxcdUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'continuous delivery',
|
||||
'flux',
|
||||
'fluxcd',
|
||||
'fluxcd dashboard',
|
||||
'fluxcd metrics',
|
||||
'fluxcd monitoring',
|
||||
'fluxcd observability',
|
||||
'gitops',
|
||||
'kubernetes',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry fluxcd',
|
||||
],
|
||||
id: 'fluxcd',
|
||||
link: '/docs/metrics-management/fluxcd-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'planetscale',
|
||||
label: 'PlanetScale',
|
||||
imgUrl: planetscaleUrl,
|
||||
tags: ['database'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'monitoring',
|
||||
'mysql',
|
||||
'observability',
|
||||
'opentelemetry planetscale',
|
||||
'planetscale',
|
||||
'planetscale database',
|
||||
'planetscale metrics',
|
||||
'planetscale monitoring',
|
||||
'planetscale observability',
|
||||
'serverless database',
|
||||
],
|
||||
id: 'planetscale',
|
||||
link: '/docs/metrics-management/opentelemetry-planetscale/',
|
||||
},
|
||||
{
|
||||
dataSource: 'hermes-agent',
|
||||
label: 'Hermes Agent',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent monitoring',
|
||||
'hermes',
|
||||
'hermes agent',
|
||||
'hermes agent monitoring',
|
||||
'hermes agent observability',
|
||||
'hermes monitoring',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'nous research',
|
||||
'observability',
|
||||
'opentelemetry hermes',
|
||||
],
|
||||
id: 'hermes-agent',
|
||||
link: '/docs/hermes-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'convex-logs',
|
||||
label: 'Convex Logs',
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRoleDeprecated,
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
|
||||
);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# AuthZ
|
||||
|
||||
Permission-based authorization system for SigNoz frontend.
|
||||
|
||||
## Supported Resources
|
||||
|
||||
See [hooks/useAuthZ/permissions.type.ts](./hooks/useAuthZ/permissions.type.ts) for available resources and verbs.
|
||||
|
||||
If your page/content represents a resource not listed there, skip authz implementation — the backend doesn't enforce it yet.
|
||||
|
||||
## UI Gating
|
||||
|
||||
Need to gate UI based on permissions? See [components/README.md](./components/README.md).
|
||||
|
||||
Covers: AuthZButton, AuthZTooltip, withAuthZ*, AuthZGuard*, when to use each.
|
||||
|
||||
## Testing
|
||||
|
||||
Need to test authz behavior? See [utils/README.md](./utils/README.md).
|
||||
|
||||
Covers: MSW handlers, mock hooks, test patterns.
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import AuthZButton from './AuthZButton';
|
||||
|
||||
// AuthZButton is a thin composition over AuthZTooltip + Button. The denial
|
||||
// tooltip / disabled-on-deny UX is owned and tested by AuthZTooltip; here we
|
||||
// assert AuthZButton forwards the right props and renders a Button child.
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip');
|
||||
const mockTooltip = AuthZTooltip as unknown as jest.Mock;
|
||||
|
||||
const createPerm = buildPermission(
|
||||
'create',
|
||||
'serviceaccount:*' as AuthZObject<'create'>,
|
||||
);
|
||||
|
||||
describe('AuthZButton', () => {
|
||||
beforeEach(() => {
|
||||
mockTooltip.mockImplementation(
|
||||
({ children }: { children: ReactElement }) => children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockTooltip.mockReset();
|
||||
});
|
||||
|
||||
it('renders a Button child with forwarded props', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('create-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-btn').tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('forwards checks and enables the check by default', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip).toHaveBeenCalledTimes(1);
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
checks: [createPerm],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom tooltipMessage', () => {
|
||||
render(
|
||||
<AuthZButton
|
||||
checks={[createPerm]}
|
||||
tooltipMessage="Ask an admin"
|
||||
testId="create-btn"
|
||||
>
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
tooltipMessage: 'Ask an admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes authZEnabled through as the tooltip enabled flag', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({ enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button, ButtonProps } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type AuthZButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Permissions required to enable the button (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/**
|
||||
* Override the default denial tooltip message.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZButton;
|
||||
@@ -1,202 +0,0 @@
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { AuthZGuard } from './AuthZGuard';
|
||||
import { AuthZGuardContent } from './AuthZGuardContent';
|
||||
import { AuthZGuardPage } from './AuthZGuardPage';
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
const Protected = (): JSX.Element => <div>Protected content</div>;
|
||||
|
||||
describe('AuthZGuard', () => {
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes denied permissions to a function fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<div>denied: {deniedPermissions.length}</div>
|
||||
)}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied: 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing for a denied check with no fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
const { container } = render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the loading fallback while checking', () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallbackOnLoading={<div>Loading…</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fails open on error by default (renders children)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback on error when failOpenOnError is false', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
onFailRenderContent={false}
|
||||
fallback={<div>No access</div>}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the app loader while checking', () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ReactElement, ReactNode, useMemo } from 'react';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type AuthZGuardFallback =
|
||||
| ReactNode
|
||||
| ((info: { deniedPermissions: BrandedPermission[] }) => ReactNode);
|
||||
|
||||
export type AuthZGuardProps = {
|
||||
/**
|
||||
* Permissions required to render `children` (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
/**
|
||||
* Rendered when denied. A function receives the denied permissions.
|
||||
*/
|
||||
fallback?: AuthZGuardFallback;
|
||||
fallbackOnLoading?: ReactNode;
|
||||
/**
|
||||
* By default, we don't expect the check API request to fail, in those cases, we prefer to show the content and then let the API fail (during list/create).
|
||||
*
|
||||
* In case you want to have a different behavior when request fail, set to false.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
onFailRenderContent?: boolean;
|
||||
};
|
||||
|
||||
function resolveFallback(
|
||||
fallback: AuthZGuardFallback | undefined,
|
||||
deniedPermissions: BrandedPermission[],
|
||||
): ReactNode {
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback({ deniedPermissions });
|
||||
}
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
export function AuthZGuard({
|
||||
checks,
|
||||
children,
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
onFailRenderContent = true,
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
const { isLoading, error, permissions } = useAuthZ(checks);
|
||||
|
||||
// TODO(authz): Use allowed/deniedPermissions from useAuthZ after devtools PR merges
|
||||
const { allowed, deniedPermissions } = useMemo(() => {
|
||||
if (!permissions) {
|
||||
return { allowed: false, deniedPermissions: [] as BrandedPermission[] };
|
||||
}
|
||||
|
||||
const denied = Object.entries(permissions)
|
||||
.filter(([, { isGranted }]) => !isGranted)
|
||||
.map(([perm]) => perm as BrandedPermission);
|
||||
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <>{fallbackOnLoading ?? null}</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onFailRenderContent ? (
|
||||
children
|
||||
) : (
|
||||
<>{resolveFallback(fallback, deniedPermissions)}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <>{resolveFallback(fallback, deniedPermissions)}</>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardContent({
|
||||
fallback,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardPage({
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallbackOnLoading={fallbackOnLoading ?? <AppLoading />}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,6 @@ const noPermissions = {
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -162,11 +160,11 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Action' });
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
@@ -11,13 +11,6 @@ import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
cursor: 'not-allowed',
|
||||
};
|
||||
|
||||
const noOp = (): void => {};
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
@@ -56,13 +49,11 @@ function AuthZTooltip({
|
||||
}, [checks, permissions]);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
});
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
@@ -73,14 +64,12 @@ function AuthZTooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
<span
|
||||
className={styles.wrapper}
|
||||
data-denied-permissions={deniedPermissions.join(',')}
|
||||
>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const NoPermissionFallback = (_response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => <div>Access denied</div>;
|
||||
const NoPermissionFallbackWithSuggestions = (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
Access denied. Required permission: {response.requiredPermissionName}
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should render children when permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnLoading when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnLoading={<LoadingFallback />}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when loading and no fallbackOnLoading provided', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnError={<div>Custom error fallback</div>}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnNoPermissions when permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallback}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permission is denied and no fallbackOnNoPermissions provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="update" object="role:123">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permissions object is null', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Access denied. Required permission:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
new RegExp(permission.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different relation and object combinations', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GuardAuthZ relation="delete" object="role:456">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
50
frontend/src/lib/authz/components/GuardAuthZ/GuardAuthZ.tsx
Normal file
50
frontend/src/lib/authz/components/GuardAuthZ/GuardAuthZ.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
children: ReactElement;
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: JSX.Element;
|
||||
fallbackOnNoPermissions?: (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}) => JSX.Element;
|
||||
};
|
||||
|
||||
export function GuardAuthZ<R extends AuthZRelation>({
|
||||
children,
|
||||
relation,
|
||||
object,
|
||||
fallbackOnLoading,
|
||||
fallbackOnError,
|
||||
fallbackOnNoPermissions,
|
||||
}: GuardAuthZProps<R>): JSX.Element | null {
|
||||
const permission = buildPermission<R>(relation, object);
|
||||
|
||||
const { permissions, isLoading, error } = useAuthZ([permission]);
|
||||
|
||||
if (isLoading) {
|
||||
return fallbackOnLoading ?? null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallbackOnError ?? children;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
return (
|
||||
fallbackOnNoPermissions?.({
|
||||
requiredPermissionName: permission,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,39 +1,18 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
permissionName="serviceaccount:read"
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -3,32 +3,18 @@ import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedCalloutProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
@@ -39,12 +25,7 @@ function PermissionDeniedCallout({
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permissionCode}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
<code className={styles.permissionCode}>{permissionName}</code>
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,16 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedFullPageProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
@@ -36,13 +22,7 @@ function PermissionDeniedFullPage({
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permission}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# AuthZ Components
|
||||
|
||||
Quick reference for permission-gating UI. All components use AND semantics: user needs ALL permissions in `checks` array.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to gate...
|
||||
├── A button? → AuthZButton
|
||||
├── Any element with tooltip on deny? → AuthZTooltip
|
||||
├── A section inside a page? → withAuthZContent (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardContent
|
||||
├── An entire page/route? → withAuthZPage (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardPage
|
||||
├── Need full control over fallback? → withAuthZ / AuthZGuard
|
||||
└── None of above fit?
|
||||
├── Can create wrapper component? → Create it (like AuthZButton)
|
||||
└── Last resort → useAuthZ hook directly
|
||||
```
|
||||
|
||||
## Building Permissions
|
||||
|
||||
Use `buildPermission`, `buildObjectString` or pre-built constants. Never cast with `as BrandedPermission`.
|
||||
|
||||
```tsx
|
||||
import { buildPermission, buildObjectString } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
buildRoleReadPermission
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
// Static permission (pre-built)
|
||||
const checks = [RoleCreatePermission];
|
||||
|
||||
// Dynamic permission (builder fn)
|
||||
const checks = [buildRoleReadPermission(roleId)];
|
||||
|
||||
// Custom permission (buildPermission + buildObjectString)
|
||||
const checks = [buildPermission('read', buildObjectString('dashboard', dashboardId))];
|
||||
```
|
||||
|
||||
## Creating Permission Helpers
|
||||
|
||||
When adding authz to a new resource, create a permissions file under `lib/authz/hooks/useAuthZ/permissions/`.
|
||||
|
||||
```tsx
|
||||
// lib/authz/hooks/useAuthZ/permissions/dashboard.permissions.ts
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard, no specific id needed
|
||||
export const DashboardCreatePermission = buildPermission('create', 'dashboard:*');
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
|
||||
// Resource-level — require specific id
|
||||
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `dashboard:${id}`);
|
||||
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `dashboard:${id}`);
|
||||
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `dashboard:${id}`);
|
||||
```
|
||||
|
||||
Pattern:
|
||||
- `<Resource><Action>Permission` → collection-level const (wildcard `*`)
|
||||
- `build<Resource><Action>Permission(id)` → resource-level fn (specific id)
|
||||
|
||||
## Components
|
||||
|
||||
### AuthZButton
|
||||
|
||||
Button that disables + shows tooltip when denied.
|
||||
|
||||
```tsx
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZButton checks={[SACreatePermission]} onClick={handleCreate}>
|
||||
Create
|
||||
</AuthZButton>
|
||||
```
|
||||
|
||||
### AuthZTooltip
|
||||
|
||||
Wraps any element. Disables child + shows denial tooltip.
|
||||
|
||||
```tsx
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZTooltip checks={[buildSADeletePermission(accountId)]}>
|
||||
<IconButton icon={<Trash />} onClick={handleDelete} />
|
||||
</AuthZTooltip>
|
||||
```
|
||||
|
||||
### withAuthZPage (preferred for pages)
|
||||
|
||||
HOC for route-level gating. Wrap at export. Shows `PermissionDeniedFullPage` + `AppLoading`.
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RolesPage(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
export default withAuthZPage(RolesPage, {
|
||||
checks: [RoleListPermission],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZContent (preferred for sections)
|
||||
|
||||
HOC for inline sections. Shows `PermissionDeniedCallout` on deny.
|
||||
|
||||
```tsx
|
||||
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RoleEditor(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// Dynamic checks from route params
|
||||
export default withAuthZContent(RoleEditor, {
|
||||
checks: (_props, ctx) => [buildRoleReadPermission(ctx.params.roleId)],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZ
|
||||
|
||||
HOC base. No default fallback. Use when you need custom fallback.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export default withAuthZ(SecretPanel, {
|
||||
checks: [buildPermission('write', 'settings:org')],
|
||||
fallback: <p>No access</p>,
|
||||
});
|
||||
```
|
||||
|
||||
### AuthZGuardPage
|
||||
|
||||
JSX variant of `withAuthZPage`. Use when HOC not possible (conditional rendering).
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardPage checks={[RoleListPermission]}>
|
||||
<RolesPage />
|
||||
</AuthZGuardPage>
|
||||
```
|
||||
|
||||
### AuthZGuardContent
|
||||
|
||||
JSX variant of `withAuthZContent`. Use when HOC not possible.
|
||||
|
||||
```tsx
|
||||
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardContent checks={[RoleCreatePermission]}>
|
||||
<RoleEditor />
|
||||
</AuthZGuardContent>
|
||||
```
|
||||
|
||||
### AuthZGuard
|
||||
|
||||
JSX base guard. No default fallback. Use when you need custom fallback in JSX.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
<AuthZGuard
|
||||
checks={[buildPermission('write', 'settings:org')]}
|
||||
fallback={<p>No access</p>}
|
||||
fallbackOnLoading={<Spinner />}
|
||||
>
|
||||
<SecretContent />
|
||||
</AuthZGuard>
|
||||
```
|
||||
|
||||
## Fallback Components
|
||||
|
||||
Don't use these components directly, always prefer using via `withAuthZ` and their variants.
|
||||
|
||||
- PermissionDeniedCallout: inline error callout. Shows `user/{id} is not authorized to perform {permissions}`.
|
||||
- PermissionDeniedFullPage: full-page centered error. Same message, bigger presentation.
|
||||
@@ -0,0 +1,440 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
describe('createGuardedRoute', () => {
|
||||
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
|
||||
<div>Test Component: {testProp}</div>
|
||||
);
|
||||
|
||||
it('should render component when permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should substitute route parameters in object string', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple route parameters', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const txn = payload[0];
|
||||
const responseData: AuthtypesGettableTransactionDTO[] = [
|
||||
{
|
||||
relation: txn.relation,
|
||||
object: {
|
||||
resource: {
|
||||
kind: txn.object.resource.kind,
|
||||
type: txn.object.resource.type,
|
||||
},
|
||||
selector: '123:456',
|
||||
},
|
||||
authorized: true,
|
||||
},
|
||||
];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: responseData, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}:{version}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123', version: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id/:version',
|
||||
url: '/dashboard/123/456',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep placeholder when route parameter is missing', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render loading fallback when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component when API error occurs (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render no permissions fallback when permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass all props to wrapped component', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const ComponentWithMultipleProps = ({
|
||||
prop1,
|
||||
prop2,
|
||||
prop3,
|
||||
}: {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: boolean;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
{prop1} - {prop2} - {prop3.toString()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
ComponentWithMultipleProps,
|
||||
'read',
|
||||
'role:*',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
prop1: 'value1',
|
||||
prop2: 42,
|
||||
prop3: true,
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize resolved object based on route params', async () => {
|
||||
let requestCount = 0;
|
||||
const requestedObjects: string[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const obj = payload[0]?.object;
|
||||
const kind = obj?.resource?.kind;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr = `${kind}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch1 = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props1 = {
|
||||
testProp: 'test-value-1',
|
||||
match: mockMatch1,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
const { unmount } = render(<GuardedComponent {...props1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(requestedObjects).toContain('role:123');
|
||||
|
||||
unmount();
|
||||
|
||||
const mockMatch2 = {
|
||||
params: { id: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/456',
|
||||
};
|
||||
|
||||
const props2 = {
|
||||
testProp: 'test-value-2',
|
||||
match: mockMatch2,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(requestedObjects).toContain('role:456');
|
||||
});
|
||||
|
||||
it('should handle different relation types', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'delete',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '789' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/789',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
.guard-authz-error-no-authz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
.guard-authz-error-no-authz-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--l3-foreground);
|
||||
line-height: 18px;
|
||||
|
||||
span {
|
||||
background-color: var(--l3-background);
|
||||
white-space: nowrap;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ComponentType, ReactElement, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import noDataUrl from 'assets/Icons/no-data.svg';
|
||||
|
||||
import AppLoading from '../../../../components/AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src={noDataUrl} alt="No permission" />
|
||||
<h3>Uh-oh! You are not authorized</h3>
|
||||
<p>
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
Component: ComponentType<P>,
|
||||
relation: R,
|
||||
object: AuthZObject<R>,
|
||||
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
|
||||
return function GuardedRouteComponent(
|
||||
props: P & RouteComponentProps<Record<string, string>>,
|
||||
): ReactElement {
|
||||
const resolvedObject = useMemo(() => {
|
||||
const paramPattern = /\{([^}]+)\}/g;
|
||||
return object.replace(paramPattern, (match, paramName) => {
|
||||
const paramValue = props.match?.params?.[paramName];
|
||||
return paramValue !== undefined ? paramValue : match;
|
||||
}) as AuthZObject<R>;
|
||||
}, [props.match?.params]);
|
||||
|
||||
return (
|
||||
<GuardAuthZ
|
||||
relation={relation}
|
||||
object={resolvedObject}
|
||||
fallbackOnLoading={<AppLoading />}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
>
|
||||
<Component {...props} />
|
||||
</GuardAuthZ>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from 'tests/test-utils';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
buildObjectString,
|
||||
buildPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { withAuthZ, RouterContext } from './withAuthZ';
|
||||
import { withAuthZContent } from './withAuthZContent';
|
||||
import { withAuthZPage } from './withAuthZPage';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
const mockUseLocation = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): Record<string, string> => mockUseParams(),
|
||||
useLocation: (): { pathname: string; search: string } => mockUseLocation(),
|
||||
}));
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
function Base(): JSX.Element {
|
||||
return <div>Base component</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
});
|
||||
|
||||
describe('withAuthZ', () => {
|
||||
it('renders the wrapped component when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when denied without a fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the provided fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, {
|
||||
checks: [readPerm],
|
||||
fallback: <div>No access</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves checks from props via the selector form', async () => {
|
||||
type Props = { roleId: string };
|
||||
const RoleView = ({ roleId }: Props): JSX.Element => <div>role {roleId}</div>;
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-1'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const Guarded = withAuthZ<Props>(RoleView, {
|
||||
checks: ({ roleId }) => [
|
||||
buildPermission('read', buildObjectString<'read'>('role', roleId)),
|
||||
],
|
||||
fallback: <div>denied selector</div>,
|
||||
});
|
||||
|
||||
render(<Guarded roleId="r-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied selector')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role r-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets a descriptive displayName', () => {
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
expect(Guarded.displayName).toBe('withAuthZ(Base)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZPage(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZContent(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context', () => {
|
||||
it('extracts checks from route params via router.params', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-123' });
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-123'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from query params via router.searchParams', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/roles',
|
||||
search: '?roleId=r-456',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-456'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleListView = (): JSX.Element => <div>role list view</div>;
|
||||
const Guarded = withAuthZ(RoleListView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.searchParams.get('roleId') ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role list view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from pathname via router.matchPath', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/settings/roles/r-789/edit',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', 'r-789'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const EditRoleView = (): JSX.Element => <div>edit role</div>;
|
||||
const Guarded = withAuthZ(EditRoleView, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ roleId: string }>(
|
||||
'/settings/roles/:roleId/edit',
|
||||
);
|
||||
return match
|
||||
? [
|
||||
buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', match.roleId),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('denies when router-derived permission is not allowed', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-denied' });
|
||||
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-denied'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
fallback: <div>access denied</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('access denied')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context stability', () => {
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter(): JSX.Element {
|
||||
renderCount += 1;
|
||||
return <div data-testid="render-count">{renderCount}</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderCount = 0;
|
||||
server.use(setupAuthzGrantByPrefix('read||__||role'));
|
||||
});
|
||||
|
||||
it('does not re-render when useParams returns new object with same values', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME values — should not cause re-render
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
// Allow any pending effects to flush
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same pathname', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ id: string }>('/roles/:id');
|
||||
return match
|
||||
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
|
||||
: [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME pathname — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same search params', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
// Access searchParams to ensure it's part of the dependency chain
|
||||
void router.searchParams.get('tab');
|
||||
return [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME search — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when params values actually change', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// Return DIFFERENT values — re-mount with new mock values
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-2' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when pathname actually changes', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT pathname — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/users', search: '' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when search params actually change', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT search — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=details' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent cache invalidation', () => {
|
||||
const testPerm = buildPermission(
|
||||
'read',
|
||||
'role:test-invalidation' as AuthZObject<'read'>,
|
||||
);
|
||||
// Callout displays permission as "relation:object" format
|
||||
const displayedPerm = 'read:role:test-invalidation';
|
||||
|
||||
function ContentComponent(): JSX.Element {
|
||||
return <div data-testid="protected-content">Protected Content</div>;
|
||||
}
|
||||
|
||||
function InvalidationTrigger({
|
||||
permission,
|
||||
onReady,
|
||||
}: {
|
||||
permission: string;
|
||||
onReady: (invalidate: () => Promise<void>) => void;
|
||||
}): null {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
onReady(async () => {
|
||||
// Reset query to initial state and trigger refetch (matches devtools behavior)
|
||||
await queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
}, [queryClient, permission, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('re-renders from allowed to denied when cache is invalidated', async () => {
|
||||
let shouldGrant = true;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially allowed - should show content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change server response to deny
|
||||
shouldGrant = false;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show denied callout
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Callout should show the denied permission
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText(displayedPerm)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-renders from denied to allowed when cache is invalidated', async () => {
|
||||
let shouldGrant = false;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially denied - should show callout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
|
||||
// Change server response to allow
|
||||
shouldGrant = true;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show protected content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuard } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export type { RouterContext, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZ<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuard, 'withAuthZ', Component, opts);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { ComponentType, ReactElement, createElement, useMemo } from 'react';
|
||||
import {
|
||||
matchPath as reactRouterMatchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
* Route params from useParams (e.g. `/roles/:roleId` → `{ roleId: "r-1" }`)
|
||||
*/
|
||||
params: Record<string, string | undefined>;
|
||||
pathname: string;
|
||||
/**
|
||||
* Query params as URLSearchParams (use `.get('key')` to read)
|
||||
*/
|
||||
searchParams: URLSearchParams;
|
||||
/**
|
||||
* Extract params from pathname using a route pattern.
|
||||
* Returns null if pattern doesn't match.
|
||||
* @example router.matchPath<{ id: string }>('/edit/:id')?.id
|
||||
*/
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
) => Params | null;
|
||||
};
|
||||
|
||||
export type WithAuthZOptions<P> = {
|
||||
/**
|
||||
* Static checks, or a selector deriving them from props and router context.
|
||||
* Use router context to extract dynamic values from route params, pathname, or query params.
|
||||
* @example
|
||||
* // From route params
|
||||
* checks: (props, router) => [buildPermission('read', `role:${router.params.roleId}`)]
|
||||
* // From query params
|
||||
* checks: (props, router) => [buildPermission('read', `dashboard:${router.searchParams.get('id')}`)]
|
||||
* // From pathname matching
|
||||
* checks: (props, router) => {
|
||||
* const match = router.matchPath<{ id: string }>('/edit/:id');
|
||||
* return match ? [buildPermission('update', `role:${match.id}`)] : [];
|
||||
* }
|
||||
*/
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
};
|
||||
|
||||
function useStableParams(): Record<string, string | undefined> {
|
||||
const params = useParams();
|
||||
const paramsJson = JSON.stringify(params);
|
||||
return useMemo(() => JSON.parse(paramsJson), [paramsJson]);
|
||||
}
|
||||
|
||||
function useRouterContext(): RouterContext {
|
||||
const params = useStableParams();
|
||||
const { pathname, search } = useLocation();
|
||||
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
return useMemo(
|
||||
(): RouterContext => ({
|
||||
params,
|
||||
pathname,
|
||||
searchParams,
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
): Params | null => {
|
||||
const match = reactRouterMatchPath<Params>(pathname, {
|
||||
path: pattern,
|
||||
exact: false,
|
||||
});
|
||||
return match?.params ?? null;
|
||||
},
|
||||
}),
|
||||
[params, pathname, searchParams],
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuthZHOC<P extends object>(
|
||||
Guard: ComponentType<AuthZGuardProps>,
|
||||
hocName: string,
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
{createElement(Component, props)}
|
||||
</Guard>
|
||||
);
|
||||
}
|
||||
|
||||
Wrapped.displayName = `${hocName}(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZContent<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardContent, 'withAuthZContent', Component, opts);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardPage } from 'lib/authz/components/AuthZGuard/AuthZGuardPage';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZPage<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardPage, 'withAuthZPage', Component, opts);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
# AuthZ Test Utilities
|
||||
|
||||
Helpers for testing permission-gated components.
|
||||
|
||||
## File Naming
|
||||
|
||||
AuthZ tests live in `*.authz.test.tsx` files alongside other test files:
|
||||
|
||||
```
|
||||
ComponentName/
|
||||
├── ComponentName.tsx
|
||||
├── __tests__/
|
||||
│ ├── ComponentName.test.tsx # functional tests
|
||||
│ └── ComponentName.authz.test.tsx # permission tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```tsx
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin, setupAuthzDenyAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
describe('ComponentName - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers(); // reset MSW handlers after each test
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows permission denied when read denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('renders content when permitted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Use `server.use()` at start of each test (not `beforeEach`) for explicit setup
|
||||
- Call `server.resetHandlers()` in `afterEach` to avoid test pollution
|
||||
- Use `waitFor` or `findBy*` queries since authz checks are async
|
||||
- Group tests by permission scenario: denied, granted, partial, loading
|
||||
|
||||
## MSW Handlers
|
||||
|
||||
Mock `/api/v1/authz/check` endpoint responses.
|
||||
|
||||
```tsx
|
||||
import { server } from 'mocks-server/server';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzDeny,
|
||||
setupAuthzAllow,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
// Grant all permissions
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
// Deny all permissions
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
// Grant all except specific permissions
|
||||
server.use(setupAuthzDeny(RoleCreatePermission, RoleDeletePermission));
|
||||
|
||||
// Deny all except specific permissions
|
||||
server.use(setupAuthzAllow(RoleListPermission));
|
||||
|
||||
// Grant by relation prefix (e.g., grant read/delete, deny update)
|
||||
server.use(setupAuthzGrantByPrefix('read', 'delete'));
|
||||
```
|
||||
|
||||
## Custom Mock Response
|
||||
|
||||
For fine-grained control over responses.
|
||||
|
||||
```tsx
|
||||
import { rest } from 'msw';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// [true, false] = first permission granted, second denied
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true, false])));
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## Testing Loading State
|
||||
|
||||
Use `ctx.delay('infinite')` to hold response indefinitely:
|
||||
|
||||
```tsx
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
@@ -104,25 +104,6 @@ export function setupAuthzAllow(
|
||||
});
|
||||
}
|
||||
|
||||
/** Grants permissions that start with any of the given prefixes. */
|
||||
export function setupAuthzGrantByPrefix(...prefixes: string[]): RestHandler {
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn) => {
|
||||
const perm = gettableTransactionToPermission(txn);
|
||||
return prefixes.some((prefix) => perm.startsWith(prefix));
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLicense(
|
||||
overrides?: Partial<LicenseResModel>,
|
||||
): LicenseResModel {
|
||||
|
||||
@@ -26,8 +26,8 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
|
||||
// Skip the mount-time Opened fire when the user expanded an already-open
|
||||
// drawer/modal — that surface already emitted Opened with the right source.
|
||||
// Router state (vs a module flag) survives page remounts and aborted
|
||||
// navigations.
|
||||
// Router state (vs a module flag) survives StrictMode double-mount and
|
||||
// aborted navigations.
|
||||
const fromInApp = location.state?.fromInApp === true;
|
||||
useEffect(() => {
|
||||
if (fromInApp) {
|
||||
@@ -52,34 +52,18 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
// Keep refs so the effect can read the latest store state without re-firing
|
||||
// when it mutates the store mid-effect (it only depends on the URL param).
|
||||
// Keep a ref so the effect can read latest conversations without re-firing
|
||||
// when startNewConversation mutates the store mid-effect.
|
||||
const conversationsRef = useRef(conversations);
|
||||
conversationsRef.current = conversations;
|
||||
const activeConversationIdRef = useRef(activeConversationId);
|
||||
activeConversationIdRef.current = activeConversationId;
|
||||
|
||||
useEffect(() => {
|
||||
// URL points at a known conversation → just activate it.
|
||||
if (conversationId && conversationsRef.current[conversationId]) {
|
||||
if (conversationsRef.current[conversationId]) {
|
||||
setActiveConversation(conversationId);
|
||||
return;
|
||||
} else {
|
||||
const newId = startNewConversation();
|
||||
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
|
||||
}
|
||||
|
||||
// The URL has no usable conversation id (bare `/ai-assistant`, or a stale
|
||||
// param). Prefer resuming the active conversation — including the
|
||||
// rehydrating placeholder for the persisted thread — over minting a new
|
||||
// one. This is what stops a throwaway blank chat from flashing as a
|
||||
// second thread during load, and stops a duplicate when the page
|
||||
// remounts during startup route churn (the active id is already set, so
|
||||
// we resume instead of create). Starting fresh is the last resort, only
|
||||
// when there is genuinely nothing to resume.
|
||||
const activeId = activeConversationIdRef.current;
|
||||
const resumeId =
|
||||
activeId && conversationsRef.current[activeId]
|
||||
? activeId
|
||||
: startNewConversation();
|
||||
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', resumeId));
|
||||
// Only re-run when the URL param changes, not when conversations mutates.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversationId]);
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { render } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/ConversationView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="conversation-view" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/components/ConversationsList', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="conversations-list" />,
|
||||
}));
|
||||
|
||||
jest.mock('components/Noz/Noz', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="noz" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
normalizePage: (page: string): string => page,
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({ mode: 'page' }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import AIAssistantPage from '../AIAssistantPage';
|
||||
|
||||
function renderAt(entry: string): { unmount: () => void } {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Route
|
||||
exact
|
||||
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
|
||||
component={AIAssistantPage}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderAtBase(): { unmount: () => void } {
|
||||
return renderAt(ROUTES.AI_ASSISTANT_BASE);
|
||||
}
|
||||
|
||||
function conversationCount(): number {
|
||||
return Object.keys(useAIAssistantStore.getState().conversations).length;
|
||||
}
|
||||
|
||||
function conversationIds(): string[] {
|
||||
return Object.keys(useAIAssistantStore.getState().conversations);
|
||||
}
|
||||
|
||||
function activeId(): string | null {
|
||||
return useAIAssistantStore.getState().activeConversationId;
|
||||
}
|
||||
|
||||
describe('AIAssistantPage', () => {
|
||||
beforeEach(() => {
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {},
|
||||
streams: {},
|
||||
activeConversationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens exactly one conversation when navigating to /ai-assistant', () => {
|
||||
const { unmount } = renderAtBase();
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not stack a second conversation when the page remounts at the bare URL (route churn)', () => {
|
||||
// First mount at `/ai-assistant` creates one blank conversation and
|
||||
// redirects to `/ai-assistant/:id`.
|
||||
const { unmount } = renderAtBase();
|
||||
expect(conversationCount()).toBe(1);
|
||||
const firstId = conversationIds()[0];
|
||||
|
||||
// Startup route-list churn unmounts and remounts the page while the URL
|
||||
// is momentarily back at the bare `/ai-assistant`. This previously
|
||||
// created a second blank conversation — now it reuses the first.
|
||||
unmount();
|
||||
const { unmount: unmount2 } = renderAtBase();
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
// The surviving conversation is the original one, resumed — not a fresh mint.
|
||||
expect(conversationIds()).toStrictEqual([firstId]);
|
||||
expect(activeId()).toBe(firstId);
|
||||
|
||||
unmount2();
|
||||
});
|
||||
|
||||
it('activates the conversation named in the URL without creating a new one', () => {
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
existing: {
|
||||
id: 'existing',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: null,
|
||||
});
|
||||
|
||||
const { unmount } = renderAt(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', 'existing'),
|
||||
);
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(activeId()).toBe('existing');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resumes the active conversation on /ai-assistant/new instead of minting a new one', () => {
|
||||
// The sidenav only routes to `/ai-assistant/new` as a fallback, but if an
|
||||
// active conversation exists the page must resume it rather than spawn a
|
||||
// throwaway blank thread for the unknown "new" param.
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
active: {
|
||||
id: 'active',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: 'active',
|
||||
});
|
||||
|
||||
const { unmount } = renderAt(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', 'new'),
|
||||
);
|
||||
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(conversationIds()).toStrictEqual(['active']);
|
||||
expect(activeId()).toBe('active');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resumes the persisted (hydrating) conversation during load instead of creating a second', () => {
|
||||
// Simulates `onRehydrateStorage` priming the persisted active
|
||||
// conversation as a hydrating placeholder before `fetchThreads` resolves.
|
||||
useAIAssistantStore.setState({
|
||||
conversations: {
|
||||
persisted: {
|
||||
id: 'persisted',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
isHydrating: true,
|
||||
},
|
||||
},
|
||||
streams: {},
|
||||
activeConversationId: 'persisted',
|
||||
});
|
||||
|
||||
const { unmount } = renderAtBase();
|
||||
|
||||
// Opening the bare URL must resume the persisted conversation, not mint a
|
||||
// throwaway blank alongside it (which flashed as a 2nd thread during load).
|
||||
expect(conversationCount()).toBe(1);
|
||||
expect(
|
||||
Object.keys(useAIAssistantStore.getState().conversations),
|
||||
).toStrictEqual(['persisted']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footerStatus {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -57,6 +64,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danglingWarning {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,18 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -60,6 +70,19 @@ function JsonEditorDrawer({
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
? `${danglingPanelIds.length} panel${plural(
|
||||
danglingPanelIds.length,
|
||||
)} not present in any section — they won't be shown after saving.`
|
||||
: null;
|
||||
const missingRefWarning =
|
||||
missingPanelRefs.length > 0
|
||||
? `${missingPanelRefs.length} layout item${plural(
|
||||
missingPanelRefs.length,
|
||||
)} reference a panel that no longer exists.`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -71,15 +94,33 @@ function JsonEditorDrawer({
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<div className={styles.footerStatus}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
{danglingWarning && (
|
||||
<Typography.Text
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-dangling-warning"
|
||||
>
|
||||
{danglingWarning}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{missingRefWarning && (
|
||||
<Typography.Text
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-missing-ref-warning"
|
||||
>
|
||||
{missingRefWarning}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -48,11 +48,13 @@ function hookValue(
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
@@ -81,6 +83,34 @@ describe('JsonEditorDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about dangling panels, and hides the warning when there are none', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
|
||||
'2 panels not present in any section',
|
||||
);
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('json-editor-dangling-warning'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns about layout refs to missing panels', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
expect(
|
||||
screen.getByTestId('json-editor-missing-ref-warning'),
|
||||
).toHaveTextContent('1 layout item reference a panel that no longer exists');
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findPanelLayoutIssues } from '../danglingPanels';
|
||||
|
||||
const grid = (refs: string[]): unknown => ({
|
||||
kind: 'Grid',
|
||||
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
|
||||
});
|
||||
|
||||
// Cast a loose fixture to the spec type — the helper is defensive against the
|
||||
// untrusted, hand-edited JSON it runs on.
|
||||
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
|
||||
value as DashboardtypesDashboardSpecDTO;
|
||||
|
||||
describe('findPanelLayoutIssues', () => {
|
||||
it('flags nothing when panels and layouts agree', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
|
||||
});
|
||||
|
||||
it('lists panels placed in no layout as dangling', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {}, c: {} },
|
||||
layouts: [grid(['#/spec/panels/a'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
|
||||
expect(result.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('treats a removed/empty layout as orphaning every panel', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({ panels: { a: {}, b: {} }, layouts: [] }),
|
||||
).danglingPanelIds.sort(),
|
||||
).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('lists layout refs to a panel that no longer exists as missing', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds).toStrictEqual([]);
|
||||
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
});
|
||||
|
||||
it('handles empty / malformed specs', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
|
||||
).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -203,4 +203,47 @@ describe('useJsonEditor', () => {
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
|
||||
it('reports panels not placed in any layout as dangling', () => {
|
||||
const withDangling = {
|
||||
...dashboard,
|
||||
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withDangling,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
|
||||
expect(result.current.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('reports layout refs to panels that no longer exist as missing', () => {
|
||||
const withMissing = {
|
||||
...dashboard,
|
||||
spec: {
|
||||
...dashboard.spec,
|
||||
panels: {},
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withMissing,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
expect(result.current.danglingPanelIds).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { extractPanelIdFromRef } from '../../utils';
|
||||
|
||||
export interface PanelLayoutIssues {
|
||||
// Panels defined in `spec.panels` that no layout places — they render nowhere.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout item references that no longer exist in `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
}
|
||||
|
||||
const referencedPanelIds = (
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): Set<string> => {
|
||||
const referenced = new Set<string>();
|
||||
layouts.forEach((layout) => {
|
||||
if (layout?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
(layout.spec?.items ?? []).forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item?.content?.$ref);
|
||||
if (id) {
|
||||
referenced.add(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
return referenced;
|
||||
};
|
||||
|
||||
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
|
||||
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
|
||||
// removed (broken reference). Guarded for untrusted, user-edited JSON.
|
||||
export function findPanelLayoutIssues(
|
||||
spec: DashboardtypesDashboardSpecDTO | undefined,
|
||||
): PanelLayoutIssues {
|
||||
const panels = spec?.panels ?? {};
|
||||
const panelIds = Object.keys(panels);
|
||||
const referenced = referencedPanelIds(spec?.layouts ?? []);
|
||||
|
||||
return {
|
||||
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
|
||||
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -28,6 +32,10 @@ interface Result {
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout references that are missing from the draft's `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
@@ -109,6 +117,23 @@ export function useJsonEditor({
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const { danglingPanelIds, missingPanelRefs } = useMemo<{
|
||||
danglingPanelIds: string[];
|
||||
missingPanelRefs: string[];
|
||||
}>(() => {
|
||||
if (!validity.valid) {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(draft) as {
|
||||
spec?: DashboardtypesDashboardSpecDTO;
|
||||
};
|
||||
return findPanelLayoutIssues(parsed.spec);
|
||||
} catch {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
}, [draft, validity.valid]);
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
@@ -138,7 +163,7 @@ export function useJsonEditor({
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -159,6 +184,8 @@ export function useJsonEditor({
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
|
||||
@@ -65,7 +65,6 @@ type provider struct {
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
serviceAccountGetter serviceaccount.Getter
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
@@ -100,7 +99,6 @@ func NewFactory(
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -138,7 +136,6 @@ func NewFactory(
|
||||
zeusHandler,
|
||||
querierHandler,
|
||||
serviceAccountHandler,
|
||||
serviceAccountGetter,
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
@@ -178,7 +175,6 @@ func newProvider(
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -217,7 +213,6 @@ func newProvider(
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
serviceAccountGetter: serviceAccountGetter,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
|
||||
@@ -141,17 +141,17 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRoleDeprecated",
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
|
||||
Request: new(serviceaccounttypes.PostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
@@ -171,7 +171,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRoleDeprecated",
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from service account",
|
||||
@@ -181,7 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
@@ -398,96 +398,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_account_roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateServiceAccountRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetServiceAccountRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Get service account role",
|
||||
Description: "This endpoint gets an existing service account role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccountRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: provider.serviceAccountIDExtractor(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteServiceAccountRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from a service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(provider.serviceAccountIDExtractor()),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(provider.roleIDExtractor()),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
|
||||
// already extracted by the ResourceDef (path or body); this only does the
|
||||
// UUID -> name lookup the FGA object string requires. Shared by service account
|
||||
// and role routes.
|
||||
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
@@ -504,53 +421,3 @@ func (provider *provider) roleSelector(ctx context.Context, resource coretypes.R
|
||||
resource.Type().MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return serviceAccountRole.RoleID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) serviceAccountIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return serviceAccountRole.ServiceAccountID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -411,7 +411,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: true,
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
@@ -434,56 +434,5 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
|
||||
ID: "CreateUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user role",
|
||||
Description: "This endpoint assigns a role to a user",
|
||||
Request: new(authtypes.PostableUserRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
|
||||
ID: "GetUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user role",
|
||||
Description: "This endpoint gets an existing user role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
|
||||
ID: "DeleteUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user role",
|
||||
Description: "This endpoint revokes a role from a user",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -216,11 +216,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
|
||||
}
|
||||
|
||||
for _, resource := range resolved {
|
||||
if err := resource.Err(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -117,16 +117,8 @@ var podsSpec = checkSpec{
|
||||
DocumentationLink: docLinkKubeletStatsReceiver,
|
||||
},
|
||||
{
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{
|
||||
"k8s.pod.phase",
|
||||
"k8s.container.restarts", // pod restart count (default-on)
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
// kubectl-style pod display status (default-off in the receiver).
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -157,10 +149,6 @@ var nodesSpec = checkSpec{
|
||||
// By default, only k8s.node.condition_ready is enabled. (Check https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/4f9a578b210a6dcb9f9bf47942f27208b5765298/receiver/k8sclusterreceiver/metadata.yaml#L802)
|
||||
"k8s.pod.phase", // pod counts per node by phase
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -194,10 +182,6 @@ var deploymentsSpec = checkSpec{
|
||||
"k8s.deployment.desired",
|
||||
"k8s.deployment.available",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -236,10 +220,6 @@ var daemonsetsSpec = checkSpec{
|
||||
"k8s.daemonset.desired_scheduled_nodes",
|
||||
"k8s.daemonset.current_scheduled_nodes",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -278,10 +258,6 @@ var statefulsetsSpec = checkSpec{
|
||||
"k8s.statefulset.desired_pods",
|
||||
"k8s.statefulset.current_pods",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -322,10 +298,6 @@ var jobsSpec = checkSpec{
|
||||
"k8s.job.failed_pods",
|
||||
"k8s.job.successful_pods",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -352,12 +324,8 @@ var namespacesSpec = checkSpec{
|
||||
DocumentationLink: docLinkKubeletStatsReceiver,
|
||||
},
|
||||
{
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -392,10 +360,6 @@ var clustersSpec = checkSpec{
|
||||
"k8s.node.condition_ready", // node counts by readiness
|
||||
"k8s.pod.phase", // pod counts per cluster by phase
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ func buildClusterRecords(
|
||||
metadataMap map[string]map[string]string,
|
||||
nodeConditionCountsMap map[string]nodeConditionCounts,
|
||||
podPhaseCountsMap map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.ClusterRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -70,10 +69,6 @@ func buildClusterRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -27,8 +27,6 @@ var clustersTableMetricNamesList = []string{
|
||||
"k8s.node.allocatable_memory",
|
||||
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
}
|
||||
|
||||
var clusterAttrKeysForMetadata = []string{
|
||||
|
||||
@@ -19,7 +19,6 @@ func buildDaemonSetRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.DaemonSetRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -78,10 +77,6 @@ func buildDaemonSetRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -23,8 +23,6 @@ var daemonSetNameGroupByKey = qbtypes.GroupByKey{
|
||||
// response to short-circuit cleanly when the phase metric is absent.
|
||||
var daemonSetsTableMetricNamesList = []string{
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.cpu_request_utilization",
|
||||
"k8s.pod.cpu_limit_utilization",
|
||||
|
||||
@@ -19,7 +19,6 @@ func buildDeploymentRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.DeploymentRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -78,10 +77,6 @@ func buildDeploymentRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -23,8 +23,6 @@ var deploymentNameGroupByKey = qbtypes.GroupByKey{
|
||||
// response to short-circuit cleanly when the phase metric is absent.
|
||||
var deploymentsTableMetricNamesList = []string{
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.cpu_request_utilization",
|
||||
"k8s.pod.cpu_limit_utilization",
|
||||
|
||||
@@ -440,30 +440,6 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
|
||||
return whereClause.WhereClause, nil
|
||||
}
|
||||
|
||||
// mergeQueryWarnings combines any number of query warnings. It is nil-safe and
|
||||
// skips nil entries. The first non-nil warning becomes the primary; each
|
||||
// subsequent one contributes its message (as an additional warning) and its
|
||||
// own additional warnings. Returns nil when all inputs are nil.
|
||||
func mergeQueryWarnings(warnings ...*qbtypes.QueryWarnData) *qbtypes.QueryWarnData {
|
||||
var merged *qbtypes.QueryWarnData
|
||||
for _, w := range warnings {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
if merged == nil {
|
||||
// Copy so we don't mutate the caller's warning.
|
||||
primary := *w
|
||||
merged = &primary
|
||||
continue
|
||||
}
|
||||
if w.Message != "" {
|
||||
merged.Warnings = append(merged.Warnings, qbtypes.QueryWarnDataAdditional{Message: w.Message})
|
||||
}
|
||||
merged.Warnings = append(merged.Warnings, w.Warnings...)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// NOTE: this method is not specific to infra monitoring — it queries attributes_metadata generically.
|
||||
// Consider moving to telemetryMetaStore when a second use case emerges.
|
||||
//
|
||||
|
||||
@@ -26,60 +26,6 @@ type podPhaseCounts struct {
|
||||
Unknown int
|
||||
}
|
||||
|
||||
// podStatusCounts holds per-group pod counts bucketed by latest kubectl-style
|
||||
// display status in window. Mirrors inframonitoringtypes.PodCountsByStatus.
|
||||
type podStatusCounts struct {
|
||||
// Phase fallback.
|
||||
Pending int
|
||||
Running int
|
||||
Failed int
|
||||
Unknown int
|
||||
|
||||
// Container-level reasons.
|
||||
CrashLoopBackOff int
|
||||
ImagePullBackOff int
|
||||
ErrImagePull int
|
||||
CreateContainerConfigError int
|
||||
ContainerCreating int
|
||||
OOMKilled int
|
||||
Completed int
|
||||
Error int
|
||||
ContainerCannotRun int
|
||||
|
||||
// Pod-level reasons.
|
||||
Evicted int
|
||||
NodeAffinity int
|
||||
NodeLost int
|
||||
Shutdown int
|
||||
UnexpectedAdmissionError int
|
||||
}
|
||||
|
||||
// podStatusCountsToResponse copies the internal per-group status counts into the
|
||||
// public response struct. Shared by every entity that surfaces pod status
|
||||
// counts (pods, nodes, namespaces, clusters, workloads).
|
||||
func podStatusCountsToResponse(podStatuses podStatusCounts) inframonitoringtypes.PodCountsByStatus {
|
||||
return inframonitoringtypes.PodCountsByStatus{
|
||||
Pending: podStatuses.Pending,
|
||||
Running: podStatuses.Running,
|
||||
Failed: podStatuses.Failed,
|
||||
Unknown: podStatuses.Unknown,
|
||||
CrashLoopBackOff: podStatuses.CrashLoopBackOff,
|
||||
ImagePullBackOff: podStatuses.ImagePullBackOff,
|
||||
ErrImagePull: podStatuses.ErrImagePull,
|
||||
CreateContainerConfigError: podStatuses.CreateContainerConfigError,
|
||||
ContainerCreating: podStatuses.ContainerCreating,
|
||||
OOMKilled: podStatuses.OOMKilled,
|
||||
Completed: podStatuses.Completed,
|
||||
Error: podStatuses.Error,
|
||||
ContainerCannotRun: podStatuses.ContainerCannotRun,
|
||||
Evicted: podStatuses.Evicted,
|
||||
NodeAffinity: podStatuses.NodeAffinity,
|
||||
NodeLost: podStatuses.NodeLost,
|
||||
Shutdown: podStatuses.Shutdown,
|
||||
UnexpectedAdmissionError: podStatuses.UnexpectedAdmissionError,
|
||||
}
|
||||
}
|
||||
|
||||
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
|
||||
type nodeConditionCounts struct {
|
||||
Ready int
|
||||
|
||||
@@ -19,7 +19,6 @@ func buildJobRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.JobRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -86,10 +85,6 @@ func buildJobRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -23,8 +23,6 @@ var jobNameGroupByKey = qbtypes.GroupByKey{
|
||||
// response to short-circuit cleanly when the phase metric is absent.
|
||||
var jobsTableMetricNamesList = []string{
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.cpu_request_utilization",
|
||||
"k8s.pod.cpu_limit_utilization",
|
||||
|
||||
@@ -309,19 +309,9 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isPodUIDInGroupBy := isKeyInGroupByAttrs(req.GroupBy, podUIDAttrKey)
|
||||
resp.Records = buildPodRecords(isPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, statusCounts, restartCounts, req.End)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, statusWarning)
|
||||
resp.Records = buildPodRecords(isPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, req.End)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -402,14 +392,9 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.NodeNameAttrKey)
|
||||
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -485,13 +470,8 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -574,13 +554,8 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -740,13 +715,8 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildDeploymentRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildDeploymentRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -830,13 +800,8 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildStatefulSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildStatefulSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -920,13 +885,8 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildJobRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildJobRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1010,13 +970,8 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildDaemonSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
|
||||
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
|
||||
resp.Records = buildDaemonSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ func buildNamespaceRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.NamespaceRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -53,10 +52,6 @@ func buildNamespaceRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -23,8 +23,6 @@ var namespacesTableMetricNamesList = []string{
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.memory.working_set",
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
}
|
||||
|
||||
var namespaceAttrKeysForMetadata = []string{
|
||||
|
||||
@@ -26,7 +26,6 @@ func buildNodeRecords(
|
||||
metadataMap map[string]map[string]string,
|
||||
nodeConditionCounts map[string]nodeConditionCounts,
|
||||
podPhaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.NodeRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -87,10 +86,6 @@ func buildNodeRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -26,8 +26,6 @@ var nodesTableMetricNamesList = []string{
|
||||
"k8s.node.allocatable_memory",
|
||||
"k8s.node.condition_ready",
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
}
|
||||
|
||||
var nodeAttrKeysForMetadata = []string{
|
||||
|
||||
@@ -26,8 +26,6 @@ func buildPodRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
statusCounts map[string]podStatusCounts,
|
||||
restartCounts map[string]int64,
|
||||
reqEnd int64,
|
||||
) []inframonitoringtypes.PodRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
@@ -40,8 +38,6 @@ func buildPodRecords(
|
||||
record := inframonitoringtypes.PodRecord{ // initialize with default values
|
||||
PodUID: podUID,
|
||||
PodPhase: inframonitoringtypes.PodPhaseNoData,
|
||||
PodStatus: inframonitoringtypes.PodStatusNoData,
|
||||
PodRestarts: -1,
|
||||
PodCPU: -1,
|
||||
PodCPURequest: -1,
|
||||
PodCPULimit: -1,
|
||||
@@ -99,57 +95,6 @@ func buildPodRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if statusCountsForGroup, ok := statusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(statusCountsForGroup)
|
||||
|
||||
// In list mode each group is one pod; the count==1 bucket identifies the status.
|
||||
if isPodUIDInGroupBy {
|
||||
switch {
|
||||
case statusCountsForGroup.Pending == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusPending
|
||||
case statusCountsForGroup.Running == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusRunning
|
||||
case statusCountsForGroup.Failed == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusFailed
|
||||
case statusCountsForGroup.Unknown == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusUnknown
|
||||
case statusCountsForGroup.CrashLoopBackOff == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusCrashLoopBackOff
|
||||
case statusCountsForGroup.ImagePullBackOff == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusImagePullBackOff
|
||||
case statusCountsForGroup.ErrImagePull == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusErrImagePull
|
||||
case statusCountsForGroup.CreateContainerConfigError == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusCreateContainerConfigError
|
||||
case statusCountsForGroup.ContainerCreating == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusContainerCreating
|
||||
case statusCountsForGroup.OOMKilled == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusOOMKilled
|
||||
case statusCountsForGroup.Completed == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusCompleted
|
||||
case statusCountsForGroup.Error == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusError
|
||||
case statusCountsForGroup.ContainerCannotRun == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusContainerCannotRun
|
||||
case statusCountsForGroup.Evicted == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusEvicted
|
||||
case statusCountsForGroup.NodeAffinity == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusNodeAffinity
|
||||
case statusCountsForGroup.NodeLost == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusNodeLost
|
||||
case statusCountsForGroup.Shutdown == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusShutdown
|
||||
case statusCountsForGroup.UnexpectedAdmissionError == 1:
|
||||
record.PodStatus = inframonitoringtypes.PodStatusUnexpectedAdmissionError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart count: pod's own sum (list mode) or group total (grouped mode).
|
||||
if restartCountForGroup, ok := restartCounts[compositeKey]; ok {
|
||||
record.PodRestarts = restartCountForGroup
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
// podAge only makes sense when pod uid is in groupBy. Otherwise the
|
||||
// group can contain multiple pods with different start times.
|
||||
@@ -389,519 +334,3 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getPerGroupPodStatusCountsWithReqMetricChecks gates getPerGroupPodStatusCounts
|
||||
// on the required metrics being present. If any of podStatusMetricNamesList has
|
||||
// never been reported, it skips the query and returns a warning instead (the
|
||||
// status query would otherwise silently degrade to bare phase). Otherwise it
|
||||
// runs the query. The returned counts map is empty (never nil) when gated off.
|
||||
func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
ctx context.Context,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
present, err := m.getMetricsExistence(ctx, podStatusMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, name := range podStatusMetricNamesList {
|
||||
if !present[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
warning := &qbtypes.QueryWarnData{
|
||||
Message: fmt.Sprintf(
|
||||
"Pod status could not be computed: required metric(s) not found: %s. "+
|
||||
"Enable the optional k8s.pod.status_reason and k8s.container.status.reason "+
|
||||
"metrics in the k8s_cluster receiver to see pod statuses.",
|
||||
strings.Join(missing, ", "),
|
||||
),
|
||||
Url: docLinkK8sClusterReceiver,
|
||||
}
|
||||
return map[string]podStatusCounts{}, warning, nil
|
||||
}
|
||||
|
||||
counts, err := m.getPerGroupPodStatusCounts(ctx, start, end, filter, groupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return counts, nil, nil
|
||||
}
|
||||
|
||||
// getPerGroupPodStatusCounts computes per-group pod counts bucketed by each
|
||||
// pod's latest kubectl-style display status in the requested window. Caller
|
||||
// must ensure the required metrics exist (getPerGroupPodStatusCountsWithReqMetricChecks).
|
||||
// Pipeline (mirrors getPerGroupPodPhaseCounts, more CTEs):
|
||||
//
|
||||
// phase_fps / phase_per_pod: latest k8s.pod.phase per pod (+ groupBy cols).
|
||||
// pod_reason_fps / pod_reason_per_pod: latest k8s.pod.status_reason per pod.
|
||||
// container_reason_fps / container_reason_per_pod:
|
||||
// highest-priority active k8s.container.status.reason per pod.
|
||||
// pod_status: display status per pod (container > pod reason > phase).
|
||||
// countPodsPerStatus: per-group uniqExactIf into the fixed status buckets.
|
||||
//
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupPodStatusCounts(
|
||||
ctx context.Context,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]podStatusCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
return map[string]podStatusCounts{}, nil
|
||||
}
|
||||
|
||||
// Merge user filter with page-groups IN clauses.
|
||||
userFilterExpr := ""
|
||||
if filter != nil {
|
||||
userFilterExpr = filter.Expression
|
||||
}
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Build the merged filter clause once; it's identical across the three fps
|
||||
// CTEs, and buildFilterClause hits the metadata store + parses the
|
||||
// expression, so we don't want to repeat it per CTE. AddWhereClause only
|
||||
// reads the clause, so the same instance is safe to attach to each builder.
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// ----- phase_fps (carries groupBy cols) -----
|
||||
phaseFps := sqlbuilder.NewSelectBuilder()
|
||||
phaseFpsCols := []string{
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", phaseFps.Var(podUIDAttrKey)),
|
||||
}
|
||||
for _, key := range groupBy {
|
||||
phaseFpsCols = append(phaseFpsCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", phaseFps.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
phaseFps.Select(phaseFpsCols...)
|
||||
phaseFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
phaseFps.Where(
|
||||
phaseFps.E("metric_name", podPhaseMetricName),
|
||||
phaseFps.GE("unix_milli", tsAdjustedStart),
|
||||
phaseFps.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if filterClause != nil {
|
||||
phaseFps.AddWhereClause(filterClause)
|
||||
}
|
||||
phaseFpsGroupBy := []string{"fingerprint", "pod_uid"}
|
||||
for _, key := range groupBy {
|
||||
phaseFpsGroupBy = append(phaseFpsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
phaseFps.GroupBy(phaseFpsGroupBy...)
|
||||
phaseFpsSQL, phaseFpsArgs := phaseFps.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- phase_per_pod -----
|
||||
phasePerPod := sqlbuilder.NewSelectBuilder()
|
||||
phasePerPodCols := []string{"fps.pod_uid AS pod_uid"}
|
||||
for _, key := range groupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
phasePerPodCols = append(phasePerPodCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
|
||||
}
|
||||
phasePerPodCols = append(phasePerPodCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS phase_value", valueCol))
|
||||
phasePerPod.Select(phasePerPodCols...)
|
||||
phasePerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN phase_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
))
|
||||
phasePerPod.Where(
|
||||
phasePerPod.E("samples.metric_name", podPhaseMetricName),
|
||||
phasePerPod.GE("samples.unix_milli", samplesStartMs),
|
||||
phasePerPod.L("samples.unix_milli", flooredEndMs),
|
||||
"fps.pod_uid != ''",
|
||||
)
|
||||
phasePerPod.GroupBy("pod_uid")
|
||||
phasePerPodSQL, phasePerPodArgs := phasePerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- pod_reason_fps -----
|
||||
podReasonFps := sqlbuilder.NewSelectBuilder()
|
||||
podReasonFps.Select(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", podReasonFps.Var(podUIDAttrKey)),
|
||||
)
|
||||
podReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
podReasonFps.Where(
|
||||
podReasonFps.E("metric_name", podStatusReasonMetricName),
|
||||
podReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
podReasonFps.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if filterClause != nil {
|
||||
podReasonFps.AddWhereClause(filterClause)
|
||||
}
|
||||
podReasonFps.GroupBy("fingerprint", "pod_uid")
|
||||
podReasonFpsSQL, podReasonFpsArgs := podReasonFps.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- pod_reason_per_pod -----
|
||||
podReasonPerPod := sqlbuilder.NewSelectBuilder()
|
||||
podReasonPerPod.Select(
|
||||
"fps.pod_uid AS pod_uid",
|
||||
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS reason_value", valueCol),
|
||||
)
|
||||
podReasonPerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN pod_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
))
|
||||
podReasonPerPod.Where(
|
||||
podReasonPerPod.E("samples.metric_name", podStatusReasonMetricName),
|
||||
podReasonPerPod.GE("samples.unix_milli", samplesStartMs),
|
||||
podReasonPerPod.L("samples.unix_milli", flooredEndMs),
|
||||
"fps.pod_uid != ''",
|
||||
)
|
||||
podReasonPerPod.GroupBy("pod_uid")
|
||||
podReasonPerPodSQL, podReasonPerPodArgs := podReasonPerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- container_reason_fps -----
|
||||
containerReasonFps := sqlbuilder.NewSelectBuilder()
|
||||
containerReasonFps.Select(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", containerReasonFps.Var(podUIDAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", containerReasonFps.Var(containerNameAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", containerReasonFps.Var(containerStatusReasonAttrKey)),
|
||||
)
|
||||
containerReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
containerReasonFps.Where(
|
||||
containerReasonFps.E("metric_name", containerStatusReasonMetricName),
|
||||
containerReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
containerReasonFps.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if filterClause != nil {
|
||||
containerReasonFps.AddWhereClause(filterClause)
|
||||
}
|
||||
containerReasonFps.GroupBy("fingerprint", "pod_uid", "container_name", "reason")
|
||||
containerReasonFpsSQL, containerReasonFpsArgs := containerReasonFps.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- container_reason_per_pod -----
|
||||
// Inner: latest value per (pod, container, reason) -> kills stale
|
||||
// fingerprints from old container incarnations; keep only active (=1).
|
||||
// Outer: highest-priority active reason per pod (waiting > terminated).
|
||||
priorityCase := "CASE fps.reason " +
|
||||
"WHEN 'CrashLoopBackOff' THEN 8 " +
|
||||
"WHEN 'ImagePullBackOff' THEN 7 " +
|
||||
"WHEN 'ErrImagePull' THEN 6 " +
|
||||
"WHEN 'CreateContainerConfigError' THEN 5 " +
|
||||
"WHEN 'ContainerCreating' THEN 4 " +
|
||||
"WHEN 'OOMKilled' THEN 3 " +
|
||||
"WHEN 'Error' THEN 2 " +
|
||||
"WHEN 'ContainerCannotRun' THEN 1 " +
|
||||
"ELSE 0 END"
|
||||
containerInner := sqlbuilder.NewSelectBuilder()
|
||||
containerInner.Select(
|
||||
"fps.pod_uid AS pod_uid",
|
||||
"fps.container_name AS container_name",
|
||||
"fps.reason AS reason",
|
||||
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS is_active", valueCol),
|
||||
priorityCase+" AS priority",
|
||||
)
|
||||
containerInner.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN container_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerInner.Where(
|
||||
containerInner.E("samples.metric_name", containerStatusReasonMetricName),
|
||||
containerInner.GE("samples.unix_milli", samplesStartMs),
|
||||
containerInner.L("samples.unix_milli", flooredEndMs),
|
||||
"fps.pod_uid != ''",
|
||||
)
|
||||
containerInner.GroupBy("fps.pod_uid", "fps.container_name", "fps.reason")
|
||||
containerInner.Having("is_active = 1")
|
||||
containerInnerSQL, containerInnerArgs := containerInner.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
containerPerPodSQL := fmt.Sprintf(
|
||||
"SELECT pod_uid, argMax(reason, priority) AS active_reason FROM (%s) GROUP BY pod_uid",
|
||||
containerInnerSQL,
|
||||
)
|
||||
|
||||
// ----- pod_status (display status per pod) -----
|
||||
// container reason > pod-level reason > phase fallback. Numeric literals
|
||||
// match the k8s.pod.status_reason and k8s.pod.phase metric encodings.
|
||||
displayStatusExpr := "multiIf(" +
|
||||
"cr.active_reason != '', cr.active_reason, " +
|
||||
"pr.reason_value = 1, 'Evicted', " +
|
||||
"pr.reason_value = 2, 'NodeAffinity', " +
|
||||
"pr.reason_value = 3, 'NodeLost', " +
|
||||
"pr.reason_value = 4, 'Shutdown', " +
|
||||
"pr.reason_value = 5, 'UnexpectedAdmissionError', " +
|
||||
"pp.phase_value = 1, 'Pending', " +
|
||||
"pp.phase_value = 2, 'Running', " +
|
||||
"pp.phase_value = 3, 'Completed', " +
|
||||
"pp.phase_value = 4, 'Failed', " +
|
||||
"pp.phase_value = 5, 'Unknown', " +
|
||||
"'Unknown')"
|
||||
podStatusSelectCols := []string{"pp.pod_uid AS pod_uid"}
|
||||
for _, key := range groupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
podStatusSelectCols = append(podStatusSelectCols, fmt.Sprintf("pp.%s AS %s", col, col))
|
||||
}
|
||||
podStatusSelectCols = append(podStatusSelectCols, displayStatusExpr+" AS display_status")
|
||||
podStatusSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM phase_per_pod AS pp "+
|
||||
"LEFT JOIN pod_reason_per_pod AS pr ON pp.pod_uid = pr.pod_uid "+
|
||||
"LEFT JOIN container_reason_per_pod AS cr ON pp.pod_uid = cr.pod_uid",
|
||||
strings.Join(podStatusSelectCols, ", "),
|
||||
)
|
||||
|
||||
// ----- countPodsPerStatus (outer SELECT) -----
|
||||
// Fixed status order; MUST match the podStatusCounts assignment in the
|
||||
// scan loop below.
|
||||
statusCountCols := []string{
|
||||
"uniqExactIf(pod_uid, display_status = 'Pending') AS pending_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Running') AS running_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Failed') AS failed_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Unknown') AS unknown_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'CrashLoopBackOff') AS crash_loop_back_off_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'ImagePullBackOff') AS image_pull_back_off_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'ErrImagePull') AS err_image_pull_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'CreateContainerConfigError') AS create_container_config_error_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'ContainerCreating') AS container_creating_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'OOMKilled') AS oom_killed_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Completed') AS completed_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Error') AS error_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'ContainerCannotRun') AS container_cannot_run_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Evicted') AS evicted_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'NodeAffinity') AS node_affinity_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'NodeLost') AS node_lost_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'Shutdown') AS shutdown_count",
|
||||
"uniqExactIf(pod_uid, display_status = 'UnexpectedAdmissionError') AS unexpected_admission_error_count",
|
||||
}
|
||||
countSelectCols := make([]string, 0, len(groupBy)+len(statusCountCols))
|
||||
countGroupBy := make([]string, 0, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
countSelectCols = append(countSelectCols, col)
|
||||
countGroupBy = append(countGroupBy, col)
|
||||
}
|
||||
countSelectCols = append(countSelectCols, statusCountCols...)
|
||||
countSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM pod_status GROUP BY %s",
|
||||
strings.Join(countSelectCols, ", "),
|
||||
strings.Join(countGroupBy, ", "),
|
||||
)
|
||||
|
||||
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
|
||||
cteFragments := []string{
|
||||
fmt.Sprintf("phase_fps AS (%s)", phaseFpsSQL),
|
||||
fmt.Sprintf("phase_per_pod AS (%s)", phasePerPodSQL),
|
||||
fmt.Sprintf("pod_reason_fps AS (%s)", podReasonFpsSQL),
|
||||
fmt.Sprintf("pod_reason_per_pod AS (%s)", podReasonPerPodSQL),
|
||||
fmt.Sprintf("container_reason_fps AS (%s)", containerReasonFpsSQL),
|
||||
fmt.Sprintf("container_reason_per_pod AS (%s)", containerPerPodSQL),
|
||||
fmt.Sprintf("pod_status AS (%s)", podStatusSQL),
|
||||
}
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{
|
||||
phaseFpsArgs, phasePerPodArgs,
|
||||
podReasonFpsArgs, podReasonPerPodArgs,
|
||||
containerReasonFpsArgs, containerInnerArgs,
|
||||
}, nil)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]podStatusCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(groupBy))
|
||||
counts := make([]uint64, len(statusCountCols))
|
||||
scanPtrs := make([]any, 0, len(groupBy)+len(statusCountCols))
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
for i := range counts {
|
||||
scanPtrs = append(scanPtrs, &counts[i])
|
||||
}
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[compositeKeyFromList(groupVals)] = podStatusCounts{
|
||||
Pending: int(counts[0]),
|
||||
Running: int(counts[1]),
|
||||
Failed: int(counts[2]),
|
||||
Unknown: int(counts[3]),
|
||||
CrashLoopBackOff: int(counts[4]),
|
||||
ImagePullBackOff: int(counts[5]),
|
||||
ErrImagePull: int(counts[6]),
|
||||
CreateContainerConfigError: int(counts[7]),
|
||||
ContainerCreating: int(counts[8]),
|
||||
OOMKilled: int(counts[9]),
|
||||
Completed: int(counts[10]),
|
||||
Error: int(counts[11]),
|
||||
ContainerCannotRun: int(counts[12]),
|
||||
Evicted: int(counts[13]),
|
||||
NodeAffinity: int(counts[14]),
|
||||
NodeLost: int(counts[15]),
|
||||
Shutdown: int(counts[16]),
|
||||
UnexpectedAdmissionError: int(counts[17]),
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getPerGroupPodRestartCounts computes the absolute pod restart count per group
|
||||
// from k8s.container.restarts (default-enabled, so no existence gate). In list
|
||||
// mode (groupBy=pod_uid) each group is one pod -> its restart count; in grouped
|
||||
// mode it's the summed restarts across all pods in the group. Mirrors DD:
|
||||
// argMax(value, unix_milli) per (pod, container) takes the current cumulative
|
||||
// count from the latest incarnation (handling fingerprint staleness/pruning),
|
||||
// then sum.
|
||||
//
|
||||
// restart_fps: fp ↔ (pod_uid, container_name, groupBy cols) from time_series.
|
||||
// container_restarts: INNER JOIN samples, latest restartCount per (pod, container).
|
||||
// (outer): per-group sum(restart_count).
|
||||
//
|
||||
// Groups absent from the result map have no data (caller default).
|
||||
func (m *module) getPerGroupPodRestartCounts(
|
||||
ctx context.Context,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]int64, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
|
||||
// Merge user filter with page-groups IN clauses.
|
||||
userFilterExpr := ""
|
||||
if filter != nil {
|
||||
userFilterExpr = filter.Expression
|
||||
}
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// ----- restart_fps (carries groupBy cols) -----
|
||||
restartFps := sqlbuilder.NewSelectBuilder()
|
||||
restartFpsCols := []string{
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", restartFps.Var(podUIDAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", restartFps.Var(containerNameAttrKey)),
|
||||
}
|
||||
for _, key := range groupBy {
|
||||
restartFpsCols = append(restartFpsCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", restartFps.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
restartFps.Select(restartFpsCols...)
|
||||
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
restartFps.Where(
|
||||
restartFps.E("metric_name", containerRestartsMetricName),
|
||||
restartFps.GE("unix_milli", tsAdjustedStart),
|
||||
restartFps.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if filterClause != nil {
|
||||
restartFps.AddWhereClause(filterClause)
|
||||
}
|
||||
restartFpsGroupBy := []string{"fingerprint", "pod_uid", "container_name"}
|
||||
for _, key := range groupBy {
|
||||
restartFpsGroupBy = append(restartFpsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
restartFps.GroupBy(restartFpsGroupBy...)
|
||||
restartFpsSQL, restartFpsArgs := restartFps.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- container_restarts (latest cumulative count per container) -----
|
||||
containerRestarts := sqlbuilder.NewSelectBuilder()
|
||||
containerRestartsCols := []string{
|
||||
"fps.pod_uid AS pod_uid",
|
||||
"fps.container_name AS container_name",
|
||||
}
|
||||
for _, key := range groupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
|
||||
}
|
||||
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS restart_count", valueCol))
|
||||
containerRestarts.Select(containerRestartsCols...)
|
||||
containerRestarts.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerRestarts.Where(
|
||||
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
|
||||
containerRestarts.GE("samples.unix_milli", samplesStartMs),
|
||||
containerRestarts.L("samples.unix_milli", flooredEndMs),
|
||||
"fps.pod_uid != ''",
|
||||
)
|
||||
containerRestarts.GroupBy("fps.pod_uid", "fps.container_name")
|
||||
containerRestartsSQL, containerRestartsArgs := containerRestarts.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- outer: per-group sum across containers (hence across pods) -----
|
||||
sumSelectCols := make([]string, 0, len(groupBy)+1)
|
||||
sumGroupBy := make([]string, 0, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
sumSelectCols = append(sumSelectCols, col)
|
||||
sumGroupBy = append(sumGroupBy, col)
|
||||
}
|
||||
sumSelectCols = append(sumSelectCols, "sum(restart_count) AS total_restarts")
|
||||
sumSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM container_restarts GROUP BY %s",
|
||||
strings.Join(sumSelectCols, ", "),
|
||||
strings.Join(sumGroupBy, ", "),
|
||||
)
|
||||
|
||||
cteFragments := []string{
|
||||
fmt.Sprintf("restart_fps AS (%s)", restartFpsSQL),
|
||||
fmt.Sprintf("container_restarts AS (%s)", containerRestartsSQL),
|
||||
}
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + sumSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{restartFpsArgs, containerRestartsArgs}, nil)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(groupBy))
|
||||
var totalRestarts float64
|
||||
scanPtrs := make([]any, 0, len(groupBy)+1)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
scanPtrs = append(scanPtrs, &totalRestarts)
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[compositeKeyFromList(groupVals)] = int64(totalRestarts)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -8,26 +8,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
containerNameAttrKey = "k8s.container.name"
|
||||
containerStatusReasonAttrKey = "k8s.container.status.reason"
|
||||
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
podStatusReasonMetricName = "k8s.pod.status_reason"
|
||||
containerStatusReasonMetricName = "k8s.container.status.reason"
|
||||
containerRestartsMetricName = "k8s.container.restarts"
|
||||
podUIDAttrKey = "k8s.pod.uid"
|
||||
podStartTimeAttrKey = "k8s.pod.start_time"
|
||||
podPhaseMetricName = "k8s.pod.phase"
|
||||
)
|
||||
|
||||
// podStatusMetricNamesList are the metrics required to derive the kubectl-style
|
||||
// pod display status. All three must be present (getMetricsExistence gate)
|
||||
// before getPerGroupPodStatusCounts runs.
|
||||
var podStatusMetricNamesList = []string{
|
||||
podPhaseMetricName,
|
||||
podStatusReasonMetricName,
|
||||
containerStatusReasonMetricName,
|
||||
}
|
||||
|
||||
var podUIDGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: podUIDAttrKey,
|
||||
@@ -44,9 +29,6 @@ var podsTableMetricNamesList = []string{
|
||||
"k8s.pod.memory_request_utilization",
|
||||
"k8s.pod.memory_limit_utilization",
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.container.restarts",
|
||||
}
|
||||
|
||||
var podAttrKeysForMetadata = []string{
|
||||
|
||||
@@ -19,7 +19,6 @@ func buildStatefulSetRecords(
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
phaseCounts map[string]podPhaseCounts,
|
||||
podStatusCounts map[string]podStatusCounts,
|
||||
) []inframonitoringtypes.StatefulSetRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
@@ -78,10 +77,6 @@ func buildStatefulSetRecords(
|
||||
}
|
||||
}
|
||||
|
||||
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
|
||||
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
|
||||
@@ -23,8 +23,6 @@ var statefulSetNameGroupByKey = qbtypes.GroupByKey{
|
||||
// response to short-circuit cleanly when the phase metric is absent.
|
||||
var statefulSetsTableMetricNamesList = []string{
|
||||
"k8s.pod.phase",
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
"k8s.pod.cpu.usage",
|
||||
"k8s.pod.cpu_request_utilization",
|
||||
"k8s.pod.cpu_limit_utilization",
|
||||
|
||||
@@ -18,10 +18,6 @@ func NewGetter(store serviceaccounttypes.Store) serviceaccount.Getter {
|
||||
return &getter{store: store}
|
||||
}
|
||||
|
||||
func (getter *getter) GetServiceAccountRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
|
||||
return getter.store.GetServiceAccountRoleByOrgIDAndID(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
|
||||
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,11 +15,10 @@ import (
|
||||
|
||||
type handler struct {
|
||||
module serviceaccount.Module
|
||||
getter serviceaccount.Getter
|
||||
}
|
||||
|
||||
func NewHandler(module serviceaccount.Module, getter serviceaccount.Getter) serviceaccount.Handler {
|
||||
return &handler{module: module, getter: getter}
|
||||
func NewHandler(module serviceaccount.Module) serviceaccount.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -228,13 +227,13 @@ func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole)
|
||||
req := new(serviceaccounttypes.PostableServiceAccountRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
|
||||
err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -272,81 +271,6 @@ func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.PostableServiceAccountRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccountRole, err := handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), req.ServiceAccountID, req.RoleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: serviceAccountRole.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) GetServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccountRole, err := handler.getter.GetServiceAccountRole(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccountRole)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccountRole, err := handler.getter.GetServiceAccountRole(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccountRole.ServiceAccountID, serviceAccountRole.RoleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
|
||||
@@ -111,19 +111,19 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) SetRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, roleID valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
|
||||
func (module *module) SetRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, roleID valuer.UUID) error {
|
||||
role, err := module.authz.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
return module.setRole(ctx, orgID, id, role)
|
||||
}
|
||||
|
||||
func (module *module) SetRoleByName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) (*serviceaccounttypes.ServiceAccountRole, error) {
|
||||
func (module *module) SetRoleByName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) error {
|
||||
role, err := module.authz.GetByOrgIDAndName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
return module.setRole(ctx, orgID, id, role)
|
||||
@@ -376,42 +376,28 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) (*serviceaccounttypes.ServiceAccountRole, error) {
|
||||
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
serviceAccountRole, err := serviceAccount.AddRole(role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceAccountWithRoles, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, existingServiceAccountRole := range serviceAccountWithRoles.ServiceAccountRoles {
|
||||
if existingServiceAccountRole.RoleID == role.ID {
|
||||
serviceAccountRole = existingServiceAccountRole
|
||||
break
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return serviceAccountRole, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) trackUser(ctx context.Context, orgID string, userID string, event string, attrs map[string]any) {
|
||||
|
||||
@@ -198,35 +198,15 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(serviceAccountRole).
|
||||
On("CONFLICT (service_account_id, role_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleAlreadyExists, "role: %s is already assigned to service account: %s", serviceAccountRole.RoleID, serviceAccountRole.ServiceAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetServiceAccountRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
|
||||
serviceAccountRole := new(serviceaccounttypes.ServiceAccountRole)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(serviceAccountRole).
|
||||
Join("JOIN service_account").
|
||||
JoinOn("service_account.id = service_account_role.service_account_id").
|
||||
Where("service_account.org_id = ?", orgID).
|
||||
Where("service_account_role.id = ?", id).
|
||||
Relation("Role").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleNotFound, "service account role with id: %s doesn't exist in org: %s", id, orgID)
|
||||
}
|
||||
|
||||
return serviceAccountRole, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
|
||||
@@ -14,9 +14,6 @@ import (
|
||||
type Getter interface {
|
||||
// OnBeforeRoleDelete checks if any service accounts are assigned to the role and rejects deletion if so.
|
||||
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
|
||||
|
||||
// Gets a service account role by org id and id.
|
||||
GetServiceAccountRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
@@ -38,11 +35,11 @@ type Module interface {
|
||||
// Updates an existing service account
|
||||
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Assign a role to the service account and returns the service account role. this is safe to retry
|
||||
SetRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error)
|
||||
// Assign a role to the service account. this is safe to retry
|
||||
SetRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Assigns a role by name to service account and returns the service account role, this is safe to retry
|
||||
SetRoleByName(context.Context, valuer.UUID, valuer.UUID, string) (*serviceaccounttypes.ServiceAccountRole, error)
|
||||
// Assigns a role by name to service account, this is safe to retry
|
||||
SetRoleByName(context.Context, valuer.UUID, valuer.UUID, string) error
|
||||
|
||||
// Revokes a role from service account, this is safe to retry
|
||||
DeleteRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) error
|
||||
@@ -98,12 +95,6 @@ type Handler interface {
|
||||
|
||||
DeleteRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateServiceAccountRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetServiceAccountRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteServiceAccountRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateFactorAPIKey(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -218,10 +218,6 @@ func (module *getter) GetRolesByUserID(ctx context.Context, userID valuer.UUID)
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
func (module *getter) GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error) {
|
||||
return module.userRoleStore.GetUserRoleByOrgIDAndID(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *getter) GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
return module.store.GetResetPasswordTokenByOrgIDAndUserID(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
@@ -588,7 +588,7 @@ func (handler *handler) SetRoleByUserID(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := handler.setter.AddUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), postableRole.Name); err != nil {
|
||||
if err := handler.setter.AddUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), postableRole.Name); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
@@ -642,93 +642,3 @@ func (handler *handler) GetUsersByRoleID(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
render.Success(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(authtypes.PostableUserRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UserID.String() == claims.UserID {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
|
||||
return
|
||||
}
|
||||
|
||||
userRole, err := handler.setter.AddUserRoleByRoleID(ctx, valuer.MustNewUUID(claims.OrgID), req.UserID, req.RoleID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusCreated, types.Identifiable{ID: userRole.ID})
|
||||
}
|
||||
|
||||
func (handler *handler) GetUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userRole, err := handler.getter.GetUserRoleByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, userRole)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
userRole, err := handler.getter.GetUserRoleByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if userRole.UserID.String() == claims.UserID {
|
||||
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), userRole.UserID, userRole.RoleID); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -914,27 +914,27 @@ func (module *setter) UpdateUserRoles(ctx context.Context, orgID, userID valuer.
|
||||
})
|
||||
}
|
||||
|
||||
func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) (*authtypes.UserRole, error) {
|
||||
func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error {
|
||||
existingUser, err := module.getter.GetUserByOrgIDAndID(ctx, orgID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
if err := existingUser.ErrIfRoot(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot add role for root user")
|
||||
return errors.WithAdditionalf(err, "cannot add role for root user")
|
||||
}
|
||||
|
||||
if err := existingUser.ErrIfDeleted(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot add role for deleted user")
|
||||
return errors.WithAdditionalf(err, "cannot add role for deleted user")
|
||||
}
|
||||
|
||||
// validate that the role name exists
|
||||
foundRoles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, []string{roleName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if len(foundRoles) != 1 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "role name not found: %s", roleName)
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "role name not found: %s", roleName)
|
||||
}
|
||||
|
||||
// grant via authz (additive, idempotent — OpenFGA uses OnDuplicate: "ignore")
|
||||
@@ -944,43 +944,18 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
|
||||
[]string{roleName},
|
||||
authtypes.MustNewSubject(coretypes.NewResourceUser(), existingUser.ID.StringValue(), existingUser.OrgID, nil),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
// create user_role entry (swallow AlreadyExists for idempotency — DB has unique constraint on user_id+role_id)
|
||||
userRole := authtypes.NewUserRoles(userID, foundRoles)[0]
|
||||
if err := module.userRoleStore.CreateUserRoles(ctx, []*authtypes.UserRole{userRole}); err != nil {
|
||||
userRoles := authtypes.NewUserRoles(userID, foundRoles)
|
||||
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
|
||||
if !errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingUserRoles, err := module.getter.GetRolesByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, existingUserRole := range existingUserRoles {
|
||||
if existingUserRole.RoleID == foundRoles[0].ID {
|
||||
userRole = existingUserRole
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := module.tokenizer.DeleteIdentity(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userRole, nil
|
||||
}
|
||||
|
||||
func (module *setter) AddUserRoleByRoleID(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) (*authtypes.UserRole, error) {
|
||||
role, err := module.authz.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.AddUserRole(ctx, orgID, userID, role.Name)
|
||||
return module.tokenizer.DeleteIdentity(ctx, userID)
|
||||
}
|
||||
|
||||
func (module *setter) RemoveUserRole(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) error {
|
||||
|
||||
@@ -39,26 +39,6 @@ func (store *userRoleStore) ListUserRolesByOrgIDAndUserIDs(ctx context.Context,
|
||||
return userRoles, nil
|
||||
}
|
||||
|
||||
func (store *userRoleStore) GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error) {
|
||||
userRole := new(authtypes.UserRole)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(userRole).
|
||||
Join("JOIN users").
|
||||
JoinOn("users.id = user_role.user_id").
|
||||
Where("users.org_id = ?", orgID).
|
||||
Where("user_role.id = ?", id).
|
||||
Relation("Role").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeUserRolesNotFound, "user role with id: %s doesn't exist in org: %s", id, orgID)
|
||||
}
|
||||
|
||||
return userRole, nil
|
||||
}
|
||||
|
||||
func (store *userRoleStore) CreateUserRoles(ctx context.Context, userRoles []*authtypes.UserRole) error {
|
||||
_, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
|
||||
@@ -50,8 +50,7 @@ type Setter interface {
|
||||
|
||||
// Roles
|
||||
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
|
||||
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) (*authtypes.UserRole, error)
|
||||
AddUserRoleByRoleID(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) (*authtypes.UserRole, error)
|
||||
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
|
||||
RemoveUserRole(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) error
|
||||
|
||||
statsreporter.StatsCollector
|
||||
@@ -93,9 +92,6 @@ type Getter interface {
|
||||
// Gets user_role with roles entries from db
|
||||
GetRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.UserRole, error)
|
||||
|
||||
// Gets a single user role by org id and id.
|
||||
GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error)
|
||||
|
||||
// Gets all the user with role using role id in an org id
|
||||
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
|
||||
|
||||
@@ -128,11 +124,6 @@ type Handler interface {
|
||||
RemoveUserRoleByRoleID(http.ResponseWriter, *http.Request)
|
||||
GetUsersByRoleID(http.ResponseWriter, *http.Request)
|
||||
|
||||
// user roles
|
||||
CreateUserRole(http.ResponseWriter, *http.Request)
|
||||
GetUserRole(http.ResponseWriter, *http.Request)
|
||||
DeleteUserRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Reset Password
|
||||
GetResetPasswordTokenDeprecated(http.ResponseWriter, *http.Request)
|
||||
GetResetPasswordToken(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -123,7 +123,7 @@ func NewHandlers(
|
||||
AuthzHandler: signozauthzapi.NewHandler(authz),
|
||||
ZeusHandler: zeus.NewHandler(zeusService, licensing),
|
||||
QuerierHandler: querierHandler,
|
||||
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter),
|
||||
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount),
|
||||
RegistryHandler: registryHandler,
|
||||
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -86,10 +86,9 @@ type Modules struct {
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
ServiceAccountGetter serviceaccount.Getter
|
||||
CloudIntegration cloudintegration.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
CloudIntegration cloudintegration.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
@@ -118,7 +117,6 @@ func NewModules(
|
||||
userGetter user.Getter,
|
||||
userRoleStore authtypes.UserRoleStore,
|
||||
serviceAccount serviceaccount.Module,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
cloudIntegrationModule cloudintegration.Module,
|
||||
retentionGetter retention.Getter,
|
||||
fl flagger.Flagger,
|
||||
@@ -156,8 +154,7 @@ func NewModules(
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
ServiceAccount: serviceAccount,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
|
||||
@@ -62,11 +62,9 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
|
||||
|
||||
serviceAccountGetter := implserviceaccount.NewGetter(implserviceaccount.NewStore(sqlstore))
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -78,7 +78,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ zeus.Handler }{},
|
||||
struct{ querier.Handler }{},
|
||||
struct{ serviceaccount.Handler }{},
|
||||
struct{ serviceaccount.Getter }{},
|
||||
struct{ factory.Handler }{},
|
||||
struct{ cloudintegration.Handler }{},
|
||||
struct{ rulestatehistory.Handler }{},
|
||||
|
||||
@@ -307,7 +307,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.ZeusHandler,
|
||||
handlers.QuerierHandler,
|
||||
handlers.ServiceAccountHandler,
|
||||
modules.ServiceAccountGetter,
|
||||
handlers.RegistryHandler,
|
||||
handlers.CloudIntegrationHandler,
|
||||
handlers.RuleStateHistory,
|
||||
|
||||
@@ -473,7 +473,7 @@ func New(
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
|
||||
@@ -36,39 +36,14 @@ type UserWithRoles struct {
|
||||
}
|
||||
|
||||
type PostableUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
UserRoles []*DeprecatedPostableUserRole `json:"userRoles" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type DeprecatedPostableUserRole struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email valuer.Email `json:"email" required:"true"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
UserRoles []*PostableUserRole `json:"userRoles" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type PostableUserRole struct {
|
||||
UserID valuer.UUID `json:"userId" required:"true"`
|
||||
RoleID valuer.UUID `json:"roleId" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableUserRole) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableUserRole
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.UserID.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "userId is required")
|
||||
}
|
||||
|
||||
if temp.RoleID.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeUserRoleInvalidInput, "roleId is required")
|
||||
}
|
||||
|
||||
*p = PostableUserRole(temp)
|
||||
return nil
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableUser) UnmarshalJSON(data []byte) error {
|
||||
@@ -116,9 +91,6 @@ type UserRoleStore interface {
|
||||
// get user roles by user id
|
||||
GetUserRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*UserRole, error)
|
||||
|
||||
// get a single user role entry by org id and its own id
|
||||
GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*UserRole, error)
|
||||
|
||||
// list all user_role entries for
|
||||
ListUserRolesByOrgIDAndUserIDs(ctx context.Context, orgID valuer.UUID, userIDs []valuer.UUID) ([]*UserRole, error)
|
||||
|
||||
|
||||
@@ -32,14 +32,19 @@ type ResourceIDsExtractor struct {
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
}
|
||||
|
||||
func NewResourceIDExtractor(phase ExtractPhase, fn func(ExtractorContext) (string, error)) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: phase, Fn: fn}
|
||||
}
|
||||
|
||||
func (extractor ResourceIDExtractor) IsPhase(phase ExtractPhase) bool {
|
||||
return extractor.Fn != nil && extractor.Phase == phase
|
||||
}
|
||||
|
||||
func (extractor ResourceIDExtractor) RunFor(phase ExtractPhase, ec ExtractorContext) (string, bool) {
|
||||
if !extractor.IsPhase(phase) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
id, _ := extractor.Fn(ec)
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (extractor ResourceIDsExtractor) IsPhase(phase ExtractPhase) bool {
|
||||
return extractor.Fn != nil && extractor.Phase == phase
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ type ResolvedResource interface {
|
||||
SourceResource() Resource
|
||||
SourceIDs() []string
|
||||
SourceSelector() SelectorFunc
|
||||
Err() error
|
||||
ResolveResponse(ec ExtractorContext)
|
||||
// hasResponsePhase reports whether an id is resolved from the response body.
|
||||
hasResponsePhase() bool
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ type resolvedResource struct {
|
||||
selector SelectorFunc
|
||||
idExtractor ResourceIDExtractor
|
||||
ids []string
|
||||
err error
|
||||
}
|
||||
|
||||
func NewResolvedResource(
|
||||
@@ -31,25 +30,11 @@ func NewResolvedResource(
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
|
||||
if !resolved.idExtractor.IsPhase(phase) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := resolved.idExtractor.Fn(ec)
|
||||
if err != nil && phase == PhaseRequest {
|
||||
resolved.err = err
|
||||
return
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
if id, ok := resolved.idExtractor.RunFor(phase, ec); ok && id != "" {
|
||||
resolved.ids = []string{id}
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Err() error {
|
||||
return resolved.err
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Verb() Verb {
|
||||
return resolved.verb
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ type resolvedResourceWithTarget struct {
|
||||
targetExtractor ResourceIDsExtractor
|
||||
targetIDs []string
|
||||
parentChild bool
|
||||
err error
|
||||
}
|
||||
|
||||
func NewResolvedResourceWithTarget(
|
||||
@@ -45,34 +44,17 @@ func NewResolvedResourceWithTarget(
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec ExtractorContext) {
|
||||
if resolved.sourceExtractor.IsPhase(phase) {
|
||||
ids, err := resolved.sourceExtractor.Fn(ec)
|
||||
if err != nil && phase == PhaseRequest {
|
||||
resolved.err = err
|
||||
return
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
if ids, _ := resolved.sourceExtractor.Fn(ec); len(ids) > 0 {
|
||||
resolved.sourceIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.targetExtractor.IsPhase(phase) {
|
||||
ids, err := resolved.targetExtractor.Fn(ec)
|
||||
if err != nil && phase == PhaseRequest {
|
||||
resolved.err = err
|
||||
return
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
if ids, _ := resolved.targetExtractor.Fn(ec); len(ids) > 0 {
|
||||
resolved.targetIDs = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Err() error {
|
||||
return resolved.err
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Verb() Verb {
|
||||
return resolved.verb
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user