mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-01 10:00:20 +01:00
Compare commits
18 Commits
platform-p
...
fix/array-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e7732243e | ||
|
|
2f952e402f | ||
|
|
a12febca4a | ||
|
|
cb71c9c3f7 | ||
|
|
1cd4ce6509 | ||
|
|
9299c8ab18 | ||
|
|
24749de269 | ||
|
|
39098ec3f4 | ||
|
|
fe554f5c94 | ||
|
|
8a60a041a6 | ||
|
|
541f19c34a | ||
|
|
010db03d6e | ||
|
|
5408acbd8c | ||
|
|
0de6c85f81 | ||
|
|
69ec24fa05 | ||
|
|
539d732b65 | ||
|
|
843d5fb199 | ||
|
|
fabdfb8cc1 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -51,7 +51,6 @@ jobs:
|
||||
- alerts
|
||||
- ingestionkeys
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -85,12 +85,10 @@ sqlstore:
|
||||
sqlite:
|
||||
# The path to the SQLite database file.
|
||||
path: /var/lib/signoz/signoz.db
|
||||
# The journal mode for the sqlite database. Supported values: delete, wal.
|
||||
# Mode is the mode to use for the sqlite database.
|
||||
mode: delete
|
||||
# The timeout for the sqlite database to wait for a lock.
|
||||
# BusyTimeout is the timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
|
||||
transaction_mode: deferred
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
@@ -354,13 +352,3 @@ identn:
|
||||
impersonation:
|
||||
# toggle impersonation identN, when enabled, all requests will impersonate the root user
|
||||
enabled: false
|
||||
|
||||
##################### Service Account #####################
|
||||
serviceaccount:
|
||||
email:
|
||||
# email domain for the service account principal
|
||||
domain: signozserviceaccount.com
|
||||
|
||||
analytics:
|
||||
# toggle service account analytics
|
||||
enabled: true
|
||||
|
||||
@@ -2447,7 +2447,7 @@ components:
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
ServiceaccounttypesFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
@@ -2457,6 +2457,8 @@ components:
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -2469,6 +2471,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
@@ -2496,68 +2499,25 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- status
|
||||
- orgId
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccountRole:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
- role
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccountWithRoles:
|
||||
properties:
|
||||
createdAt:
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
@@ -2568,10 +2528,9 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
serviceAccountRoles:
|
||||
roles:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
nullable: true
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -2582,9 +2541,10 @@ components:
|
||||
- id
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgId
|
||||
- serviceAccountRoles
|
||||
- deletedAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
@@ -2597,6 +2557,28 @@ components:
|
||||
- name
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- roles
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccountStatus:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -2720,6 +2702,43 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesGettableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
createdByUser:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
expiresAt:
|
||||
format: int64
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
lastUsed:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
revoked:
|
||||
type: boolean
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
updatedByUser:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesIdentifiable:
|
||||
properties:
|
||||
id:
|
||||
@@ -2774,6 +2793,16 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesPostableAPIKey:
|
||||
properties:
|
||||
expiresInDays:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableBulkInviteRequest:
|
||||
properties:
|
||||
invites:
|
||||
@@ -2834,6 +2863,33 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesStorableAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
revoked:
|
||||
type: boolean
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesUpdatableUser:
|
||||
properties:
|
||||
displayName:
|
||||
@@ -4910,6 +4966,222 @@ paths:
|
||||
summary: Update org preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v1/pats:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all api keys
|
||||
operationId: ListAPIKeys
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List api keys
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an api key
|
||||
operationId: CreateAPIKey
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableAPIKey'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesGettableAPIKey'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create api key
|
||||
tags:
|
||||
- users
|
||||
/api/v1/pats/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes an api key
|
||||
operationId: RevokeAPIKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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: Revoke api key
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an api key
|
||||
operationId: UpdateAPIKey
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesStorableAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
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: Update api key
|
||||
tags:
|
||||
- users
|
||||
/api/v1/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -5684,7 +5956,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -5738,7 +6010,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
@@ -5803,7 +6075,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesGettableFactorAPIKey'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesFactorAPIKey'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -6026,71 +6298,11 @@ paths:
|
||||
summary: Updates a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles:
|
||||
get:
|
||||
/api/v1/service_accounts/{id}/status:
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint gets all the roles for the existing service account
|
||||
operationId: GetServiceAccountRoles
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"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: Gets service account roles
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
description: This endpoint updates an existing service account status
|
||||
operationId: UpdateServiceAccountStatus
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -6101,22 +6313,14 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableServiceAccountStatus'
|
||||
responses:
|
||||
"201":
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
@@ -6135,89 +6339,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: rid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/me:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets my service account
|
||||
operationId: GetMyServiceAccount
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
@@ -6230,38 +6351,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Gets my service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint gets my service account
|
||||
operationId: UpdateMyServiceAccount
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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
|
||||
summary: Updates my service account
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Updates a service account status
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/user:
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package otlphttpauditor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
spb "google.golang.org/genproto/googleapis/rpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
maxHTTPResponseReadBytes int64 = 64 * 1024
|
||||
protobufContentType string = "application/x-protobuf"
|
||||
)
|
||||
|
||||
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
|
||||
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
|
||||
|
||||
request, err := provider.marshaler.MarshalLogs(logs)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
|
||||
}
|
||||
|
||||
if err := provider.send(ctx, request); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Posts a protobuf-encoded OTLP request to the configured endpoint.
|
||||
// Retries are handled by the underlying heimdall HTTP client.
|
||||
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlphttpexporter/otlp.go
|
||||
func (provider *provider) send(ctx context.Context, body []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.config.OTLPHTTP.Endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", protobufContentType)
|
||||
|
||||
res, err := provider.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_, _ = io.CopyN(io.Discard, res.Body, maxHTTPResponseReadBytes)
|
||||
res.Body.Close()
|
||||
}()
|
||||
|
||||
if res.StatusCode >= 200 && res.StatusCode <= 299 {
|
||||
provider.onSuccess(ctx, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.onErr(res)
|
||||
}
|
||||
|
||||
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L403.
|
||||
func (provider *provider) onSuccess(ctx context.Context, res *http.Response) {
|
||||
resBytes, err := readResponseBody(res)
|
||||
if err != nil || resBytes == nil {
|
||||
return
|
||||
}
|
||||
|
||||
exportResponse := &collogspb.ExportLogsServiceResponse{}
|
||||
if err := proto.Unmarshal(resBytes, exportResponse); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ps := exportResponse.GetPartialSuccess()
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if ps.GetErrorMessage() != "" || ps.GetRejectedLogRecords() != 0 {
|
||||
provider.settings.Logger().WarnContext(ctx, "partial success response", slog.String("message", ps.GetErrorMessage()), slog.Int64("dropped_log_records", ps.GetRejectedLogRecords()))
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) onErr(res *http.Response) error {
|
||||
status := readResponseStatus(res)
|
||||
|
||||
if status != nil {
|
||||
return errors.Newf(errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "request to %s responded with status code %d, Message=%s, Details=%v", provider.config.OTLPHTTP.Endpoint.String(), res.StatusCode, status.Message, status.Details)
|
||||
}
|
||||
|
||||
return errors.Newf(errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "request to %s responded with status code %d", provider.config.OTLPHTTP.Endpoint.String(), res.StatusCode)
|
||||
}
|
||||
|
||||
// Reads at most maxHTTPResponseReadBytes from the response body.
|
||||
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L275.
|
||||
func readResponseBody(resp *http.Response) ([]byte, error) {
|
||||
if resp.ContentLength == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
maxRead := resp.ContentLength
|
||||
if maxRead == -1 || maxRead > maxHTTPResponseReadBytes {
|
||||
maxRead = maxHTTPResponseReadBytes
|
||||
}
|
||||
|
||||
protoBytes := make([]byte, maxRead)
|
||||
n, err := io.ReadFull(resp.Body, protoBytes)
|
||||
if n == 0 && (err == nil || errors.Is(err, io.EOF)) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return protoBytes[:n], nil
|
||||
}
|
||||
|
||||
// Decodes a protobuf-encoded Status from 4xx/5xx response bodies. Returns nil if the response is empty or cannot be decoded.
|
||||
// Ref: https://github.com/open-telemetry/opentelemetry-collector/blob/01b07fcbb7a253bd996c290dcae6166e71d13732/exporter/otlphttpexporter/otlp.go#L310.
|
||||
func readResponseStatus(resp *http.Response) *spb.Status {
|
||||
if resp.StatusCode < 400 || resp.StatusCode > 599 {
|
||||
return nil
|
||||
}
|
||||
|
||||
respBytes, err := readResponseBody(resp)
|
||||
if err != nil || respBytes == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
respStatus := &spb.Status{}
|
||||
if err := proto.Unmarshal(respBytes, respStatus); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return respStatus
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package otlphttpauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
client "github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
)
|
||||
|
||||
var _ auditor.Auditor = (*provider)(nil)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
config auditor.Config
|
||||
licensing licensing.Licensing
|
||||
build version.Build
|
||||
server *auditorserver.Server
|
||||
marshaler plog.ProtoMarshaler
|
||||
httpClient *client.Client
|
||||
}
|
||||
|
||||
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("otlphttp"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
|
||||
return newProvider(ctx, providerSettings, config, licensing, build)
|
||||
})
|
||||
}
|
||||
|
||||
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/otlphttpauditor")
|
||||
|
||||
httpClient, err := client.New(
|
||||
settings.Logger(),
|
||||
providerSettings.TracerProvider,
|
||||
providerSettings.MeterProvider,
|
||||
client.WithTimeout(config.OTLPHTTP.Timeout),
|
||||
client.WithRetryCount(retryCountFromConfig(config.OTLPHTTP.Retry)),
|
||||
retrierOption(config.OTLPHTTP.Retry),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: settings,
|
||||
config: config,
|
||||
licensing: licensing,
|
||||
build: build,
|
||||
marshaler: plog.ProtoMarshaler{},
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
server, err := auditorserver.New(settings,
|
||||
auditorserver.Config{
|
||||
BufferSize: config.BufferSize,
|
||||
BatchSize: config.BatchSize,
|
||||
FlushInterval: config.FlushInterval,
|
||||
},
|
||||
provider.export,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider.server = server
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
return provider.server.Start(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
|
||||
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
|
||||
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
provider.server.Add(ctx, event)
|
||||
}
|
||||
|
||||
func (provider *provider) Healthy() <-chan struct{} {
|
||||
return provider.server.Healthy()
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
return provider.server.Stop(ctx)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package otlphttpauditor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
client "github.com/SigNoz/signoz/pkg/http/client"
|
||||
)
|
||||
|
||||
// retrier implements client.Retriable with exponential backoff
|
||||
// derived from auditor.RetryConfig.
|
||||
type retrier struct {
|
||||
initialInterval time.Duration
|
||||
maxInterval time.Duration
|
||||
}
|
||||
|
||||
func newRetrier(cfg auditor.RetryConfig) *retrier {
|
||||
return &retrier{
|
||||
initialInterval: cfg.InitialInterval,
|
||||
maxInterval: cfg.MaxInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// NextInterval returns the backoff duration for the given retry attempt.
|
||||
// Uses exponential backoff: initialInterval * 2^retry, capped at maxInterval.
|
||||
func (r *retrier) NextInterval(retry int) time.Duration {
|
||||
interval := r.initialInterval
|
||||
for range retry {
|
||||
interval *= 2
|
||||
}
|
||||
return min(interval, r.maxInterval)
|
||||
}
|
||||
|
||||
func retrierOption(cfg auditor.RetryConfig) client.Option {
|
||||
return client.WithRetriable(newRetrier(cfg))
|
||||
}
|
||||
|
||||
func retryCountFromConfig(cfg auditor.RetryConfig) int {
|
||||
if !cfg.Enabled || cfg.MaxElapsedTime <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
elapsed := time.Duration(0)
|
||||
interval := cfg.InitialInterval
|
||||
for elapsed < cfg.MaxElapsedTime {
|
||||
elapsed += interval
|
||||
interval = min(interval*2, cfg.MaxInterval)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -34,22 +34,9 @@ func (server *Server) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
|
||||
subject := ""
|
||||
switch claims.Principal {
|
||||
case authtypes.PrincipalUser:
|
||||
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = user
|
||||
case authtypes.PrincipalServiceAccount:
|
||||
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = serviceAccount
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)
|
||||
|
||||
@@ -213,8 +213,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -49,7 +50,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't provision PAT for cloud integration:",
|
||||
@@ -109,44 +110,84 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
ah.Respond(w, result)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
integrationPATName := fmt.Sprintf("%s", cloudProvider)
|
||||
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
|
||||
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
|
||||
|
||||
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
if apiErr != nil {
|
||||
return "", apiErr
|
||||
}
|
||||
|
||||
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
|
||||
orgIdUUID, err := valuer.NewUUID(orgId)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't parse orgId: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't list PATs: %w", err,
|
||||
))
|
||||
}
|
||||
for _, p := range allPats {
|
||||
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
|
||||
return p.Token, nil
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "no PAT found for cloud integration, creating a new one",
|
||||
"cloud_provider", cloudProvider,
|
||||
)
|
||||
|
||||
newPAT, err := types.NewStorableAPIKey(
|
||||
integrationPATName,
|
||||
integrationUser.ID,
|
||||
types.RoleViewer,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
|
||||
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
return factorAPIKey.Key, nil
|
||||
return newPAT.Token, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Context, orgId valuer.UUID) (*serviceaccounttypes.ServiceAccount, *basemodel.ApiError) {
|
||||
domain := ah.Signoz.Modules.ServiceAccount.Config().Email.Domain
|
||||
cloudIntegrationServiceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgId)
|
||||
cloudIntegrationServiceAccount, err := ah.Signoz.Modules.ServiceAccount.GetOrCreate(ctx, orgId, cloudIntegrationServiceAccount)
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
}
|
||||
|
||||
return cloudIntegrationServiceAccount, nil
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
|
||||
ctx,
|
||||
cloudIntegrationUser,
|
||||
user.WithFactorPassword(password),
|
||||
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
}
|
||||
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
|
||||
@@ -101,22 +101,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
// Don't redirect to onboarding if workspace has issues (blocked, suspended, or restricted)
|
||||
// User needs access to settings/billing to fix payment issues
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock;
|
||||
const isWorkspaceSuspended = activeLicense?.state === LicenseState.DEFAULTED;
|
||||
const isWorkspaceAccessRestricted =
|
||||
activeLicense?.state === LicenseState.TERMINATED ||
|
||||
activeLicense?.state === LicenseState.EXPIRED ||
|
||||
activeLicense?.state === LicenseState.CANCELLED;
|
||||
|
||||
const hasWorkspaceIssue =
|
||||
isWorkspaceBlocked || isWorkspaceSuspended || isWorkspaceAccessRestricted;
|
||||
|
||||
if (hasWorkspaceIssue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
if (
|
||||
isFirstUser &&
|
||||
@@ -135,36 +119,40 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
orgPreferences,
|
||||
usersData,
|
||||
pathname,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.state,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceBlocked = useCallback((): void => {
|
||||
const navigateToWorkSpaceBlocked = (route: any): void => {
|
||||
const { path } = route;
|
||||
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
(path === ROUTES.SETTINGS ||
|
||||
path === ROUTES.ORG_SETTINGS ||
|
||||
path === ROUTES.MEMBERS_SETTINGS ||
|
||||
path === ROUTES.BILLING ||
|
||||
path === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname &&
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
path &&
|
||||
path !== ROUTES.WORKSPACE_LOCKED &&
|
||||
!isRouteEnabledForWorkspaceBlockedState
|
||||
) {
|
||||
history.push(ROUTES.WORKSPACE_LOCKED);
|
||||
}
|
||||
}, [isAdmin, pathname]);
|
||||
};
|
||||
|
||||
const navigateToWorkSpaceAccessRestricted = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
|
||||
const navigateToWorkSpaceAccessRestricted = (route: any): void => {
|
||||
const { path } = route;
|
||||
|
||||
if (path && path !== ROUTES.WORKSPACE_ACCESS_RESTRICTED) {
|
||||
history.push(ROUTES.WORKSPACE_ACCESS_RESTRICTED);
|
||||
}
|
||||
}, [pathname]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
|
||||
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
|
||||
const isExpired = activeLicense.state === LicenseState.EXPIRED;
|
||||
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
|
||||
@@ -173,53 +161,61 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
|
||||
const { platform } = activeLicense;
|
||||
|
||||
if (isWorkspaceAccessRestricted && platform === LicensePlatform.CLOUD) {
|
||||
navigateToWorkSpaceAccessRestricted();
|
||||
if (
|
||||
isWorkspaceAccessRestricted &&
|
||||
platform === LicensePlatform.CLOUD &&
|
||||
currentRoute
|
||||
) {
|
||||
navigateToWorkSpaceAccessRestricted(currentRoute);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
activeLicense,
|
||||
navigateToWorkSpaceAccessRestricted,
|
||||
]);
|
||||
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense) {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
|
||||
|
||||
if (
|
||||
shouldBlockWorkspace &&
|
||||
currentRoute &&
|
||||
activeLicense?.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceBlocked();
|
||||
navigateToWorkSpaceBlocked(currentRoute);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isFetchingActiveLicense,
|
||||
trialInfo?.workSpaceBlock,
|
||||
activeLicense?.platform,
|
||||
navigateToWorkSpaceBlocked,
|
||||
mapRoutes,
|
||||
pathname,
|
||||
]);
|
||||
|
||||
const navigateToWorkSpaceSuspended = useCallback((): void => {
|
||||
if (pathname && pathname !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
const navigateToWorkSpaceSuspended = (route: any): void => {
|
||||
const { path } = route;
|
||||
|
||||
if (path && path !== ROUTES.WORKSPACE_SUSPENDED) {
|
||||
history.push(ROUTES.WORKSPACE_SUSPENDED);
|
||||
}
|
||||
}, [pathname]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingActiveLicense && activeLicense) {
|
||||
const currentRoute = mapRoutes.get('current');
|
||||
const shouldSuspendWorkspace =
|
||||
activeLicense.state === LicenseState.DEFAULTED;
|
||||
|
||||
if (
|
||||
shouldSuspendWorkspace &&
|
||||
currentRoute &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
navigateToWorkSpaceSuspended();
|
||||
navigateToWorkSpaceSuspended(currentRoute);
|
||||
}
|
||||
}
|
||||
}, [isFetchingActiveLicense, activeLicense, navigateToWorkSpaceSuspended]);
|
||||
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (org && org.length > 0 && org[0].id !== undefined) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -157,6 +157,10 @@ export const IngestionSettings = Loadable(
|
||||
() => import(/* webpackChunkName: "Ingestion Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const APIKeys = Loadable(
|
||||
() => import(/* webpackChunkName: "All Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const MySettings = Loadable(
|
||||
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
@@ -513,7 +513,6 @@ export const oldRoutes = [
|
||||
'/logs-save-views',
|
||||
'/traces-save-views',
|
||||
'/settings/access-tokens',
|
||||
'/settings/api-keys',
|
||||
'/messaging-queues',
|
||||
'/alerts/edit',
|
||||
];
|
||||
@@ -524,8 +523,7 @@ export const oldNewRoutesMapping: Record<string, string> = {
|
||||
'/logs-explorer/live': '/logs/logs-explorer/live',
|
||||
'/logs-save-views': '/logs/saved-views',
|
||||
'/traces-save-views': '/traces/saved-views',
|
||||
'/settings/access-tokens': '/settings/service-accounts',
|
||||
'/settings/api-keys': '/settings/service-accounts',
|
||||
'/settings/access-tokens': '/settings/api-keys',
|
||||
'/messaging-queues': '/messaging-queues/overview',
|
||||
'/alerts/edit': '/alerts/overview',
|
||||
};
|
||||
|
||||
@@ -23,15 +23,9 @@ import type {
|
||||
CreateServiceAccount201,
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
ListServiceAccountKeysPathParameters,
|
||||
ListServiceAccounts200,
|
||||
@@ -39,10 +33,12 @@ import type {
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesUpdatableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountDTO,
|
||||
ServiceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
UpdateServiceAccountKeyPathParameters,
|
||||
UpdateServiceAccountPathParameters,
|
||||
UpdateServiceAccountStatusPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -403,13 +399,13 @@ export const invalidateGetServiceAccount = async (
|
||||
*/
|
||||
export const updateServiceAccount = (
|
||||
{ id }: UpdateServiceAccountPathParameters,
|
||||
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
serviceaccounttypesUpdatableServiceAccountDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountDTO,
|
||||
data: serviceaccounttypesUpdatableServiceAccountDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -422,7 +418,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -431,7 +427,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -448,7 +444,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -462,7 +458,7 @@ export const getUpdateServiceAccountMutationOptions = <
|
||||
export type UpdateServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccount>>
|
||||
>;
|
||||
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
export type UpdateServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -477,7 +473,7 @@ export const useUpdateServiceAccount = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -486,7 +482,7 @@ export const useUpdateServiceAccount = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateServiceAccountPathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -875,150 +871,44 @@ export const useUpdateServiceAccountKey = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets all the roles for the existing service account
|
||||
* @summary Gets service account roles
|
||||
* This endpoint updates an existing service account status
|
||||
* @summary Updates a service account status
|
||||
*/
|
||||
export const getServiceAccountRoles = (
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
signal?: AbortSignal,
|
||||
export const updateServiceAccountStatus = (
|
||||
{ id }: UpdateServiceAccountStatusPathParameters,
|
||||
serviceaccounttypesUpdatableServiceAccountStatusDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccountRoles200>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRolesQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolesPathParameters) => {
|
||||
return [`/api/v1/service_accounts/${id}/roles`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRolesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRolesQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>
|
||||
> = ({ signal }) => getServiceAccountRoles({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRolesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>
|
||||
>;
|
||||
export type GetServiceAccountRolesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Gets service account roles
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRoles<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRoles>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRolesQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Gets service account roles
|
||||
*/
|
||||
export const invalidateGetServiceAccountRoles = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolesPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRolesQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/status`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
data: serviceaccounttypesUpdatableServiceAccountStatusDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
export const getUpdateServiceAccountStatusMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const mutationKey = ['updateServiceAccountStatus'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1028,299 +918,52 @@ export const getCreateServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
return updateServiceAccountStatus(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
export type UpdateServiceAccountStatusMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
export type CreateServiceAccountRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
export type UpdateServiceAccountStatusMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
export type UpdateServiceAccountStatusMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account role
|
||||
* @summary Updates a service account status
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
export const useUpdateServiceAccountStatus = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: UpdateServiceAccountStatusPathParameters;
|
||||
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateServiceAccountRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = ({
|
||||
id,
|
||||
rid,
|
||||
}: DeleteServiceAccountRolePathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getDeleteServiceAccountRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
export const getMyServiceAccount = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMyServiceAccount200>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMyServiceAccountQueryKey = () => {
|
||||
return [`/api/v1/service_accounts/me`] as const;
|
||||
};
|
||||
|
||||
export const getGetMyServiceAccountQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMyServiceAccountQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>
|
||||
> = ({ signal }) => getMyServiceAccount(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMyServiceAccountQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>
|
||||
>;
|
||||
export type GetMyServiceAccountQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
|
||||
export function useGetMyServiceAccount<
|
||||
TData = Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMyServiceAccount>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMyServiceAccountQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Gets my service account
|
||||
*/
|
||||
export const invalidateGetMyServiceAccount = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMyServiceAccountQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Updates my service account
|
||||
*/
|
||||
export const updateMyServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMyServiceAccountMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMyServiceAccount'];
|
||||
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 updateMyServiceAccount>>,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateMyServiceAccount(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMyServiceAccountMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>
|
||||
>;
|
||||
export type UpdateMyServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
|
||||
export type UpdateMyServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Updates my service account
|
||||
*/
|
||||
export const useUpdateMyServiceAccount = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMyServiceAccount>>,
|
||||
TError,
|
||||
{ data: BodyType<ServiceaccounttypesPostableServiceAccountDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateMyServiceAccountMutationOptions(options);
|
||||
const mutationOptions = getUpdateServiceAccountStatusMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -2843,7 +2843,7 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
state: RulestatehistorytypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2858,6 +2858,10 @@ export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2905,14 +2909,15 @@ export interface ServiceaccounttypesPostableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -2921,65 +2926,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
role: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
deletedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2998,9 +2949,8 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
serviceAccountRoles: ServiceaccounttypesServiceAccountRoleDTO[] | null;
|
||||
roles: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3024,6 +2974,28 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesUpdatableServiceAccountStatusDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
}
|
||||
|
||||
export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
@@ -3167,6 +3139,63 @@ export interface TypesDeprecatedUserDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesGettableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
createdByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
expiresAt?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
lastUsed?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
revoked?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
updatedByUser?: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TypesIdentifiableDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3249,6 +3278,22 @@ export interface TypesOrganizationDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesPostableAPIKeyDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
expiresInDays?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableBulkInviteRequestDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -3328,6 +3373,51 @@ export interface TypesResetPasswordTokenDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesStorableAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
revoked?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TypesUpdatableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3866,6 +3956,31 @@ export type GetOrgPreference200 = {
|
||||
export type UpdateOrgPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListAPIKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesGettableAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAPIKey201 = {
|
||||
data: TypesGettableAPIKeyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RevokeAPIKeyPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateAPIKeyPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
@@ -3970,7 +4085,7 @@ export type GetServiceAccountPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
data: ServiceaccounttypesServiceAccountDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3987,7 +4102,7 @@ export type ListServiceAccountKeys200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
data: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4013,44 +4128,9 @@ export type UpdateServiceAccountKeyPathParameters = {
|
||||
id: string;
|
||||
fid: string;
|
||||
};
|
||||
export type GetServiceAccountRolesPathParameters = {
|
||||
export type UpdateServiceAccountStatusPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
data: AuthtypesRoleDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
export type GetMyServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
ChangePasswordPathParameters,
|
||||
CreateAPIKey201,
|
||||
CreateInvite201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
@@ -35,19 +36,24 @@ import type {
|
||||
GetUserPathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListAPIKeys200,
|
||||
ListUsers200,
|
||||
ListUsersDeprecated200,
|
||||
RemoveUserRoleByUserIDAndRoleIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeAPIKeyPathParameters,
|
||||
SetRoleByUserIDPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesDeprecatedUserDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableBulkInviteRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesPostableRoleDTO,
|
||||
TypesStorableAPIKeyDTO,
|
||||
TypesUpdatableUserDTO,
|
||||
UpdateAPIKeyPathParameters,
|
||||
UpdateUserDeprecated200,
|
||||
UpdateUserDeprecatedPathParameters,
|
||||
UpdateUserPathParameters,
|
||||
@@ -422,6 +428,349 @@ export const useCreateBulkInvite = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all api keys
|
||||
* @summary List api keys
|
||||
*/
|
||||
export const listAPIKeys = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListAPIKeys200>({
|
||||
url: `/api/v1/pats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAPIKeysQueryKey = () => {
|
||||
return [`/api/v1/pats`] as const;
|
||||
};
|
||||
|
||||
export const getListAPIKeysQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListAPIKeysQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAPIKeys>>> = ({
|
||||
signal,
|
||||
}) => listAPIKeys(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAPIKeysQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>
|
||||
>;
|
||||
export type ListAPIKeysQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List api keys
|
||||
*/
|
||||
|
||||
export function useListAPIKeys<
|
||||
TData = Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAPIKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAPIKeysQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List api keys
|
||||
*/
|
||||
export const invalidateListAPIKeys = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListAPIKeysQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates an api key
|
||||
* @summary Create api key
|
||||
*/
|
||||
export const createAPIKey = (
|
||||
typesPostableAPIKeyDTO: BodyType<TypesPostableAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAPIKey201>({
|
||||
url: `/api/v1/pats`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableAPIKeyDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createAPIKey'];
|
||||
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 createAPIKey>>,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createAPIKey(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createAPIKey>>
|
||||
>;
|
||||
export type CreateAPIKeyMutationBody = BodyType<TypesPostableAPIKeyDTO>;
|
||||
export type CreateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create api key
|
||||
*/
|
||||
export const useCreateAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createAPIKey>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAPIKeyDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes an api key
|
||||
* @summary Revoke api key
|
||||
*/
|
||||
export const revokeAPIKey = ({ id }: RevokeAPIKeyPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/pats/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getRevokeAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['revokeAPIKey'];
|
||||
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 revokeAPIKey>>,
|
||||
{ pathParams: RevokeAPIKeyPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return revokeAPIKey(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RevokeAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>
|
||||
>;
|
||||
|
||||
export type RevokeAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Revoke api key
|
||||
*/
|
||||
export const useRevokeAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof revokeAPIKey>>,
|
||||
TError,
|
||||
{ pathParams: RevokeAPIKeyPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getRevokeAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an api key
|
||||
* @summary Update api key
|
||||
*/
|
||||
export const updateAPIKey = (
|
||||
{ id }: UpdateAPIKeyPathParameters,
|
||||
typesStorableAPIKeyDTO: BodyType<TypesStorableAPIKeyDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/pats/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesStorableAPIKeyDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateAPIKeyMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateAPIKey'];
|
||||
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 updateAPIKey>>,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateAPIKey(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateAPIKeyMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>
|
||||
>;
|
||||
export type UpdateAPIKeyMutationBody = BodyType<TypesStorableAPIKeyDTO>;
|
||||
export type UpdateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update api key
|
||||
*/
|
||||
export const useUpdateAPIKey = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateAPIKey>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAPIKeyPathParameters;
|
||||
data: BodyType<TypesStorableAPIKeyDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateAPIKeyMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint resets the password by token
|
||||
* @summary Reset password
|
||||
|
||||
28
frontend/src/api/v1/pats/create.ts
Normal file
28
frontend/src/api/v1/pats/create.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
APIKeyProps,
|
||||
CreateAPIKeyProps,
|
||||
CreatePayloadProps,
|
||||
} from 'types/api/pat/types';
|
||||
|
||||
const create = async (
|
||||
props: CreateAPIKeyProps,
|
||||
): Promise<SuccessResponseV2<APIKeyProps>> => {
|
||||
try {
|
||||
const response = await axios.post<CreatePayloadProps>('/pats', {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
19
frontend/src/api/v1/pats/delete.ts
Normal file
19
frontend/src/api/v1/pats/delete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
const deleteAPIKey = async (id: string): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete(`/pats/${id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteAPIKey;
|
||||
20
frontend/src/api/v1/pats/list.ts
Normal file
20
frontend/src/api/v1/pats/list.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { AllAPIKeyProps, APIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
const list = async (): Promise<SuccessResponseV2<APIKeyProps[]>> => {
|
||||
try {
|
||||
const response = await axios.get<AllAPIKeyProps>('/pats');
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default list;
|
||||
24
frontend/src/api/v1/pats/update.ts
Normal file
24
frontend/src/api/v1/pats/update.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdateAPIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
const updateAPIKey = async (
|
||||
props: UpdateAPIKeyProps,
|
||||
): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/pats/${props.id}`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateAPIKey;
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal(): JSX.Element {
|
||||
@@ -37,6 +41,8 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +70,13 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
function handleClose(): void {
|
||||
reset();
|
||||
@@ -74,6 +87,8 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -119,6 +134,68 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
<p className="create-sa-form__error">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-email">Email Address</label>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Email Address is required',
|
||||
pattern: {
|
||||
value: EMAIL_REGEX,
|
||||
message: 'Please enter a valid email address',
|
||||
},
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="create-sa-form__error">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="create-sa-form__helper">
|
||||
Used only for notifications about this service account. It is not used for
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-roles">Roles</label>
|
||||
<Controller
|
||||
name="roles"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): string | true =>
|
||||
value.length > 0 || 'At least one role is required',
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
placeholder="Select roles"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.roles && (
|
||||
<p className="create-sa-form__error">{errors.roles.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -11,6 +12,7 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
@@ -25,6 +27,9 @@ describe('CreateServiceAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
@@ -43,11 +48,38 @@ describe('CreateServiceAccountModal', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button remains disabled when email is invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'deploy@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
@@ -84,6 +116,13 @@ describe('CreateServiceAccountModal', () => {
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'dupe@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
@@ -125,4 +164,16 @@ describe('CreateServiceAccountModal', () => {
|
||||
|
||||
await screen.findByText('Name is required');
|
||||
});
|
||||
|
||||
it('shows "Please enter a valid email address" for a malformed email', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await screen.findByText('Please enter a valid email address');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useRoles(): {
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.id ?? '',
|
||||
value: role.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { PowerOff, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
invalidateListServiceAccounts,
|
||||
useDeleteServiceAccount,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -17,14 +17,14 @@ import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
function DeleteAccountModal(): JSX.Element {
|
||||
function DisableAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
const [isDisableOpen, setIsDisableOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DISABLE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const open = !!isDeleteOpen && !!accountId;
|
||||
const open = !!isDisableOpen && !!accountId;
|
||||
|
||||
const cachedAccount = accountId
|
||||
? queryClient.getQueryData<{
|
||||
@@ -34,13 +34,13 @@ function DeleteAccountModal(): JSX.Element {
|
||||
const accountName = cachedAccount?.data?.name;
|
||||
|
||||
const {
|
||||
mutate: deleteAccount,
|
||||
isLoading: isDeleting,
|
||||
} = useDeleteServiceAccount({
|
||||
mutate: updateStatus,
|
||||
isLoading: isDisabling,
|
||||
} = useUpdateServiceAccountStatus({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Service account deleted', { richColors: true });
|
||||
await setIsDeleteOpen(null);
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
await setIsDisableOpen(null);
|
||||
await setAccountId(null);
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
@@ -48,7 +48,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to delete service account';
|
||||
)?.getErrorMessage() || 'Failed to disable service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
@@ -58,13 +58,14 @@ function DeleteAccountModal(): JSX.Element {
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
deleteAccount({
|
||||
updateStatus({
|
||||
pathParams: { id: accountId },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setIsDeleteOpen(null);
|
||||
setIsDisableOpen(null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -75,18 +76,17 @@ function DeleteAccountModal(): JSX.Element {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
title={`Delete service account ${accountName ?? ''}?`}
|
||||
title={`Disable service account ${accountName ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
className="alert-dialog sa-disable-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
cannot be undone. All keys associated with this service account will be
|
||||
permanently removed.
|
||||
<p className="sa-disable-dialog__body">
|
||||
Disabling this service account will revoke access for all its keys. Any
|
||||
systems using this account will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="sa-delete-dialog__footer">
|
||||
<DialogFooter className="sa-disable-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
@@ -95,15 +95,15 @@ function DeleteAccountModal(): JSX.Element {
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isDeleting}
|
||||
loading={isDisabling}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
<PowerOff size={12} />
|
||||
Disable
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteAccountModal;
|
||||
export default DisableAccountModal;
|
||||
@@ -6,7 +6,7 @@ import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
@@ -17,7 +17,7 @@ export interface EditKeyFormProps {
|
||||
register: UseFormRegister<FormValues>;
|
||||
control: Control<FormValues>;
|
||||
expiryMode: ExpiryMode;
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
isSaving: boolean;
|
||||
isDirty: boolean;
|
||||
onSubmit: () => void;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -27,7 +27,7 @@ import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
export interface EditKeyModalProps {
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
}
|
||||
|
||||
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/button';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
@@ -14,7 +14,7 @@ import RevokeKeyModal from './RevokeKeyModal';
|
||||
import { formatLastObservedAt } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
keys: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
currentPage: number;
|
||||
@@ -44,7 +44,7 @@ function buildColumns({
|
||||
isDisabled,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
|
||||
return [
|
||||
{
|
||||
title: 'Name',
|
||||
@@ -183,7 +183,7 @@ function KeysTab({
|
||||
return (
|
||||
<>
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceaccounttypesGettableFactorAPIKeyDTO>
|
||||
<Table<ServiceaccounttypesFactorAPIKeyDTO>
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="id"
|
||||
|
||||
@@ -9,9 +9,6 @@ import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import SaveErrorItem from './SaveErrorItem';
|
||||
import type { SaveError } from './utils';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
@@ -24,7 +21,6 @@ interface OverviewTabProps {
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
saveErrors?: SaveError[];
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
@@ -39,7 +35,6 @@ function OverviewTab({
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
saveErrors = [],
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -97,14 +92,11 @@ function OverviewTab({
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
@@ -134,13 +126,9 @@ function OverviewTab({
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : account.status?.toUpperCase() === 'DELETED' ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
{account.status ? account.status.toUpperCase() : 'UNKNOWN'}
|
||||
DISABLED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,19 +143,6 @@ function OverviewTab({
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveErrors.length > 0 && (
|
||||
<div className="sa-drawer__save-errors">
|
||||
{saveErrors.map(({ context, apiError, onRetry }) => (
|
||||
<SaveErrorItem
|
||||
key={context}
|
||||
context={context}
|
||||
apiError={apiError}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -64,9 +64,9 @@ function RevokeKeyModal(): JSX.Element {
|
||||
const open = !!revokeKeyId && !!accountId;
|
||||
|
||||
const cachedKeys = accountId
|
||||
? queryClient.getQueryData<{
|
||||
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
}>(getListServiceAccountKeysQueryKey({ id: accountId }))
|
||||
? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>(
|
||||
getListServiceAccountKeysQueryKey({ id: accountId }),
|
||||
)
|
||||
: null;
|
||||
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, ChevronUp, CircleAlert, RotateCw } from '@signozhq/icons';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface SaveErrorItemProps {
|
||||
context: string;
|
||||
apiError: APIError;
|
||||
onRetry?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function SaveErrorItem({
|
||||
context,
|
||||
apiError,
|
||||
onRetry,
|
||||
}: SaveErrorItemProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const ChevronIcon = expanded ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<div className="sa-error-item">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="sa-error-item__header"
|
||||
aria-disabled={isRetrying}
|
||||
onClick={(): void => {
|
||||
if (!isRetrying) {
|
||||
setExpanded((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CircleAlert size={12} className="sa-error-item__icon" />
|
||||
<span className="sa-error-item__title">
|
||||
{isRetrying ? 'Retrying...' : `${context}: ${apiError.getErrorMessage()}`}
|
||||
</span>
|
||||
{onRetry && !isRetrying && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Retry"
|
||||
size="xs"
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
setIsRetrying(true);
|
||||
setExpanded(false);
|
||||
try {
|
||||
await onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCw size={12} color={Color.BG_CHERRY_400} />
|
||||
</Button>
|
||||
)}
|
||||
{!isRetrying && (
|
||||
<ChevronIcon size={14} className="sa-error-item__chevron" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && !isRetrying && (
|
||||
<div className="sa-error-item__body">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveErrorItem;
|
||||
@@ -92,23 +92,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.25rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(136, 136, 136, 0.4);
|
||||
border-radius: 0.125rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(136, 136, 136, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
@@ -256,113 +239,6 @@
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__save-errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-error-item {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
width: 100%;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--callout-error-border);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-cherry-500);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
.error-content {
|
||||
&__summary {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
&__summary-left {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__error-code {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__error-message {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__docs-button {
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
&__message-badge {
|
||||
padding: 0 12px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__message-item {
|
||||
font-size: 11px;
|
||||
padding: 2px 12px 2px 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keys-tab {
|
||||
@@ -553,7 +429,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sa-delete-dialog {
|
||||
.sa-disable-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListServiceAccountsQueryKey,
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
ServiceAccountRow,
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -31,14 +27,12 @@ import {
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import DisableAccountModal from './DisableAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import type { SaveError } from './utils';
|
||||
import { ServiceAccountDrawerTab } from './utils';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
@@ -75,16 +69,12 @@ function ServiceAccountDrawer({
|
||||
SA_QUERY_PARAMS.ADD_KEY,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
const [, setIsDisableOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DISABLE_SA,
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
@@ -103,30 +93,21 @@ function ServiceAccountDrawer({
|
||||
[accountData],
|
||||
);
|
||||
|
||||
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
|
||||
selectedAccountId ?? '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
setKeysPage(1);
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [account?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
|
||||
}, [currentRoles]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
@@ -152,189 +133,51 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
|
||||
const toSaveApiError = useCallback(
|
||||
(err: unknown): APIError =>
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
|
||||
[],
|
||||
);
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateMutateAsync({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName },
|
||||
});
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
}, []);
|
||||
|
||||
const makeRoleRetry = useCallback(
|
||||
(
|
||||
context: string,
|
||||
rawRetry: () => Promise<void>,
|
||||
) => async (): Promise<void> => {
|
||||
try {
|
||||
await rawRetry();
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[toSaveApiError],
|
||||
);
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await applyDiff(localRoles, availableRoles);
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
const roleErrors = failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
|
||||
{
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
return [...rest, ...roleErrors];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
e.context === 'Roles update' ? { ...e, apiError: toSaveApiError(err) } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
refetchAccount();
|
||||
onSuccess({ closeDrawer: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
function handleSave(): void {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setSaveErrors([]);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const namePromise =
|
||||
localName !== (account.name ?? '')
|
||||
? updateMutateAsync({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName },
|
||||
})
|
||||
: Promise.resolve();
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
applyDiff(localRoles, availableRoles),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
|
||||
if (nameResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Name update',
|
||||
apiError: toSaveApiError(nameResult.reason),
|
||||
onRetry: retryNameUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (rolesResult.status === 'rejected') {
|
||||
errors.push({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: retryRolesUpdate,
|
||||
});
|
||||
} else {
|
||||
for (const failure of rolesResult.value) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors);
|
||||
} else {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
onSuccess({ closeDrawer: false });
|
||||
}
|
||||
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRoles,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
applyDiff,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
]);
|
||||
updateAccount({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
});
|
||||
}
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDeleteOpen(null);
|
||||
setIsDisableOpen(null);
|
||||
setIsAddKeyOpen(null);
|
||||
setSelectedAccountId(null);
|
||||
setActiveTab(null);
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
setSaveErrors([]);
|
||||
}, [
|
||||
setSelectedAccountId,
|
||||
setActiveTab,
|
||||
setKeysPage,
|
||||
setEditKeyId,
|
||||
setIsAddKeyOpen,
|
||||
setIsDeleteOpen,
|
||||
setIsDisableOpen,
|
||||
]);
|
||||
|
||||
const drawerContent = (
|
||||
@@ -377,7 +220,7 @@ function ServiceAccountDrawer({
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
setIsAddKeyOpen(true);
|
||||
}}
|
||||
@@ -408,23 +251,22 @@ function ServiceAccountDrawer({
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
onNameChange={setLocalName}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDeleted}
|
||||
isDisabled={isDisabled}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
isDisabled={isDisabled}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
@@ -456,20 +298,20 @@ function ServiceAccountDrawer({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => {
|
||||
setIsDeleteOpen(true);
|
||||
setIsDisableOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
{!isDisabled && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -517,7 +359,7 @@ function ServiceAccountDrawer({
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
<DeleteAccountModal />
|
||||
<DisableAccountModal />
|
||||
|
||||
<AddKeyModal />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -14,16 +14,17 @@ const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
|
||||
|
||||
const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
|
||||
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_abc123',
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
function renderModal(
|
||||
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null = mockKey,
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
|
||||
searchParams: Record<string, string> = {
|
||||
account: 'sa-1',
|
||||
'edit-key': 'key-1',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -14,12 +14,13 @@ const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
|
||||
|
||||
const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_prod_123',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
@@ -27,6 +28,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
key: 'snz_stag_456',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -23,9 +23,7 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
@@ -37,10 +35,10 @@ const activeAccountResponse = {
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const deletedAccountResponse = {
|
||||
const disabledAccountResponse = {
|
||||
...activeAccountResponse,
|
||||
id: 'sa-2',
|
||||
status: 'DELETED',
|
||||
status: 'DISABLED',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -69,23 +67,7 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
@@ -133,6 +115,8 @@ describe('ServiceAccountDrawer', () => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'CI Bot Updated',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
}),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
|
||||
@@ -141,7 +125,6 @@ describe('ServiceAccountDrawer', () => {
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
const roleSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
@@ -149,10 +132,6 @@ describe('ServiceAccountDrawer', () => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
|
||||
roleSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
@@ -167,22 +146,21 @@ describe('ServiceAccountDrawer', () => {
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(roleSpy).toHaveBeenCalledWith(
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '019c24aa-2248-7585-a129-4188b3473c27',
|
||||
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Delete Service Account" opens confirm dialog; confirming sends delete request', async () => {
|
||||
const deleteSpy = jest.fn();
|
||||
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
|
||||
const statusSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) => {
|
||||
deleteSpy();
|
||||
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
|
||||
statusSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
@@ -192,19 +170,19 @@ describe('ServiceAccountDrawer', () => {
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Delete Service Account/i }),
|
||||
screen.getByRole('button', { name: /Disable Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Delete service account CI Bot/i,
|
||||
name: /Disable service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -212,17 +190,14 @@ describe('ServiceAccountDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('deleted account shows read-only name, no Save button, no Delete button', async () => {
|
||||
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: deletedAccountResponse })),
|
||||
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer({ account: 'sa-2' });
|
||||
@@ -233,7 +208,7 @@ describe('ServiceAccountDrawer', () => {
|
||||
screen.queryByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Delete Service Account/i }),
|
||||
screen.queryByRole('button', { name: /Disable Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -273,169 +248,3 @@ describe('ServiceAccountDrawer', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
|
||||
),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('name update failure shows SaveErrorItem with "Name update" context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'name update failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'New Name');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role update failure shows SaveErrorItem with the role name context', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'role assign failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
// Add the signoz-viewer role (which is not currently assigned)
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Role 'signoz-viewer'.*role assign failed/i,
|
||||
undefined,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// First: PUT always fails so the error appears
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'name update failed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Retry Test');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
|
||||
const retryBtn = screen.getByRole('button', { name: /Retry/i });
|
||||
await user.click(retryBtn);
|
||||
|
||||
// Error item should be removed after successful retry
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/Name update.*name update failed/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface SaveError {
|
||||
context: string;
|
||||
apiError: APIError;
|
||||
onRetry: () => Promise<void>;
|
||||
}
|
||||
|
||||
export enum ServiceAccountDrawerTab {
|
||||
Overview = 'overview',
|
||||
|
||||
@@ -8,6 +8,7 @@ const mockActiveAccount: ServiceAccountRow = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
@@ -17,6 +18,7 @@ const mockDisabledAccount: ServiceAccountRow = {
|
||||
id: 'sa-2',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
|
||||
status: 'DISABLED',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
@@ -37,6 +39,7 @@ describe('ServiceAccountsTable', () => {
|
||||
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -46,6 +49,8 @@ describe('ServiceAccountsTable', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the correct account when a row is clicked', async () => {
|
||||
|
||||
@@ -25,6 +25,32 @@ export function NameEmailCell({
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
if (!roles || roles.length === 0) {
|
||||
return <span className="sa-dash">—</span>;
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
const tooltipContent = roles.slice(1).join(', ');
|
||||
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
overlayClassName="sa-tooltip"
|
||||
overlayStyle={{ maxWidth: '600px' }}
|
||||
>
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
if (status?.toUpperCase() === 'ACTIVE') {
|
||||
return (
|
||||
@@ -33,16 +59,9 @@ export function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status?.toUpperCase() === 'DELETED') {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
{status ? status.toUpperCase() : 'UNKNOWN'}
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +98,13 @@ export const columns: ColumnsType<ServiceAccountRow> = [
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 420,
|
||||
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -38,6 +38,7 @@ const ROUTES = {
|
||||
SETTINGS: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
API_KEYS: '/settings/api-keys',
|
||||
INGESTION_SETTINGS: '/settings/ingestion-settings',
|
||||
SOMETHING_WENT_WRONG: '/something-went-wrong',
|
||||
UN_AUTHORIZED: '/un-authorized',
|
||||
|
||||
@@ -248,5 +248,15 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.BILLING),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-api-keys',
|
||||
name: 'Go to Account Settings API Keys',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsAPIKeys],
|
||||
keywords: 'account settings api keys',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.API_KEYS),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
@@ -70,6 +72,7 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsAPIKeys: 'Navigate to API Keys Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
|
||||
685
frontend/src/container/APIKeys/APIKeys.styles.scss
Normal file
685
frontend/src/container/APIKeys/APIKeys.styles.scss
Normal file
@@ -0,0 +1,685 @@
|
||||
.api-key-container {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.api-key-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.api-keys-search-add-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
padding: 16px 0;
|
||||
|
||||
.add-new-api-key-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-row {
|
||||
.ant-table-cell {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
.column-render {
|
||||
margin: 8px 0 !important;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
|
||||
.title-with-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
|
||||
.api-key-data {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.api-key-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
|
||||
background: var(--bg-ink-200);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.visibility-btn {
|
||||
border: 1px solid rgba(113, 144, 249, 0.2);
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0px 8px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #121317;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 8px;
|
||||
|
||||
.api-key-tag {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50px;
|
||||
background: var(--bg-slate-300);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.tag-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
leading-trim: both;
|
||||
text-edge: cap;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: normal;
|
||||
letter-spacing: -0.05px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-created-by {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.api-key-last-used-at {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'cpsp' on, 'case' on;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-expires-in {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--bg-amber-400);
|
||||
|
||||
.dot {
|
||||
background: var(--bg-amber-400);
|
||||
box-shadow: 0px 0px 6px 0px var(--bg-amber-400);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-400);
|
||||
|
||||
.dot {
|
||||
background: var(--bg-cherry-400);
|
||||
box-shadow: 0px 0px 6px 0px var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> a {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'case' on, 'cpsp' on;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-item-active {
|
||||
background-color: var(--bg-robin-500);
|
||||
> a {
|
||||
color: var(--bg-ink-500) !important;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-info-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.user-avatar {
|
||||
background-color: lightslategray;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.user-email {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
background: var(--bg-ink-200);
|
||||
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-access-role {
|
||||
display: flex;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
|
||||
&::before {
|
||||
background-color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: #fff;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-api-key-modal {
|
||||
width: calc(100% - 30px) !important; /* Adjust the 20px as needed */
|
||||
max-width: 384px;
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 16px;
|
||||
background: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0px 16px 28px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-color-picker-trigger {
|
||||
padding: 6px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
.ant-color-picker-color-block {
|
||||
border-radius: 50px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-color-picker-color-block-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 16px;
|
||||
margin: 0;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-cherry-500);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--bg-cherry-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
}
|
||||
|
||||
.expiration-selector {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.newAPIKeyDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyable-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 20px;
|
||||
padding: 0px 12px;
|
||||
background: var(--bg-ink-200, #23262e);
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.api-key-container {
|
||||
.api-key-content {
|
||||
.title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.ant-table-row {
|
||||
.ant-table-cell {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-table-cell {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.column-render {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.title-with-action {
|
||||
.api-key-title {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.copy-key-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-details {
|
||||
border-top: 1px solid var(--bg-vanilla-200);
|
||||
.api-key-tag {
|
||||
background: var(--bg-vanilla-200);
|
||||
.tag-text {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-created-by {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.api-key-last-used-at {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-api-key-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-modal-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
.ant-input {
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
.cancel-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-info-container {
|
||||
.user-email {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-vanilla-200);
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-access-role {
|
||||
.ant-radio-button-wrapper {
|
||||
&.ant-radio-button-wrapper-checked {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
&::before {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&::before {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyable-text {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
99
frontend/src/container/APIKeys/APIKeys.test.tsx
Normal file
99
frontend/src/container/APIKeys/APIKeys.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
createAPIKeyResponse,
|
||||
getAPIKeysResponse,
|
||||
} from 'mocks-server/__mockdata__/apiKeys';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import APIKeys from './APIKeys';
|
||||
|
||||
const apiKeysURL = 'http://localhost/api/v1/pats';
|
||||
|
||||
describe('APIKeys component', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(getAPIKeysResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<APIKeys />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders APIKeys component without crashing', () => {
|
||||
expect(screen.getByText('API Keys')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage API keys for the SigNoz API'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render list of Access Tokens', async () => {
|
||||
server.use(
|
||||
rest.get(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(getAPIKeysResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No Expiry Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-5 of 18 keys')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens add new key modal on button click', async () => {
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
await waitFor(() => {
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes add new key modal on cancel button click', async () => {
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a new key on form submission', async () => {
|
||||
server.use(
|
||||
rest.post(apiKeysURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(createAPIKeyResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('New Key'));
|
||||
|
||||
const createNewKeyBtn = screen.getByRole('button', {
|
||||
name: /Create new key/i,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createNewKeyBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const inputElement = screen.getByPlaceholderText('Enter Key Name');
|
||||
fireEvent.change(inputElement, { target: { value: 'Top Secret' } });
|
||||
fireEvent.click(screen.getByTestId('create-form-admin-role-btn'));
|
||||
fireEvent.click(createNewKeyBtn);
|
||||
});
|
||||
});
|
||||
});
|
||||
874
frontend/src/container/APIKeys/APIKeys.tsx
Normal file
874
frontend/src/container/APIKeys/APIKeys.tsx
Normal file
@@ -0,0 +1,874 @@
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
CollapseProps,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import createAPIKeyApi from 'api/v1/pats/create';
|
||||
import deleteAPIKeyApi from 'api/v1/pats/delete';
|
||||
import updateAPIKeyApi from 'api/v1/pats/update';
|
||||
import cx from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useGetAllAPIKeys } from 'hooks/APIKeys/useGetAllAPIKeys';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
ClipboardEdit,
|
||||
Contact2,
|
||||
Copy,
|
||||
Eye,
|
||||
Minus,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
View,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { APIKeyProps } from 'types/api/pat/types';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import './APIKeys.styles.scss';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const showErrorNotification = (
|
||||
notifications: NotificationInstance,
|
||||
err: APIError,
|
||||
): void => {
|
||||
notifications.error({
|
||||
message: err.getErrorCode(),
|
||||
description: err.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
type ExpiryOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const EXPIRATION_WITHIN_SEVEN_DAYS = 7;
|
||||
|
||||
const API_KEY_EXPIRY_OPTIONS: ExpiryOption[] = [
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '7', label: '1 week' },
|
||||
{ value: '30', label: '1 month' },
|
||||
{ value: '90', label: '3 months' },
|
||||
{ value: '365', label: '1 year' },
|
||||
{ value: '0', label: 'No Expiry' },
|
||||
];
|
||||
|
||||
export const isExpiredToken = (expiryTimestamp: number): boolean => {
|
||||
if (expiryTimestamp === 0) {
|
||||
return false;
|
||||
}
|
||||
const currentTime = dayjs();
|
||||
const tokenExpiresAt = dayjs.unix(expiryTimestamp);
|
||||
return tokenExpiresAt.isBefore(currentTime);
|
||||
};
|
||||
|
||||
export const getDateDifference = (
|
||||
createdTimestamp: number,
|
||||
expiryTimestamp: number,
|
||||
): number => {
|
||||
const differenceInSeconds = Math.abs(expiryTimestamp - createdTimestamp);
|
||||
|
||||
// Convert seconds to days
|
||||
return differenceInSeconds / (60 * 60 * 24);
|
||||
};
|
||||
|
||||
function APIKeys(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [showNewAPIKeyDetails, setShowNewAPIKeyDetails] = useState(false);
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [activeAPIKey, setActiveAPIKey] = useState<APIKeyProps | null>();
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [dataSource, setDataSource] = useState<APIKeyProps[]>([]);
|
||||
const { t } = useTranslation(['apiKeys']);
|
||||
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
|
||||
const handleFormReset = (): void => {
|
||||
editForm.resetFields();
|
||||
createForm.resetFields();
|
||||
};
|
||||
|
||||
const hideDeleteViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const showDeleteModal = (apiKey: APIKeyProps): void => {
|
||||
setActiveAPIKey(apiKey);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const hideEditViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(null);
|
||||
setIsEditModalOpen(false);
|
||||
};
|
||||
|
||||
const hideAddViewModal = (): void => {
|
||||
handleFormReset();
|
||||
setShowNewAPIKeyDetails(false);
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const showEditModal = (apiKey: APIKeyProps): void => {
|
||||
handleFormReset();
|
||||
setActiveAPIKey(apiKey);
|
||||
|
||||
editForm.setFieldsValue({
|
||||
name: apiKey.name,
|
||||
role: apiKey.role || USER_ROLES.VIEWER,
|
||||
});
|
||||
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const showAddModal = (): void => {
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = (): void => {
|
||||
setActiveAPIKey(null);
|
||||
};
|
||||
|
||||
const {
|
||||
data: APIKeys,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch: refetchAPIKeys,
|
||||
error,
|
||||
isError,
|
||||
} = useGetAllAPIKeys();
|
||||
|
||||
useEffect(() => {
|
||||
setActiveAPIKey(APIKeys?.data?.[0]);
|
||||
}, [APIKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(APIKeys?.data || []);
|
||||
}, [APIKeys?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
}
|
||||
}, [error, isError, notifications]);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchValue(e.target.value);
|
||||
const filteredData = APIKeys?.data?.filter(
|
||||
(key: APIKeyProps) =>
|
||||
key &&
|
||||
key.name &&
|
||||
key.name.toLowerCase().includes(e.target.value.toLowerCase()),
|
||||
);
|
||||
setDataSource(filteredData || []);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const { mutate: createAPIKey, isLoading: isLoadingCreateAPIKey } = useMutation(
|
||||
createAPIKeyApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setShowNewAPIKeyDetails(true);
|
||||
setActiveAPIKey(data.data);
|
||||
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
|
||||
updateAPIKeyApi,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
|
||||
deleteAPIKeyApi,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onDeleteHandler = (): void => {
|
||||
clearSearch();
|
||||
|
||||
if (activeAPIKey) {
|
||||
deleteAPIKey(activeAPIKey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateApiKey = (): void => {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (activeAPIKey) {
|
||||
updateAPIKey({
|
||||
id: activeAPIKey.id,
|
||||
data: {
|
||||
name: values.name,
|
||||
role: values.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateAPIKey = (): void => {
|
||||
createForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (user) {
|
||||
createAPIKey({
|
||||
name: values.name,
|
||||
expiresInDays: parseInt(values.expiration, 10),
|
||||
role: values.role,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
const getFormattedTime = (epochTime: number): string => {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
const formattedTime = new Date(epochTime * 1000).toLocaleTimeString(
|
||||
'en-US',
|
||||
timeOptions,
|
||||
);
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
const formattedDate = new Date(epochTime * 1000).toLocaleDateString(
|
||||
'en-US',
|
||||
dateOptions,
|
||||
);
|
||||
|
||||
return `${formattedDate} ${formattedTime}`;
|
||||
};
|
||||
|
||||
const handleCopyClose = (): void => {
|
||||
if (activeAPIKey) {
|
||||
handleCopyKey(activeAPIKey?.token);
|
||||
}
|
||||
|
||||
hideAddViewModal();
|
||||
};
|
||||
|
||||
const columns: TableProps<APIKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'API Key',
|
||||
key: 'api-key',
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
render: (APIKey: APIKeyProps): JSX.Element => {
|
||||
const formattedDateAndTime =
|
||||
APIKey && APIKey?.lastUsed && APIKey?.lastUsed !== 0
|
||||
? getFormattedTime(APIKey?.lastUsed)
|
||||
: 'Never';
|
||||
|
||||
const createdOn = new Date(APIKey.createdAt).toLocaleString();
|
||||
|
||||
const expiresIn =
|
||||
APIKey.expiresAt === 0
|
||||
? Number.POSITIVE_INFINITY
|
||||
: getDateDifference(
|
||||
new Date(APIKey?.createdAt).getTime() / 1000,
|
||||
APIKey?.expiresAt,
|
||||
);
|
||||
|
||||
const isExpired = isExpiredToken(APIKey.expiresAt);
|
||||
|
||||
const expiresOn =
|
||||
!APIKey.expiresAt || APIKey.expiresAt === 0
|
||||
? 'No Expiry'
|
||||
: getFormattedTime(APIKey.expiresAt);
|
||||
|
||||
const updatedOn =
|
||||
!APIKey.updatedAt || APIKey.updatedAt === ''
|
||||
? null
|
||||
: new Date(APIKey.updatedAt).toLocaleString();
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className="title-with-action">
|
||||
<div className="api-key-data">
|
||||
<div className="api-key-title">
|
||||
<Typography.Text>{APIKey?.name}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="api-key-value">
|
||||
<Typography.Text>
|
||||
{APIKey?.token.substring(0, 2)}********
|
||||
{APIKey?.token.substring(APIKey.token.length - 2).trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyKey(APIKey.token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{APIKey.role === USER_ROLES.ADMIN && (
|
||||
<Tooltip title={USER_ROLES.ADMIN}>
|
||||
<Contact2 size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{APIKey.role === USER_ROLES.EDITOR && (
|
||||
<Tooltip title={USER_ROLES.EDITOR}>
|
||||
<ClipboardEdit size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{APIKey.role === USER_ROLES.VIEWER && (
|
||||
<Tooltip title={USER_ROLES.VIEWER}>
|
||||
<View size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!APIKey.role && (
|
||||
<Tooltip title={USER_ROLES.ADMIN}>
|
||||
<Contact2 size={14} color={Color.BG_ROBIN_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="api-key-info-container">
|
||||
{APIKey?.createdByUser && (
|
||||
<Row>
|
||||
<Col span={6}> Creator </Col>
|
||||
<Col span={12} className="user-info">
|
||||
<Avatar className="user-avatar" size="small">
|
||||
{APIKey?.createdByUser?.displayName?.substring(0, 1)}
|
||||
</Avatar>
|
||||
|
||||
<Typography.Text>
|
||||
{APIKey.createdByUser?.displayName}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="user-email">{APIKey.createdByUser?.email}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row>
|
||||
<Col span={6}> Created on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{createdOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
{updatedOn && (
|
||||
<Row>
|
||||
<Col span={6}> Updated on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{updatedOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col span={6}> Expires on </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{expiresOn}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="column-render">
|
||||
<Collapse items={items} />
|
||||
|
||||
<div className="api-key-details">
|
||||
<div className="api-key-last-used-at">
|
||||
<CalendarClock size={14} />
|
||||
Last used <Minus size={12} />
|
||||
<Typography.Text>{formattedDateAndTime}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{!isExpired && expiresIn <= EXPIRATION_WITHIN_SEVEN_DAYS && (
|
||||
<div
|
||||
className={cx(
|
||||
'api-key-expires-in',
|
||||
expiresIn <= 3 ? 'danger' : 'warning',
|
||||
)}
|
||||
>
|
||||
<span className="dot" /> Expires {dayjs().to(expiresOn)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className={cx('api-key-expires-in danger')}>
|
||||
<span className="dot" /> Expired
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="api-key-container">
|
||||
<div className="api-key-content">
|
||||
<header>
|
||||
<Typography.Title className="title">API Keys</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage API keys for the SigNoz API
|
||||
</Typography.Text>
|
||||
</header>
|
||||
|
||||
<div className="api-keys-search-add-new">
|
||||
<Input
|
||||
placeholder="Search for keys..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="add-new-api-key-btn"
|
||||
type="primary"
|
||||
onClick={showAddModal}
|
||||
>
|
||||
<Plus size={14} /> New Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={isLoading || isRefetching}
|
||||
showHeader={false}
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
hideOnSinglePage: true,
|
||||
showTotal: (total: number, range: number[]): string =>
|
||||
`${range[0]}-${range[1]} of ${total} keys`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Key Modal */}
|
||||
<Modal
|
||||
className="delete-api-key-modal"
|
||||
title={<span className="title">Delete Key</span>}
|
||||
open={isDeleteModalOpen}
|
||||
closable
|
||||
afterClose={handleModalClose}
|
||||
onCancel={hideDeleteViewModal}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideDeleteViewModal}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
loading={isDeleteingAPIKey}
|
||||
onClick={onDeleteHandler}
|
||||
className="delete-btn"
|
||||
>
|
||||
Delete key
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="delete-text">
|
||||
{t('delete_confirm_message', {
|
||||
keyName: activeAPIKey?.name,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Key Modal */}
|
||||
<Modal
|
||||
className="api-key-modal"
|
||||
title="Edit key"
|
||||
open={isEditModalOpen}
|
||||
key="edit-api-key-modal"
|
||||
afterClose={handleModalClose}
|
||||
// closable
|
||||
onCancel={hideEditViewModal}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideEditViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={isLoadingUpdateAPIKey}
|
||||
icon={<Check size={14} />}
|
||||
onClick={onUpdateApiKey}
|
||||
>
|
||||
Update key
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
name="edit-api-key-form"
|
||||
key={activeAPIKey?.id}
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
name: activeAPIKey?.name,
|
||||
role: activeAPIKey?.role,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true }, { type: 'string', min: 6 }]}
|
||||
>
|
||||
<Input placeholder="Enter Key Name" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="role" label="Role">
|
||||
<Flex vertical gap="middle">
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
className="api-key-access-role"
|
||||
defaultValue={activeAPIKey?.role}
|
||||
>
|
||||
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.EDITOR} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.VIEWER} className={cx('tab')}>
|
||||
<div className="role">
|
||||
<Eye size={14} /> Viewer
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Create New Key Modal */}
|
||||
<Modal
|
||||
className="api-key-modal"
|
||||
title="Create new key"
|
||||
open={isAddModalOpen}
|
||||
key="create-api-key-modal"
|
||||
closable
|
||||
onCancel={hideAddViewModal}
|
||||
destroyOnClose
|
||||
footer={
|
||||
showNewAPIKeyDetails
|
||||
? [
|
||||
<Button
|
||||
key="copy-key-close"
|
||||
className="periscope-btn primary"
|
||||
data-testid="copy-key-close-btn"
|
||||
type="primary"
|
||||
onClick={handleCopyClose}
|
||||
icon={<Check size={12} />}
|
||||
>
|
||||
Copy key and close
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={hideAddViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
test-id="create-new-key"
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
loading={isLoadingCreateAPIKey}
|
||||
onClick={onCreateAPIKey}
|
||||
>
|
||||
Create new key
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{!showNewAPIKeyDetails && (
|
||||
<Form
|
||||
key="createForm"
|
||||
name="create-api-key-form"
|
||||
form={createForm}
|
||||
initialValues={{
|
||||
role: USER_ROLES.ADMIN,
|
||||
expiration: '1',
|
||||
name: '',
|
||||
}}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true }, { type: 'string', min: 6 }]}
|
||||
validateTrigger="onFinish"
|
||||
>
|
||||
<Input placeholder="Enter Key Name" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="role" label="Role">
|
||||
<Flex vertical gap="middle">
|
||||
<Radio.Group
|
||||
buttonStyle="solid"
|
||||
className="api-key-access-role"
|
||||
defaultValue={USER_ROLES.ADMIN}
|
||||
>
|
||||
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
|
||||
<div className="role" data-testid="create-form-admin-role-btn">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.EDITOR} className="tab">
|
||||
<div className="role" data-testid="create-form-editor-role-btn">
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={USER_ROLES.VIEWER} className="tab">
|
||||
<div className="role" data-testid="create-form-viewer-role-btn">
|
||||
<Eye size={14} /> Viewer
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item name="expiration" label="Expiration">
|
||||
<Select
|
||||
className="expiration-selector"
|
||||
placeholder="Expiration"
|
||||
options={API_KEY_EXPIRY_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{showNewAPIKeyDetails && (
|
||||
<div className="api-key-info-container">
|
||||
<Row>
|
||||
<Col span={8}>Key</Col>
|
||||
<Col span={16}>
|
||||
<span className="copyable-text">
|
||||
<Typography.Text>
|
||||
{activeAPIKey?.token.substring(0, 2)}****************
|
||||
{activeAPIKey?.token.substring(activeAPIKey.token.length - 2).trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(): void => {
|
||||
if (activeAPIKey) {
|
||||
handleCopyKey(activeAPIKey.token);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Name</Col>
|
||||
<Col span={16}>{activeAPIKey?.name}</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Role</Col>
|
||||
<Col span={16}>
|
||||
{activeAPIKey?.role === USER_ROLES.ADMIN && (
|
||||
<div className="role">
|
||||
<Contact2 size={14} /> Admin
|
||||
</div>
|
||||
)}
|
||||
{activeAPIKey?.role === USER_ROLES.EDITOR && (
|
||||
<div className="role">
|
||||
{' '}
|
||||
<ClipboardEdit size={14} /> Editor
|
||||
</div>
|
||||
)}
|
||||
{activeAPIKey?.role === USER_ROLES.VIEWER && (
|
||||
<div className="role">
|
||||
{' '}
|
||||
<View size={14} /> Viewer
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={8}>Creator</Col>
|
||||
|
||||
<Col span={16} className="user-info">
|
||||
<Avatar className="user-avatar" size="small">
|
||||
{activeAPIKey?.createdByUser?.displayName?.substring(0, 1)}
|
||||
</Avatar>
|
||||
|
||||
<Typography.Text>
|
||||
{activeAPIKey?.createdByUser?.displayName}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="user-email">{activeAPIKey?.createdByUser?.email}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{activeAPIKey?.createdAt && (
|
||||
<Row>
|
||||
<Col span={8}>Created on</Col>
|
||||
<Col span={16}>
|
||||
{new Date(activeAPIKey?.createdAt).toLocaleString()}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.expiresAt !== 0 && activeAPIKey?.expiresAt && (
|
||||
<Row>
|
||||
<Col span={8}>Expires on</Col>
|
||||
<Col span={16}>{getFormattedTime(activeAPIKey?.expiresAt)}</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.expiresAt === 0 && (
|
||||
<Row>
|
||||
<Col span={8}>Expires on</Col>
|
||||
<Col span={16}> No Expiry </Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default APIKeys;
|
||||
@@ -16,6 +16,7 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -264,21 +265,23 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={
|
||||
<>
|
||||
<strong>API Keys</strong> have been deprecated and replaced by{' '}
|
||||
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
|
||||
programmatic API access.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
{IS_SERVICE_ACCOUNTS_ENABLED && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={
|
||||
<>
|
||||
<strong>API Keys</strong> have been deprecated and replaced by{' '}
|
||||
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
|
||||
programmatic API access.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
formatDataForTable,
|
||||
getK8sVolumesListColumns,
|
||||
getK8sVolumesListQuery,
|
||||
getVolumeListGroupedByRowDataQueryKey,
|
||||
getVolumesListQueryKey,
|
||||
K8sVolumesRowData,
|
||||
} from './utils';
|
||||
import VolumeDetails from './VolumeDetails';
|
||||
@@ -169,26 +167,6 @@ function K8sVolumesList({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minTime, maxTime, orderBy, selectedRowData, groupBy]);
|
||||
|
||||
const groupedByRowDataQueryKey = useMemo(
|
||||
() =>
|
||||
getVolumeListGroupedByRowDataQueryKey(
|
||||
selectedRowData?.groupedByMeta,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
groupBy,
|
||||
minTime,
|
||||
maxTime,
|
||||
),
|
||||
[
|
||||
selectedRowData?.groupedByMeta,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
groupBy,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
data: groupedByRowData,
|
||||
isFetching: isFetchingGroupedByRowData,
|
||||
@@ -198,7 +176,7 @@ function K8sVolumesList({
|
||||
} = useGetK8sVolumesList(
|
||||
fetchGroupedByRowDataQuery as K8sVolumesListPayload,
|
||||
{
|
||||
queryKey: groupedByRowDataQueryKey,
|
||||
queryKey: ['volumeList', fetchGroupedByRowDataQuery],
|
||||
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
|
||||
},
|
||||
undefined,
|
||||
@@ -243,28 +221,6 @@ function K8sVolumesList({
|
||||
return queryPayload;
|
||||
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
|
||||
|
||||
const volumesListQueryKey = useMemo(() => {
|
||||
return getVolumesListQueryKey(
|
||||
selectedVolumeUID,
|
||||
pageSize,
|
||||
currentPage,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
groupBy,
|
||||
minTime,
|
||||
maxTime,
|
||||
);
|
||||
}, [
|
||||
selectedVolumeUID,
|
||||
pageSize,
|
||||
currentPage,
|
||||
queryFilters,
|
||||
groupBy,
|
||||
orderBy,
|
||||
minTime,
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
const formattedGroupedByVolumesData = useMemo(
|
||||
() =>
|
||||
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
|
||||
@@ -281,7 +237,7 @@ function K8sVolumesList({
|
||||
const { data, isFetching, isLoading, isError } = useGetK8sVolumesList(
|
||||
query as K8sVolumesListPayload,
|
||||
{
|
||||
queryKey: volumesListQueryKey,
|
||||
queryKey: ['volumeList', query],
|
||||
enabled: !!query,
|
||||
},
|
||||
undefined,
|
||||
|
||||
@@ -77,74 +77,6 @@ export const getK8sVolumesListQuery = (): K8sVolumesListPayload => ({
|
||||
orderBy: { columnName: 'usage', order: 'desc' },
|
||||
});
|
||||
|
||||
export const getVolumeListGroupedByRowDataQueryKey = (
|
||||
groupedByMeta: K8sVolumesData['meta'] | undefined,
|
||||
queryFilters: IBuilderQuery['filters'],
|
||||
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
|
||||
groupBy: IBuilderQuery['groupBy'],
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
): (string | undefined)[] => {
|
||||
// When we have grouped by metadata defined
|
||||
// We need to leave out the min/max time
|
||||
// Otherwise it will cause a loop
|
||||
const groupedByMetaStr = JSON.stringify(groupedByMeta || undefined) ?? '';
|
||||
if (groupedByMetaStr) {
|
||||
return [
|
||||
'volumeList',
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
groupedByMetaStr,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'volumeList',
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
groupedByMetaStr,
|
||||
String(minTime),
|
||||
String(maxTime),
|
||||
];
|
||||
};
|
||||
|
||||
export const getVolumesListQueryKey = (
|
||||
selectedVolumeUID: string | null,
|
||||
pageSize: number,
|
||||
currentPage: number,
|
||||
queryFilters: IBuilderQuery['filters'],
|
||||
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
|
||||
groupBy: IBuilderQuery['groupBy'],
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
): (string | undefined)[] => {
|
||||
// When selected volume is defined
|
||||
// We need to leave out the min/max time
|
||||
// Otherwise it will cause a loop
|
||||
if (selectedVolumeUID) {
|
||||
return [
|
||||
'volumeList',
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'volumeList',
|
||||
String(pageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
String(minTime),
|
||||
String(maxTime),
|
||||
];
|
||||
};
|
||||
|
||||
const columnsConfig = [
|
||||
{
|
||||
title: <div className="column-header-left pvc-name-header">PVC Name</div>,
|
||||
|
||||
@@ -1,93 +1,29 @@
|
||||
import setupCommonMocks from '../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { applyMiddleware, legacy_createStore as createStore } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import reducers from 'store/reducers';
|
||||
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { LicenseResModel } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../../constants';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const SERVER_URL = 'http://localhost/api';
|
||||
|
||||
// jsdom does not implement IntersectionObserver — provide a no-op stub
|
||||
const mockObserver = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
global.IntersectionObserver = jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockObserver) as any;
|
||||
|
||||
const mockVolume = {
|
||||
persistentVolumeClaimName: 'test-pvc',
|
||||
volumeAvailable: 1000000,
|
||||
volumeCapacity: 5000000,
|
||||
volumeInodes: 100,
|
||||
volumeInodesFree: 50,
|
||||
volumeInodesUsed: 50,
|
||||
volumeUsage: 4000000,
|
||||
meta: {
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
k8s_node_name: 'test-node',
|
||||
k8s_persistentvolumeclaim_name: 'test-pvc',
|
||||
k8s_pod_name: 'test-pod',
|
||||
k8s_pod_uid: 'test-pod-uid',
|
||||
k8s_statefulset_name: '',
|
||||
},
|
||||
};
|
||||
|
||||
const mockVolumesResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: '',
|
||||
records: [mockVolume],
|
||||
groups: null,
|
||||
total: 1,
|
||||
sentAnyHostMetricsData: false,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Renders K8sVolumesList with a real Redux store so dispatched actions affect state. */
|
||||
function renderWithRealStore(
|
||||
initialEntries?: Record<string, any>,
|
||||
): { testStore: ReturnType<typeof createStore> } {
|
||||
const testStore = createStore(reducers, applyMiddleware(thunk as any));
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={initialEntries}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryBuilderProvider>
|
||||
<MemoryRouter>
|
||||
<K8sVolumesList
|
||||
isFiltersVisible={false}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
quickFiltersLastUpdated={-1}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryBuilderProvider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
return { testStore };
|
||||
}
|
||||
|
||||
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
let requestsMade: Array<{
|
||||
url: string;
|
||||
@@ -97,6 +33,7 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
requestsMade = [];
|
||||
queryClient.clear();
|
||||
|
||||
server.use(
|
||||
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
|
||||
@@ -142,7 +79,19 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
});
|
||||
|
||||
it('should call aggregate keys API with k8s_volume_capacity', async () => {
|
||||
renderWithRealStore();
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<K8sVolumesList
|
||||
isFiltersVisible={false}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
quickFiltersLastUpdated={-1}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestsMade.length).toBeGreaterThan(0);
|
||||
@@ -179,7 +128,19 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
activeLicense: (null as unknown) as LicenseResModel,
|
||||
} as IAppContext);
|
||||
|
||||
renderWithRealStore();
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<K8sVolumesList
|
||||
isFiltersVisible={false}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
quickFiltersLastUpdated={-1}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestsMade.length).toBeGreaterThan(0);
|
||||
@@ -198,193 +159,3 @@ describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
|
||||
expect(aggregateAttribute).toBe('k8s.volume.capacity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('K8sVolumesList', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/pvcs/list', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockVolumesResponse)),
|
||||
),
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { attributeKeys: [] } })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders volume rows from API response', async () => {
|
||||
renderWithRealStore();
|
||||
|
||||
await waitFor(async () => {
|
||||
const elements = await screen.findAllByText('test-pvc');
|
||||
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens VolumeDetails when a volume row is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRealStore();
|
||||
|
||||
const pvcCells = await screen.findAllByText('test-pvc');
|
||||
expect(pvcCells.length).toBeGreaterThan(0);
|
||||
|
||||
const row = pvcCells[0].closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
await user.click(row!);
|
||||
|
||||
await waitFor(async () => {
|
||||
const cells = await screen.findAllByText('test-pvc');
|
||||
expect(cells.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('closes VolumeDetails when the close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRealStore();
|
||||
|
||||
const pvcCells = await screen.findAllByText('test-pvc');
|
||||
expect(pvcCells.length).toBeGreaterThan(0);
|
||||
|
||||
const row = pvcCells[0].closest('tr');
|
||||
await user.click(row!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Close' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-fetch the volumes list when time range changes after selecting a volume', async () => {
|
||||
const user = userEvent.setup();
|
||||
let apiCallCount = 0;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/pvcs/list', async (_req, res, ctx) => {
|
||||
apiCallCount += 1;
|
||||
return res(ctx.status(200), ctx.json(mockVolumesResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
const { testStore } = renderWithRealStore();
|
||||
|
||||
await waitFor(() => expect(apiCallCount).toBe(1));
|
||||
|
||||
const pvcCells = await screen.findAllByText('test-pvc');
|
||||
const row = pvcCells[0].closest('tr');
|
||||
await user.click(row!);
|
||||
await waitFor(async () => {
|
||||
const cells = await screen.findAllByText('test-pvc');
|
||||
expect(cells.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Wait for nuqs URL state to fully propagate to the component
|
||||
// The selectedVolumeUID is managed via nuqs (async URL state),
|
||||
// so we need to ensure the state has settled before dispatching time changes
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
});
|
||||
|
||||
const countAfterClick = apiCallCount;
|
||||
|
||||
// There's a specific component causing the min/max time to be updated
|
||||
// After the volume loads, it triggers the change again
|
||||
// And then the query to fetch data for the selected volume enters in a loop
|
||||
act(() => {
|
||||
testStore.dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
|
||||
maxTime: Date.now() * 1000000,
|
||||
selectedTime: '30m',
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
// Allow any potential re-fetch to settle
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
expect(apiCallCount).toBe(countAfterClick);
|
||||
});
|
||||
|
||||
it('does not re-fetch groupedByRowData when time range changes after expanding a volume row with groupBy', async () => {
|
||||
const user = userEvent.setup();
|
||||
const groupByValue = [{ key: 'k8s_namespace_name' }];
|
||||
|
||||
let groupedByRowDataCallCount = 0;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/pvcs/list', async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
// Check for both underscore and dot notation keys since dotMetricsEnabled
|
||||
// may be true or false depending on test order
|
||||
const isGroupedByRowDataRequest = body.filters?.items?.some(
|
||||
(item: { key?: { key?: string }; value?: string }) =>
|
||||
(item.key?.key === 'k8s_namespace_name' ||
|
||||
item.key?.key === 'k8s.namespace.name') &&
|
||||
item.value === 'test-namespace',
|
||||
);
|
||||
if (isGroupedByRowDataRequest) {
|
||||
groupedByRowDataCallCount += 1;
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(mockVolumesResponse));
|
||||
}),
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
attributeKeys: [{ key: 'k8s_namespace_name', dataType: 'string' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { testStore } = renderWithRealStore({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupByValue),
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const elements = await screen.findAllByText('test-namespace');
|
||||
return expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const row = (await screen.findAllByText('test-namespace'))[0].closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
user.click(row as HTMLElement);
|
||||
await waitFor(() => expect(groupedByRowDataCallCount).toBe(1));
|
||||
|
||||
const countAfterExpand = groupedByRowDataCallCount;
|
||||
|
||||
act(() => {
|
||||
testStore.dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
minTime: Date.now() * 1000000 - 30 * 60 * 1000 * 1000000,
|
||||
maxTime: Date.now() * 1000000,
|
||||
selectedTime: '30m',
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
// Allow any potential re-fetch to settle
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
expect(groupedByRowDataCallCount).toBe(countAfterExpand);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
useListServiceAccounts,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
GetServiceAccount200,
|
||||
ListServiceAccounts200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
@@ -51,13 +59,29 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const seedAccountCache = useCallback(
|
||||
(data: ListServiceAccounts200) => {
|
||||
data.data.forEach((account) => {
|
||||
queryClient.setQueryData<GetServiceAccount200>(
|
||||
getGetServiceAccountQueryKey({ id: account.id }),
|
||||
(old) => old ?? { data: account, status: data.status },
|
||||
);
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts();
|
||||
} = useListServiceAccounts({
|
||||
query: { onSuccess: seedAccountCache },
|
||||
});
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
@@ -73,10 +97,10 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const deletedCount = useMemo(
|
||||
const disabledCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
@@ -88,9 +112,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
);
|
||||
} else if (filterMode === FilterMode.Deleted) {
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +122,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
|
||||
a.name?.toLowerCase().includes(q) ||
|
||||
a.email?.toLowerCase().includes(q) ||
|
||||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,15 +174,15 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Deleted,
|
||||
key: FilterMode.Disabled,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Deleted ⎯ {deletedCount}</span>
|
||||
{filterMode === FilterMode.Deleted && <Check size={14} />}
|
||||
<span>Disabled ⎯ {disabledCount}</span>
|
||||
{filterMode === FilterMode.Disabled && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setFilterMode(FilterMode.Disabled);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
@@ -166,8 +192,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
switch (filterMode) {
|
||||
case FilterMode.Active:
|
||||
return `Active ⎯ ${activeCount}`;
|
||||
case FilterMode.Deleted:
|
||||
return `Deleted ⎯ ${deletedCount}`;
|
||||
case FilterMode.Disabled:
|
||||
return `Disabled ⎯ ${disabledCount}`;
|
||||
default:
|
||||
return `All accounts ⎯ ${totalCount}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Delete Service Account/i }),
|
||||
await screen.findByRole('button', { name: /Disable Service Account/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -187,16 +187,14 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
await waitFor(() => {
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const IS_SERVICE_ACCOUNTS_ENABLED = false;
|
||||
|
||||
@@ -9,5 +9,5 @@ export const SA_QUERY_PARAMS = {
|
||||
ADD_KEY: 'add-key',
|
||||
EDIT_KEY: 'edit-key',
|
||||
REVOKE_KEY: 'revoke-key',
|
||||
DELETE_SA: 'delete-sa',
|
||||
DISABLE_SA: 'disable-sa',
|
||||
} as const;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function toServiceAccountRow(
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
email: sa.email,
|
||||
roles: sa.roles,
|
||||
status: sa.status,
|
||||
createdAt: toISOString(sa.createdAt),
|
||||
updatedAt: toISOString(sa.updatedAt),
|
||||
@@ -17,18 +18,19 @@ export function toServiceAccountRow(
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Active = 'active',
|
||||
Deleted = 'deleted',
|
||||
Disabled = 'disabled',
|
||||
}
|
||||
|
||||
export enum ServiceAccountStatus {
|
||||
Active = 'ACTIVE',
|
||||
Deleted = 'DELETED',
|
||||
Disabled = 'DISABLED',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
|
||||
@@ -692,6 +692,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys, () =>
|
||||
onClickHandler(ROUTES.API_KEYS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
@@ -717,6 +720,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Github,
|
||||
HardDrive,
|
||||
Home,
|
||||
Key,
|
||||
Keyboard,
|
||||
Layers2,
|
||||
LayoutGrid,
|
||||
@@ -365,6 +366,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'service-accounts',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
|
||||
@@ -158,6 +158,7 @@ export const routesToSkip = [
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
ROUTES.LOGS_PIPELINES,
|
||||
ROUTES.BILLING,
|
||||
|
||||
14
frontend/src/hooks/APIKeys/useGetAllAPIKeys.ts
Normal file
14
frontend/src/hooks/APIKeys/useGetAllAPIKeys.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import list from 'api/v1/pats/list';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { APIKeyProps } from 'types/api/pat/types';
|
||||
|
||||
export const useGetAllAPIKeys = (): UseQueryResult<
|
||||
SuccessResponseV2<APIKeyProps[]>,
|
||||
APIError
|
||||
> =>
|
||||
useQuery<SuccessResponseV2<APIKeyProps[]>, APIError>({
|
||||
queryKey: ['APIKeys'],
|
||||
queryFn: () => list(),
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface RoleUpdateFailure {
|
||||
roleName: string;
|
||||
error: unknown;
|
||||
onRetry: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
export function useServiceAccountRoleManager(
|
||||
accountId: string,
|
||||
): UseServiceAccountRoleManagerResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useGetServiceAccountRoles({ id: accountId });
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(() => data?.data ?? [], [
|
||||
data?.data,
|
||||
]);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries(
|
||||
getGetServiceAccountRolesQueryKey({ id: accountId }),
|
||||
),
|
||||
[accountId, queryClient],
|
||||
);
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
);
|
||||
const desiredRoleIds = new Set(
|
||||
localRoleIds.filter((id) => id != null && id !== ''),
|
||||
);
|
||||
|
||||
const addedRoles = availableRoles.filter(
|
||||
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const removedRoles = currentRoles.filter(
|
||||
(r) => r.id && !desiredRoleIds.has(r.id),
|
||||
);
|
||||
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof createRole> =>
|
||||
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof deleteRole> =>
|
||||
deleteRole({ pathParams: { id: accountId, rid: role.id } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
await invalidateRoles();
|
||||
|
||||
const failures: RoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const { role, run } = allOperations[index];
|
||||
failures.push({
|
||||
roleName: role.name ?? 'unknown',
|
||||
error: result.reason,
|
||||
onRetry: async (): Promise<void> => {
|
||||
await run();
|
||||
await invalidateRoles();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return failures;
|
||||
},
|
||||
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
|
||||
);
|
||||
|
||||
return {
|
||||
currentRoles,
|
||||
isLoading,
|
||||
applyDiff,
|
||||
};
|
||||
}
|
||||
541
frontend/src/mocks-server/__mockdata__/apiKeys.ts
Normal file
541
frontend/src/mocks-server/__mockdata__/apiKeys.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
const createdByEmail = 'mando@signoz.io';
|
||||
|
||||
export const getAPIKeysResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
id: '26',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'T2DuASwpuUx3wlYraFl5r7N9G1ikBhzGuy2ihcIKDMs=',
|
||||
role: 'ADMIN',
|
||||
name: '1 Day Old',
|
||||
createdAt: 1708010258,
|
||||
expiresAt: 1708096658,
|
||||
updatedAt: 1708010258,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '24',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'EteVs77BA4FFLJD/TsFE9c+CLX4kXVmlx+0GGK7dpXY=',
|
||||
role: 'ADMIN',
|
||||
name: '1 year expiry - updated',
|
||||
createdAt: 1708008146,
|
||||
expiresAt: 1739544146,
|
||||
updatedAt: 1708008239,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '25',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: '1udrUFmRI6gdb8r/hLabS7zRlgfMQlUw/tz9sac82pE=',
|
||||
role: 'ADMIN',
|
||||
name: 'No Expiry Key',
|
||||
createdAt: 1708008178,
|
||||
expiresAt: 0,
|
||||
updatedAt: 1708008190,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'gtqKF7g7avoe+Yu2+WhyDDLQSr6IsVaR5xpby2XhLAY=',
|
||||
role: 'VIEWER',
|
||||
name: 'No Expiry',
|
||||
createdAt: 1708007395,
|
||||
expiresAt: 0,
|
||||
updatedAt: 1708007936,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'GM/TqEID8N4ynlvQHK38ITEvRAcn5XkJZpmd11xT3OQ=',
|
||||
role: 'VIEWER',
|
||||
name: 'No Expiry - 2',
|
||||
createdAt: 1708007685,
|
||||
expiresAt: 0,
|
||||
updatedAt: 1708007786,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'Oj75e6Zr7JmjFcWIo0UK/Nl06RdC2BKOr/QVHoBA0gM=',
|
||||
role: 'ADMIN',
|
||||
name: '7 Days',
|
||||
createdAt: 1708003326,
|
||||
expiresAt: 1708608126,
|
||||
updatedAt: 1708007380,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'T+sNdYe6I74ya/9mEKqB3UTrFm8+jwI0DiirqEx3bsM=',
|
||||
role: 'EDITOR',
|
||||
name: '1 month',
|
||||
createdAt: 1708004012,
|
||||
expiresAt: 1710596012,
|
||||
updatedAt: 1708005206,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'JWw26FuymeHq+fsfFcb+2+Ls/MdokmeXxXdZisuaVeI=',
|
||||
role: 'ADMIN',
|
||||
name: '3 Months',
|
||||
createdAt: 1708004755,
|
||||
expiresAt: 1715780755,
|
||||
updatedAt: 1708005197,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: '2zDrYNr+IWXUyA14+afVvO6GI9dcHfEsOYxjA9mrprg=',
|
||||
role: 'ADMIN',
|
||||
name: 'New No Expiry',
|
||||
createdAt: 1708000444,
|
||||
expiresAt: 0,
|
||||
updatedAt: 1708000444,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'Q+/+UB2OrDPcS9b0+5A1dDXYmWHz0abbVVidF48QCso=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token for user 1',
|
||||
createdAt: 1707997720,
|
||||
expiresAt: 1708170520,
|
||||
updatedAt: 1707997720,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: '/X3OEaSOLrrJImvzIB3g5WGg+5831X89fZZQT1JaxvQ=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token for user 2',
|
||||
createdAt: 1707997603,
|
||||
expiresAt: 1708170403,
|
||||
updatedAt: 1707997603,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'bTs+Q6waIiP4KJ8L5N58EQonuapWMXsfEra/cmMwmbE=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token for user 3',
|
||||
createdAt: 1707997539,
|
||||
expiresAt: 1708170339,
|
||||
updatedAt: 1707997539,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'YaEqQHrH8KOnYFllor/8Tq653TgxPU1Z7ZDzY3+ETmI=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token for user',
|
||||
createdAt: 1707997537,
|
||||
expiresAt: 1708170337,
|
||||
updatedAt: 1707997537,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'Hg/QpMU9VQyqIuzSh9ND2454IN5uOHzVkv7owEtBcPo=',
|
||||
role: 'EDITOR',
|
||||
name: 'test123',
|
||||
createdAt: 1707997288,
|
||||
expiresAt: 1708083688,
|
||||
updatedAt: 1707997288,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'M5gMsccDthPTibquB7kR7ZSEI76y4endOxZPESZ9/po=',
|
||||
role: 'VIEWER',
|
||||
name: 'Viewer Token for user',
|
||||
createdAt: 1707996747,
|
||||
expiresAt: 1708255947,
|
||||
updatedAt: 1707996747,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'H8NVlOD09IcMgQ/rzfVucb+4+jEcqZ4ZRx6n7QztMSc=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token for user',
|
||||
createdAt: 1707996736,
|
||||
expiresAt: 1708169536,
|
||||
updatedAt: 1707996736,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
createdAt: 0,
|
||||
profilePictureURL: '',
|
||||
notFound: true,
|
||||
},
|
||||
token: 'z24SswLmNlPVUgb1j6rfc2u4Kb4xSUolwb11cI8kbrs=',
|
||||
role: 'ADMIN',
|
||||
name: 'Admin Token for user',
|
||||
createdAt: 1707996719,
|
||||
expiresAt: 0,
|
||||
updatedAt: 1707996719,
|
||||
lastUsed: 0,
|
||||
revoked: false,
|
||||
updatedByUserId: '',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
userId: 'mandalorian',
|
||||
createdByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
updatedByUser: {
|
||||
id: '001',
|
||||
name: 'Mando',
|
||||
email: createdByEmail,
|
||||
createdAt: 1707974098,
|
||||
profilePictureURL: '',
|
||||
notFound: false,
|
||||
},
|
||||
token: 'SWuNSF08EB6+VN05312QaAsPum2wkqIm+ujiWZKnm2Q=',
|
||||
role: 'EDITOR',
|
||||
name: 'Editor Token',
|
||||
createdAt: 1707992270,
|
||||
expiresAt: 1708165070,
|
||||
updatedAt: 1707995424,
|
||||
lastUsed: 1707992517,
|
||||
revoked: false,
|
||||
updatedByUserId: 'mandalorian',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const createAPIKeyResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
id: '57',
|
||||
userId: 'mandalorian',
|
||||
token: 'pQ5kiHjcbQ2FbKlS14LQjA2RzXEBi/KvBfM7BRSwltI=',
|
||||
name: 'test1233',
|
||||
createdAt: 1707818550,
|
||||
expiresAt: 0,
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { settingsNavSections } from 'container/SideNav/menuItems';
|
||||
@@ -83,10 +84,12 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
|
||||
item.key === ROUTES.SHORTCUTS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -115,9 +118,11 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -141,9 +146,11 @@ function SettingsPage(): JSX.Element {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS)
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('SettingsPage nav sections', () => {
|
||||
'billing',
|
||||
'roles',
|
||||
'members',
|
||||
'api-keys',
|
||||
'sso',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
@@ -81,9 +82,12 @@ describe('SettingsPage nav sections', () => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
});
|
||||
it.each(['billing', 'roles', 'api-keys'])(
|
||||
'does not render "%s" element',
|
||||
(id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Self-hosted Admin', () => {
|
||||
@@ -95,7 +99,7 @@ describe('SettingsPage nav sections', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['roles', 'members', 'integrations', 'sso', 'ingestion'])(
|
||||
it.each(['roles', 'members', 'api-keys', 'integrations', 'sso', 'ingestion'])(
|
||||
'renders "%s" element',
|
||||
(id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RouteTabProps } from 'components/RouteTab/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertChannels from 'container/AllAlertChannels';
|
||||
import APIKeys from 'container/APIKeys/APIKeys';
|
||||
import BillingContainer from 'container/BillingContainer/BillingContainer';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Keyboard,
|
||||
KeySquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
@@ -112,6 +114,19 @@ export const generalSettingsCloud = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const apiKeys = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: APIKeys,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<KeySquare size={16} /> {t('routes:api_keys').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.API_KEYS,
|
||||
key: ROUTES.API_KEYS,
|
||||
},
|
||||
];
|
||||
|
||||
export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: BillingContainer,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { RouteTabProps } from 'components/RouteTab/types';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { TFunction } from 'i18next';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import {
|
||||
alertChannels,
|
||||
apiKeys,
|
||||
billingSettings,
|
||||
createAlertChannels,
|
||||
editAlertChannels,
|
||||
@@ -62,7 +64,11 @@ export const getRoutes = (
|
||||
settings.push(...alertChannels(t));
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(...membersSettings(t), ...serviceAccountsSettings(t));
|
||||
settings.push(...apiKeys(t), ...membersSettings(t));
|
||||
|
||||
if (IS_SERVICE_ACCOUNTS_ENABLED) {
|
||||
settings.push(...serviceAccountsSettings(t));
|
||||
}
|
||||
}
|
||||
|
||||
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
|
||||
|
||||
@@ -47,9 +47,6 @@ const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
56
frontend/src/types/api/pat/types.ts
Normal file
56
frontend/src/types/api/pat/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface User {
|
||||
createdAt?: number;
|
||||
email?: string;
|
||||
id: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface APIKeyProps {
|
||||
name: string;
|
||||
expiresAt: number;
|
||||
role: string;
|
||||
token: string;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
createdByUser?: User;
|
||||
updatedAt?: string;
|
||||
updatedByUser?: User;
|
||||
lastUsed?: number;
|
||||
}
|
||||
|
||||
export interface CreatePayloadProps {
|
||||
data: APIKeyProps;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CreateAPIKeyProps {
|
||||
name: string;
|
||||
expiresInDays: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AllAPIKeyProps {
|
||||
status: string;
|
||||
data: APIKeyProps[];
|
||||
}
|
||||
|
||||
export interface CreateAPIKeyProp {
|
||||
data: APIKeyProps;
|
||||
}
|
||||
|
||||
export interface DeleteAPIKeyPayloadProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface UpdateAPIKeyProps {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
status: string;
|
||||
data: string;
|
||||
};
|
||||
@@ -108,6 +108,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_FUNNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_FUNNELS_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
API_KEYS: ['ADMIN'],
|
||||
LOGS_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
OLD_LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SHORTCUTS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
4
go.mod
4
go.mod
@@ -372,7 +372,7 @@ require (
|
||||
go.opentelemetry.io/otel/log v0.15.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0
|
||||
go.opentelemetry.io/proto/otlp v1.9.0
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
@@ -381,7 +381,7 @@ require (
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
gopkg.in/telebot.v3 v3.3.8 // indirect
|
||||
k8s.io/client-go v0.35.0 // indirect
|
||||
|
||||
@@ -50,11 +50,6 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
|
||||
)
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
|
||||
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
|
||||
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -45,23 +44,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
|
||||
ID: "GetMyServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets my service account",
|
||||
Description: "This endpoint gets my service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: nil,
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
@@ -69,7 +51,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint gets an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
|
||||
Response: new(serviceaccounttypes.ServiceAccount),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
@@ -79,74 +61,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRoles",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets service account roles",
|
||||
Description: "This endpoint gets all the roles for the existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), 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},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
|
||||
ID: "UpdateMyServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates my service account",
|
||||
Description: "This endpoint gets my service account",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: nil,
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
@@ -164,6 +78,23 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/status", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateStatus), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountStatus",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account status",
|
||||
Description: "This endpoint updates an existing service account status",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccountStatus),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
@@ -205,7 +136,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint lists the service account keys",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
|
||||
Response: make([]*serviceaccounttypes.FactorAPIKey, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
|
||||
@@ -43,6 +43,74 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
|
||||
ID: "CreateAPIKey",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create api key",
|
||||
Description: "This endpoint creates an api key",
|
||||
Request: new(types.PostableAPIKey),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.GettableAPIKey),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
|
||||
ID: "ListAPIKeys",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List api keys",
|
||||
Description: "This endpoint lists all api keys",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.GettableAPIKey, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
|
||||
ID: "UpdateAPIKey",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update api key",
|
||||
Description: "This endpoint updates an api key",
|
||||
Request: new(types.StorableAPIKey),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
|
||||
ID: "RevokeAPIKey",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Revoke api key",
|
||||
Description: "This endpoint revokes an api key",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListUsersDeprecated), handler.OpenAPIDef{
|
||||
ID: "ListUsersDeprecated",
|
||||
Tags: []string{"users"},
|
||||
|
||||
@@ -3,15 +3,10 @@ package auditor
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeAuditExportFailed = errors.MustNewCode("audit_export_failed")
|
||||
)
|
||||
|
||||
type Auditor interface {
|
||||
factory.ServiceWithHealthy
|
||||
|
||||
|
||||
@@ -21,15 +21,11 @@ func newTestSettings() factory.ScopedProviderSettings {
|
||||
|
||||
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
|
||||
return audittypes.AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventName: audittypes.NewEventName(resource, action),
|
||||
AuditAttributes: audittypes.AuditAttributes{
|
||||
Action: action,
|
||||
Outcome: audittypes.OutcomeSuccess,
|
||||
},
|
||||
ResourceAttributes: audittypes.ResourceAttributes{
|
||||
ResourceName: resource,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
EventName: audittypes.NewEventName(resource, action),
|
||||
ResourceName: resource,
|
||||
Action: action,
|
||||
Outcome: audittypes.OutcomeSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package auditor
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
var _ factory.Config = (*Config)(nil)
|
||||
|
||||
type Config struct {
|
||||
// Provider specifies the audit export implementation to use.
|
||||
Provider string `mapstructure:"provider"`
|
||||
|
||||
// BufferSize is the async channel capacity for audit events.
|
||||
@@ -30,12 +28,18 @@ type Config struct {
|
||||
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
|
||||
// Fields map to go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp options.
|
||||
type OTLPHTTPConfig struct {
|
||||
// Endpoint is the target scheme://host:port of the OTLP HTTP endpoint.
|
||||
Endpoint *url.URL `mapstructure:"endpoint"`
|
||||
// Endpoint is the target host:port (without scheme or path).
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
|
||||
// URLPath overrides the default URL path (/v1/logs).
|
||||
URLPath string `mapstructure:"url_path"`
|
||||
|
||||
// Insecure disables TLS, using HTTP instead of HTTPS.
|
||||
Insecure bool `mapstructure:"insecure"`
|
||||
|
||||
// Compression sets the compression strategy. Supported: "none", "gzip".
|
||||
Compression string `mapstructure:"compression"`
|
||||
|
||||
// Timeout is the maximum duration for an export attempt.
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
|
||||
@@ -67,12 +71,10 @@ func newConfig() factory.Config {
|
||||
BatchSize: 100,
|
||||
FlushInterval: time.Second,
|
||||
OTLPHTTP: OTLPHTTPConfig{
|
||||
Endpoint: &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:4318",
|
||||
Path: "/v1/logs",
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
Endpoint: "localhost:4318",
|
||||
URLPath: "/v1/logs",
|
||||
Compression: "none",
|
||||
Timeout: 10 * time.Second,
|
||||
Retry: RetryConfig{
|
||||
Enabled: true,
|
||||
InitialInterval: 5 * time.Second,
|
||||
@@ -91,24 +93,14 @@ func (c Config) Validate() error {
|
||||
if c.BufferSize <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::buffer_size must be greater than 0")
|
||||
}
|
||||
|
||||
if c.BatchSize <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::batch_size must be greater than 0")
|
||||
}
|
||||
|
||||
if c.FlushInterval <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::flush_interval must be greater than 0")
|
||||
}
|
||||
|
||||
if c.BatchSize > c.BufferSize {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::batch_size must not exceed auditor::buffer_size")
|
||||
}
|
||||
|
||||
if c.Provider == "otlphttp" {
|
||||
if c.OTLPHTTP.Endpoint == nil {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::otlphttp::endpoint must be set when provider is otlphttp")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func New(store authtypes.AuthNStore) *AuthN {
|
||||
}
|
||||
|
||||
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
|
||||
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
|
||||
user, factorPassword, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -30,5 +30,11 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
|
||||
}
|
||||
|
||||
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
|
||||
if len(userRoles) == 0 {
|
||||
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
|
||||
}
|
||||
|
||||
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
|
||||
|
||||
return authtypes.NewIdentity(user.ID, orgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
|
||||
}
|
||||
|
||||
@@ -97,7 +97,11 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
|
||||
if len(roles) != len(names) {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", names)
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
authtypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided names: %v", names,
|
||||
)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
@@ -118,7 +122,11 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
|
||||
}
|
||||
|
||||
if len(roles) != len(ids) {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", ids)
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(
|
||||
nil,
|
||||
authtypes.ErrCodeRoleNotFound,
|
||||
"not all roles found for the provided ids: %v", ids,
|
||||
)
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
|
||||
@@ -148,12 +148,7 @@ func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, tuples, nil)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to grant roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleNames []string, updatedRoleNames []string, subject string) error {
|
||||
@@ -185,13 +180,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, nil, tuples)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to revoke roles: %v to subject: %s", names, subject)
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.Write(ctx, nil, tuples)
|
||||
}
|
||||
|
||||
func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID, managedRoles []*authtypes.Role) error {
|
||||
|
||||
@@ -140,22 +140,9 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
|
||||
}
|
||||
|
||||
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector, roleSelectors []authtypes.Selector) error {
|
||||
subject := ""
|
||||
switch claims.Principal {
|
||||
case authtypes.PrincipalUser:
|
||||
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = user
|
||||
case authtypes.PrincipalServiceAccount:
|
||||
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject = serviceAccount
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)
|
||||
|
||||
@@ -20,12 +20,6 @@ var (
|
||||
CodeLicenseUnavailable = Code{"license_unavailable"}
|
||||
)
|
||||
|
||||
var (
|
||||
// Used when reverse engineering an error from a response that doesn't have a code.
|
||||
// This should never be used in the codebase, and if it is, it's a bug that should be fixed by using proper error handling and including error codes in responses.
|
||||
CodeUnset = Code{"unset"}
|
||||
)
|
||||
|
||||
var (
|
||||
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
|
||||
)
|
||||
|
||||
@@ -15,16 +15,14 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
|
||||
type Handler interface {
|
||||
http.Handler
|
||||
ServeOpenAPI(openapi.OperationContext)
|
||||
AuditDef() *AuditDef
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
auditDef *AuditDef
|
||||
}
|
||||
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
|
||||
// Remove duplicate error status codes
|
||||
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
|
||||
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
|
||||
@@ -38,16 +36,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
|
||||
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
|
||||
}
|
||||
|
||||
handler := &handler{
|
||||
return &handler{
|
||||
handlerFunc: handlerFunc,
|
||||
openAPIDef: openAPIDef,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(handler)
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -128,8 +120,5 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
openapi.WithHTTPStatus(statusCode),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) AuditDef() *AuditDef {
|
||||
return handler.auditDef
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
)
|
||||
|
||||
// Option configures optional behaviour on a handler created by New.
|
||||
type Option func(*handler)
|
||||
|
||||
type AuditDef struct {
|
||||
ResourceName string // AuthZ Typeable.Name() value, e.g. "dashboard", "user".
|
||||
Action audittypes.Action // create, update, delete, login, etc.
|
||||
Category audittypes.ActionCategory // access_control, configuration_change, etc.
|
||||
ResourceIDParam string // Gorilla mux path param name for the resource ID.
|
||||
}
|
||||
|
||||
// WithAudit attaches an AuditDef to the handler. The actual audit event
|
||||
// emission is handled by the middleware layer, which reads the AuditDef
|
||||
// from the matched route's handler.
|
||||
func WithAuditDef(def AuditDef) Option {
|
||||
return func(h *handler) {
|
||||
h.auditDef = &def
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
logMessage = "::RECEIVED-REQUEST::"
|
||||
)
|
||||
|
||||
type Audit struct {
|
||||
logger *slog.Logger
|
||||
excludedRoutes map[string]struct{}
|
||||
auditor auditor.Auditor
|
||||
}
|
||||
|
||||
func NewAudit(logger *slog.Logger, excludedRoutes []string, auditor auditor.Auditor) *Audit {
|
||||
excludedRoutesMap := make(map[string]struct{})
|
||||
for _, route := range excludedRoutes {
|
||||
excludedRoutesMap[route] = struct{}{}
|
||||
}
|
||||
|
||||
return &Audit{
|
||||
logger: logger.With(slog.String("pkg", pkgname)),
|
||||
excludedRoutes: excludedRoutesMap,
|
||||
auditor: auditor,
|
||||
}
|
||||
}
|
||||
|
||||
func (middleware *Audit) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
start := time.Now()
|
||||
host, port, _ := net.SplitHostPort(req.Host)
|
||||
path, err := mux.CurrentRoute(req).GetPathTemplate()
|
||||
if err != nil {
|
||||
path = req.URL.Path
|
||||
}
|
||||
|
||||
fields := []any{
|
||||
string(semconv.ClientAddressKey), req.RemoteAddr,
|
||||
string(semconv.UserAgentOriginalKey), req.UserAgent(),
|
||||
string(semconv.ServerAddressKey), host,
|
||||
string(semconv.ServerPortKey), port,
|
||||
string(semconv.HTTPRequestSizeKey), req.ContentLength,
|
||||
string(semconv.HTTPRouteKey), path,
|
||||
}
|
||||
|
||||
responseBuffer := &byteBuffer{}
|
||||
writer := newResponseCapture(rw, responseBuffer)
|
||||
next.ServeHTTP(writer, req)
|
||||
|
||||
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
|
||||
|
||||
// Logging or Audit: skip if the matched route is in the excluded list. This allows us to exclude noisy routes (e.g. health checks) from both logging and audit.
|
||||
if _, ok := middleware.excludedRoutes[path]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
middleware.emitAuditEvent(req, writer, path)
|
||||
|
||||
fields = append(fields,
|
||||
string(semconv.HTTPResponseStatusCodeKey), statusCode,
|
||||
string(semconv.HTTPServerRequestDurationName), time.Since(start),
|
||||
)
|
||||
if writeErr != nil {
|
||||
fields = append(fields, errors.Attr(writeErr))
|
||||
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
|
||||
} else {
|
||||
if responseBuffer.Len() != 0 {
|
||||
fields = append(fields, "response.body", responseBuffer.String())
|
||||
}
|
||||
|
||||
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCapture, routeTemplate string) {
|
||||
if middleware.auditor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
def := auditDefFromRequest(req)
|
||||
if def == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// extract claims
|
||||
claims, _ := authtypes.ClaimsFromContext(req.Context())
|
||||
|
||||
// extract status code
|
||||
statusCode := writer.StatusCode()
|
||||
|
||||
// extract traces.
|
||||
span := trace.SpanFromContext(req.Context())
|
||||
|
||||
// extract error details.
|
||||
var errorType, errorCode string
|
||||
if statusCode >= 400 {
|
||||
errorType = render.ErrorTypeFromStatusCode(statusCode)
|
||||
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
|
||||
}
|
||||
|
||||
event := audittypes.NewAuditEventFromHTTPRequest(
|
||||
req,
|
||||
routeTemplate,
|
||||
statusCode,
|
||||
span.SpanContext().TraceID(),
|
||||
span.SpanContext().SpanID(),
|
||||
def.Action,
|
||||
def.Category,
|
||||
claims,
|
||||
resourceIDFromRequest(req, def.ResourceIDParam),
|
||||
def.ResourceName,
|
||||
errorType,
|
||||
errorCode,
|
||||
)
|
||||
|
||||
middleware.auditor.Audit(req.Context(), event)
|
||||
}
|
||||
|
||||
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
actualHandler := route.GetHandler()
|
||||
if actualHandler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The type assertion is necessary because route.GetHandler() returns
|
||||
// http.Handler, and not every http.Handler on the mux is a handler.Handler
|
||||
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
|
||||
provider, ok := actualHandler.(handler.Handler)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.AuditDef()
|
||||
}
|
||||
|
||||
func resourceIDFromRequest(req *http.Request, param string) string {
|
||||
if param == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
vars := mux.Vars(req)
|
||||
if vars == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return vars[param]
|
||||
}
|
||||
@@ -40,6 +40,17 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
|
||||
if err := claims.IsViewer(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
|
||||
@@ -79,6 +90,17 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
|
||||
if err := claims.IsEditor(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
|
||||
@@ -117,6 +139,17 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
|
||||
if err := claims.IsAdmin(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
}
|
||||
@@ -153,28 +186,13 @@ func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
id := mux.Vars(req)["id"]
|
||||
if err := claims.IsSelfAccess(id); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(
|
||||
req.Context(),
|
||||
claims,
|
||||
valuer.MustNewUUID(claims.OrgID),
|
||||
authtypes.RelationAssignee,
|
||||
authtypes.TypeableRole,
|
||||
selectors,
|
||||
selectors,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
id := mux.Vars(req)["id"]
|
||||
if err := claims.IsSelfAccess(id); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,10 +61,8 @@ func (m *IdentN) Wrap(next http.Handler) http.Handler {
|
||||
ctx = authtypes.NewContextWithClaims(ctx, claims)
|
||||
|
||||
comment := ctxtypes.CommentFromContext(ctx)
|
||||
comment.Set("identn_provider", claims.IdentNProvider.StringValue())
|
||||
comment.Set("identn_provider", claims.IdentNProvider)
|
||||
comment.Set("user_id", claims.UserID)
|
||||
comment.Set("service_account_id", claims.ServiceAccountID)
|
||||
comment.Set("principal", claims.Principal.StringValue())
|
||||
comment.Set("org_id", claims.OrgID)
|
||||
ctx = ctxtypes.NewContextWithComment(ctx, comment)
|
||||
|
||||
|
||||
81
pkg/http/middleware/logging.go
Normal file
81
pkg/http/middleware/logging.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
logMessage string = "::RECEIVED-REQUEST::"
|
||||
)
|
||||
|
||||
type Logging struct {
|
||||
logger *slog.Logger
|
||||
excludedRoutes map[string]struct{}
|
||||
}
|
||||
|
||||
func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging {
|
||||
excludedRoutesMap := make(map[string]struct{})
|
||||
for _, route := range excludedRoutes {
|
||||
excludedRoutesMap[route] = struct{}{}
|
||||
}
|
||||
|
||||
return &Logging{
|
||||
logger: logger.With(slog.String("pkg", pkgname)),
|
||||
excludedRoutes: excludedRoutesMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (middleware *Logging) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
start := time.Now()
|
||||
host, port, _ := net.SplitHostPort(req.Host)
|
||||
path, err := mux.CurrentRoute(req).GetPathTemplate()
|
||||
if err != nil {
|
||||
path = req.URL.Path
|
||||
}
|
||||
|
||||
fields := []any{
|
||||
string(semconv.ClientAddressKey), req.RemoteAddr,
|
||||
string(semconv.UserAgentOriginalKey), req.UserAgent(),
|
||||
string(semconv.ServerAddressKey), host,
|
||||
string(semconv.ServerPortKey), port,
|
||||
string(semconv.HTTPRequestSizeKey), req.ContentLength,
|
||||
string(semconv.HTTPRouteKey), path,
|
||||
}
|
||||
|
||||
badResponseBuffer := new(bytes.Buffer)
|
||||
writer := newBadResponseLoggingWriter(rw, badResponseBuffer)
|
||||
next.ServeHTTP(writer, req)
|
||||
|
||||
// if the path is in the excludedRoutes map, don't log
|
||||
if _, ok := middleware.excludedRoutes[path]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
statusCode, err := writer.StatusCode(), writer.WriteError()
|
||||
fields = append(fields,
|
||||
string(semconv.HTTPResponseStatusCodeKey), statusCode,
|
||||
string(semconv.HTTPServerRequestDurationName), time.Since(start),
|
||||
)
|
||||
if err != nil {
|
||||
fields = append(fields, errors.Attr(err))
|
||||
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
|
||||
} else {
|
||||
// when the status code is 400 or >=500, and the response body is not empty.
|
||||
if badResponseBuffer.Len() != 0 {
|
||||
fields = append(fields, "response.body", badResponseBuffer.String())
|
||||
}
|
||||
|
||||
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
@@ -9,156 +10,118 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
|
||||
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
|
||||
)
|
||||
|
||||
// Wraps an http.ResponseWriter to capture the status code,
|
||||
// write errors, and (for error responses) a bounded slice of the body.
|
||||
type responseCapture interface {
|
||||
type badResponseLoggingWriter interface {
|
||||
http.ResponseWriter
|
||||
|
||||
// StatusCode returns the HTTP status code written to the response.
|
||||
// Get the status code.
|
||||
StatusCode() int
|
||||
|
||||
// WriteError returns the error (if any) from the downstream Write call.
|
||||
// Get the error while writing.
|
||||
WriteError() error
|
||||
|
||||
// BodyBytes returns the captured response body bytes. Only populated
|
||||
// for error responses (status >= 400).
|
||||
BodyBytes() []byte
|
||||
}
|
||||
|
||||
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
|
||||
b := nonFlushingResponseCapture{
|
||||
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
|
||||
b := nonFlushingBadResponseLoggingWriter{
|
||||
rw: rw,
|
||||
buffer: buffer,
|
||||
captureBody: false,
|
||||
bodyBytesLeft: maxResponseBodyCapture,
|
||||
logBody: false,
|
||||
bodyBytesLeft: maxResponseBodyInLogs,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
if f, ok := rw.(http.Flusher); ok {
|
||||
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
|
||||
return &flushingBadResponseLoggingWriter{b, f}
|
||||
}
|
||||
|
||||
return &b
|
||||
}
|
||||
|
||||
// byteBuffer is a minimal write-only buffer used to capture response bodies.
|
||||
type byteBuffer struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (b *byteBuffer) Write(p []byte) (int, error) {
|
||||
b.buf = append(b.buf, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *byteBuffer) WriteString(s string) (int, error) {
|
||||
b.buf = append(b.buf, s...)
|
||||
return len(s), nil
|
||||
}
|
||||
|
||||
func (b *byteBuffer) Bytes() []byte {
|
||||
return b.buf
|
||||
}
|
||||
|
||||
func (b *byteBuffer) Len() int {
|
||||
return len(b.buf)
|
||||
}
|
||||
|
||||
func (b *byteBuffer) String() string {
|
||||
return string(b.buf)
|
||||
}
|
||||
|
||||
type nonFlushingResponseCapture struct {
|
||||
type nonFlushingBadResponseLoggingWriter struct {
|
||||
rw http.ResponseWriter
|
||||
buffer *byteBuffer
|
||||
captureBody bool
|
||||
buffer io.Writer
|
||||
logBody bool
|
||||
bodyBytesLeft int
|
||||
statusCode int
|
||||
writeError error
|
||||
writeError error // The error returned when downstream Write() fails.
|
||||
}
|
||||
|
||||
type flushingResponseCapture struct {
|
||||
nonFlushingResponseCapture
|
||||
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
|
||||
type flushingBadResponseLoggingWriter struct {
|
||||
nonFlushingBadResponseLoggingWriter
|
||||
f http.Flusher
|
||||
}
|
||||
|
||||
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
|
||||
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
|
||||
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
|
||||
return writer.rw
|
||||
}
|
||||
|
||||
// Header returns the header map that will be sent by WriteHeader.
|
||||
func (writer *nonFlushingResponseCapture) Header() http.Header {
|
||||
// Implements ResponseWriter.
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
|
||||
return writer.rw.Header()
|
||||
}
|
||||
|
||||
// WriteHeader writes the HTTP response header.
|
||||
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
|
||||
writer.statusCode = statusCode
|
||||
if statusCode >= 400 {
|
||||
writer.captureBody = true
|
||||
if statusCode >= 500 || statusCode == 400 {
|
||||
writer.logBody = true
|
||||
}
|
||||
|
||||
writer.rw.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
// Write writes HTTP response data.
|
||||
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
|
||||
// Writes HTTP response data.
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
|
||||
if writer.statusCode == 0 {
|
||||
// WriteHeader has (probably) not been called, so we need to call it with StatusOK to fulfill the interface contract.
|
||||
// https://godoc.org/net/http#ResponseWriter
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 204 No Content is a success response that indicates that the request has been successfully processed and that the response body is intentionally empty.
|
||||
if writer.statusCode == 204 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
n, err := writer.rw.Write(data)
|
||||
if writer.captureBody {
|
||||
if writer.logBody {
|
||||
writer.captureResponseBody(data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writer.writeError = err
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Hijack hijacks the first response writer that is a Hijacker.
|
||||
func (writer *nonFlushingResponseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hj, ok := writer.rw.(http.Hijacker)
|
||||
if ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
return nil, nil, errors.NewInternalf(errors.CodeInternal, "cannot cast underlying response writer to Hijacker")
|
||||
}
|
||||
|
||||
func (writer *nonFlushingResponseCapture) StatusCode() int {
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) StatusCode() int {
|
||||
return writer.statusCode
|
||||
}
|
||||
|
||||
func (writer *nonFlushingResponseCapture) WriteError() error {
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
|
||||
return writer.writeError
|
||||
}
|
||||
|
||||
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
|
||||
return writer.buffer.Bytes()
|
||||
}
|
||||
|
||||
func (writer *flushingResponseCapture) Flush() {
|
||||
func (writer *flushingBadResponseLoggingWriter) Flush() {
|
||||
writer.f.Flush()
|
||||
}
|
||||
|
||||
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
|
||||
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
|
||||
if len(data) > writer.bodyBytesLeft {
|
||||
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
|
||||
_, _ = writer.buffer.WriteString("...")
|
||||
_, _ = io.WriteString(writer.buffer, "...")
|
||||
writer.bodyBytesLeft = 0
|
||||
writer.captureBody = false
|
||||
writer.logBody = false
|
||||
} else {
|
||||
_, _ = writer.buffer.Write(data)
|
||||
writer.bodyBytesLeft -= len(data)
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResponseCapture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
expectedStatus int
|
||||
expectedBodyBytes string
|
||||
expectedClientBody string
|
||||
}{
|
||||
{
|
||||
name: "Success_DoesNotCaptureBody",
|
||||
handler: func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, _ = rw.Write([]byte(`{"status":"success","data":{"id":"123"}}`))
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBodyBytes: "",
|
||||
expectedClientBody: `{"status":"success","data":{"id":"123"}}`,
|
||||
},
|
||||
{
|
||||
name: "Error_CapturesBody",
|
||||
handler: func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusForbidden)
|
||||
_, _ = rw.Write([]byte(`{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`))
|
||||
},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedBodyBytes: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
|
||||
expectedClientBody: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
|
||||
},
|
||||
{
|
||||
name: "Error_TruncatesAtMaxCapture",
|
||||
handler: func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = rw.Write([]byte(strings.Repeat("x", maxResponseBodyCapture+100)))
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedBodyBytes: strings.Repeat("x", maxResponseBodyCapture) + "...",
|
||||
expectedClientBody: strings.Repeat("x", maxResponseBodyCapture+100),
|
||||
},
|
||||
{
|
||||
name: "NoContent_SuppressesWrite",
|
||||
handler: func(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
_, _ = rw.Write([]byte("should be suppressed"))
|
||||
},
|
||||
expectedStatus: http.StatusNoContent,
|
||||
expectedBodyBytes: "",
|
||||
expectedClientBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var captured responseCapture
|
||||
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
buf := &byteBuffer{}
|
||||
captured = newResponseCapture(rw, buf)
|
||||
testCase.handler(captured, req)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
clientBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
assert.Equal(t, testCase.expectedStatus, captured.StatusCode())
|
||||
assert.Equal(t, testCase.expectedBodyBytes, string(captured.BodyBytes()))
|
||||
assert.Equal(t, testCase.expectedClientBody, string(clientBody))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -43,45 +42,6 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
|
||||
_, _ = rw.Write(body)
|
||||
}
|
||||
|
||||
func ErrorCodeFromBody(body []byte) string {
|
||||
code := gjson.GetBytes(body, "error.code").String()
|
||||
|
||||
// This should never return empty since we only call this function on responses that were generated by us.
|
||||
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
|
||||
if code == "" {
|
||||
return errors.CodeUnset.String()
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
func ErrorTypeFromStatusCode(statusCode int) string {
|
||||
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
|
||||
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return errors.TypeInvalidInput.String()
|
||||
case http.StatusNotFound:
|
||||
return errors.TypeNotFound.String()
|
||||
case http.StatusConflict:
|
||||
return errors.TypeAlreadyExists.String()
|
||||
case http.StatusUnauthorized:
|
||||
return errors.TypeUnauthenticated.String()
|
||||
case http.StatusNotImplemented:
|
||||
return errors.TypeUnsupported.String()
|
||||
case http.StatusForbidden:
|
||||
return errors.TypeForbidden.String()
|
||||
case statusClientClosedConnection:
|
||||
return errors.TypeCanceled.String()
|
||||
case http.StatusGatewayTimeout:
|
||||
return errors.TypeTimeout.String()
|
||||
case http.StatusUnavailableForLegalReasons:
|
||||
return errors.TypeLicenseUnavailable.String()
|
||||
default:
|
||||
return errors.TypeInternal.String()
|
||||
}
|
||||
}
|
||||
|
||||
func Error(rw http.ResponseWriter, cause error) {
|
||||
// Derive the http code from the error type
|
||||
t, _, _, _, _, _ := errors.Unwrapb(cause)
|
||||
|
||||
@@ -58,31 +58,6 @@ func TestSuccess(t *testing.T) {
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestErrorCodeFromBody(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "ValidErrorResponse",
|
||||
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
|
||||
wantCode: "authz_forbidden",
|
||||
},
|
||||
{
|
||||
name: "InvalidJSON",
|
||||
body: []byte(`not json`),
|
||||
wantCode: "unset",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -10,29 +10,33 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
// todo: will move this in types layer with service account integration
|
||||
type apiKeyTokenKey struct{}
|
||||
|
||||
type provider struct {
|
||||
serviceAccount serviceaccount.Module
|
||||
config identn.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
sfGroup *singleflight.Group
|
||||
store sqlstore.SQLStore
|
||||
config identn.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
sfGroup *singleflight.Group
|
||||
}
|
||||
|
||||
func NewFactory(serviceAccount serviceaccount.Module) factory.ProviderFactory[identn.IdentN, identn.Config] {
|
||||
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
|
||||
return New(serviceAccount, config, providerSettings)
|
||||
return New(providerSettings, store, config)
|
||||
})
|
||||
}
|
||||
|
||||
func New(serviceAccount serviceaccount.Module, config identn.Config, providerSettings factory.ProviderSettings) (identn.IdentN, error) {
|
||||
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, config identn.Config) (identn.IdentN, error) {
|
||||
return &provider{
|
||||
serviceAccount: serviceAccount,
|
||||
config: config,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
|
||||
sfGroup: &singleflight.Group{},
|
||||
store: store,
|
||||
config: config,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
|
||||
sfGroup: &singleflight.Group{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -50,44 +54,75 @@ func (provider *provider) Test(req *http.Request) bool {
|
||||
}
|
||||
|
||||
func (provider *provider) Pre(req *http.Request) *http.Request {
|
||||
apiKey := provider.extractToken(req)
|
||||
if apiKey == "" {
|
||||
token := provider.extractToken(req)
|
||||
if token == "" {
|
||||
return req
|
||||
}
|
||||
|
||||
ctx := authtypes.NewContextWithAPIKey(req.Context(), apiKey)
|
||||
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
|
||||
ctx := req.Context()
|
||||
apiKey, err := authtypes.APIKeyFromContext(ctx)
|
||||
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
|
||||
if !ok || apiKeyToken == "" {
|
||||
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing api key")
|
||||
}
|
||||
|
||||
var apiKey types.StorableAPIKey
|
||||
err := provider.
|
||||
store.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&apiKey).
|
||||
Where("token = ?", apiKeyToken).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := provider.serviceAccount.GetIdentity(ctx, apiKey)
|
||||
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
|
||||
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
|
||||
}
|
||||
|
||||
var user types.User
|
||||
err = provider.
|
||||
store.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&user).
|
||||
Where("id = ?", apiKey.UserID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity := authtypes.NewIdentity(user.ID, user.OrgID, user.Email, apiKey.Role, provider.Name())
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
|
||||
apiKey, err := authtypes.APIKeyFromContext(ctx)
|
||||
if err != nil {
|
||||
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
|
||||
if !ok || apiKeyToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_, _, _ = provider.sfGroup.Do(apiKey, func() (any, error) {
|
||||
if err := provider.serviceAccount.SetLastObservedAt(ctx, apiKey, time.Now()); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
|
||||
return false, err
|
||||
_, _, _ = provider.sfGroup.Do(apiKeyToken, func() (any, error) {
|
||||
_, err := provider.
|
||||
store.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.StorableAPIKey)).
|
||||
Set("last_used = ?", time.Now()).
|
||||
Where("token = ?", apiKeyToken).
|
||||
Where("revoked = false").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to update last used of api key", errors.Attr(err))
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) extractToken(req *http.Request) string {
|
||||
|
||||
@@ -79,15 +79,22 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rootUser, _, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
|
||||
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider.identity = authtypes.NewPrincipalUserIdentity(
|
||||
if len(userRoles) == 0 {
|
||||
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
|
||||
}
|
||||
|
||||
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
|
||||
|
||||
provider.identity = authtypes.NewIdentity(
|
||||
rootUser.ID,
|
||||
rootUser.OrgID,
|
||||
rootUser.Email,
|
||||
role,
|
||||
authtypes.IdentNProviderImpersonation,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,16 +15,12 @@ import (
|
||||
type Instrumentation interface {
|
||||
// Logger returns the Slog logger.
|
||||
Logger() *slog.Logger
|
||||
|
||||
// MeterProvider returns the OpenTelemetry meter provider.
|
||||
MeterProvider() sdkmetric.MeterProvider
|
||||
|
||||
// TracerProvider returns the OpenTelemetry tracer provider.
|
||||
TracerProvider() sdktrace.TracerProvider
|
||||
|
||||
// PrometheusRegisterer returns the Prometheus registerer.
|
||||
PrometheusRegisterer() prometheus.Registerer
|
||||
|
||||
// ToProviderSettings converts instrumentation to provider settings.
|
||||
ToProviderSettings() factory.ProviderSettings
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ type Module interface {
|
||||
|
||||
Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error)
|
||||
|
||||
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error
|
||||
|
||||
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
@@ -23,12 +22,11 @@ import (
|
||||
|
||||
type handler struct {
|
||||
module dashboard.Module
|
||||
authz authz.AuthZ
|
||||
providerSettings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, authz authz.AuthZ) dashboard.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings, authz: authz}
|
||||
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings}
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -59,7 +57,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
dashboardMigrator.Migrate(ctx, req)
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
|
||||
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.UserID), req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -110,7 +108,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
diff := 0
|
||||
// Allow multiple deletions for API key requests; enforce for others
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer {
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer.StringValue() {
|
||||
diff = 1
|
||||
}
|
||||
|
||||
@@ -157,24 +155,7 @@ func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
selectors := []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
|
||||
}
|
||||
err = handler.authz.CheckWithTupleCreation(
|
||||
ctx,
|
||||
claims,
|
||||
valuer.MustNewUUID(claims.OrgID),
|
||||
authtypes.RelationAssignee,
|
||||
authtypes.TypeableRole,
|
||||
selectors,
|
||||
selectors,
|
||||
)
|
||||
if err == nil {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, isAdmin, *req.Locked)
|
||||
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, claims.Role, *req.Locked)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -99,13 +99,13 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
|
||||
dashboard, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
|
||||
err = dashboard.LockUnlock(lock, role, updatedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package serviceaccount
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Email config for service accounts
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
|
||||
// Analytics collection config for service accounts
|
||||
Analytics AnalyticsConfig `mapstructure:"analytics"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
Domain string `mapstructure:"domain"`
|
||||
}
|
||||
|
||||
type AnalyticsConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("serviceaccount"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Email: EmailConfig{
|
||||
Domain: "signozserviceaccount.com",
|
||||
},
|
||||
Analytics: AnalyticsConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Email.Domain == "" {
|
||||
return errors.New(errors.TypeInvalidInput, serviceaccounttypes.ErrCodeServiceAccountInvalidConfig, "email domain cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, handler.module.Config().Email.Domain, serviceaccounttypes.ServiceAccountStatusActive, valuer.MustNewUUID(claims.OrgID))
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, req.Email, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
|
||||
err = handler.module.Create(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -59,59 +59,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccountWithRoles, err := handler.module.GetWithRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccountWithRoles)
|
||||
}
|
||||
|
||||
func (handler *handler) GetMe(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(claims.ServiceAccountID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccountWithRoles, err := handler.module.GetWithRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccountWithRoles)
|
||||
}
|
||||
|
||||
func (handler *handler) GetRoles(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
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.GetWithRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, serviceAccount.GetRoles())
|
||||
render.Success(rw, http.StatusOK, serviceAccount)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -157,7 +111,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = serviceAccount.Update(req.Name)
|
||||
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -172,7 +126,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) {
|
||||
func (handler *handler) UpdateStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
@@ -180,13 +134,13 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(claims.ServiceAccountID)
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.UpdatableServiceAccount)
|
||||
req := new(serviceaccounttypes.UpdatableServiceAccountStatus)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -198,71 +152,13 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = serviceAccount.Update(req.Name)
|
||||
err = serviceAccount.UpdateStatus(req.Status)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) SetRole(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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteRole(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
|
||||
}
|
||||
|
||||
roleID, err := valuer.NewUUID(mux.Vars(r)["rid"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), id, roleID)
|
||||
err = handler.module.UpdateStatus(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -315,7 +211,7 @@ func (handler *handler) CreateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// this takes care of checking the existence of service account and the org constraint.
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -350,7 +246,7 @@ func (handler *handler) ListFactorAPIKey(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -391,7 +287,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -403,12 +299,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
err = factorAPIKey.Update(req.Name, req.ExpiresAt)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
factorAPIKey.Update(req.Name, req.ExpiresAt)
|
||||
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -438,7 +329,7 @@ func (handler *handler) RevokeFactorAPIKey(rw http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
serviceAccount, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
serviceAccount, err := handler.module.GetWithoutRoles(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -2,181 +2,52 @@ package implserviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cachetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/emailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
emptyOrgID valuer.UUID = valuer.UUID{}
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store serviceaccounttypes.Store
|
||||
authz authz.AuthZ
|
||||
cache cache.Cache
|
||||
analytics analytics.Analytics
|
||||
settings factory.ScopedProviderSettings
|
||||
config serviceaccount.Config
|
||||
store serviceaccounttypes.Store
|
||||
authz authz.AuthZ
|
||||
emailing emailing.Emailing
|
||||
settings factory.ScopedProviderSettings
|
||||
}
|
||||
|
||||
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, cache cache.Cache, analytics analytics.Analytics, providerSettings factory.ProviderSettings, config serviceaccount.Config) serviceaccount.Module {
|
||||
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
|
||||
return &module{store: store, authz: authz, cache: cache, analytics: analytics, settings: settings, config: config}
|
||||
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.store.Create(ctx, serviceAccount)
|
||||
// validates the presence of all roles passed in the create request
|
||||
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, serviceAccount.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.identifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
|
||||
module.trackUser(ctx, orgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetOrCreate(ctx context.Context, orgID valuer.UUID, serviceAccountWithRoles *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
existingServiceAccount, err := module.GetActiveByOrgIDAndName(ctx, serviceAccountWithRoles.OrgID, serviceAccountWithRoles.Name)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingServiceAccount != nil {
|
||||
return existingServiceAccount, nil
|
||||
}
|
||||
|
||||
err = module.Create(ctx, orgID, serviceAccountWithRoles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccountWithRoles, nil
|
||||
}
|
||||
|
||||
func (module *module) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
serviceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.Get(ctx, orgID, serviceAccount.ID)
|
||||
}
|
||||
|
||||
func (module *module) GetWithRoles(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, error) {
|
||||
serviceAccountWithRoles, err := module.store.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccountWithRoles, nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableServiceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
|
||||
serviceAccounts, err := module.store.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccounts, nil
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.store.Update(ctx, orgID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.identifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
|
||||
module.trackUser(ctx, orgID.String(), input.ID.String(), "Service Account Updated", input.Traits())
|
||||
return nil
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
return module.setRole(ctx, orgID, id, role)
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
return module.setRole(ctx, orgID, id, role)
|
||||
}
|
||||
|
||||
func (module *module) DeleteRole(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 err
|
||||
}
|
||||
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = serviceAccount.ErrIfDeleted()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.DeleteServiceAccountRole(ctx, serviceAccount.ID, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Revoke(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = serviceAccount.UpdateStatus(serviceaccounttypes.ServiceAccountStatusDeleted)
|
||||
// authz actions cannot run in sql transactions
|
||||
err = module.authz.Grant(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableServiceAccount := serviceaccounttypes.NewStorableServiceAccount(serviceAccount)
|
||||
storableServiceAccountRoles := serviceaccounttypes.NewStorableServiceAccountRoles(serviceAccount.ID, roles)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, id)
|
||||
err := module.store.Create(ctx, storableServiceAccount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status but do not delete the role mappings as we will use them for audits
|
||||
err = module.store.Update(ctx, orgID, serviceAccount.ServiceAccount)
|
||||
err = module.store.CreateServiceAccountRoles(ctx, storableServiceAccountRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,78 +58,251 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Revoke(ctx, orgID, serviceAccount.RoleNames(), authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.StringValue(), orgID, nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
existingServiceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, serviceAccount.OrgID, serviceAccount.Name)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingServiceAccount != nil {
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// did the orchestration on application layer instead of DB as the ORM also does it anyways for many to many tables.
|
||||
storableServiceAccountRoles, err := module.store.GetServiceAccountRoles(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleIDs := make([]valuer.UUID, len(storableServiceAccountRoles))
|
||||
for idx, sar := range storableServiceAccountRoles {
|
||||
roleIDs[idx] = valuer.MustNewUUID(sar.RoleID)
|
||||
}
|
||||
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rolesNames, err := serviceaccounttypes.NewRolesFromStorableServiceAccountRoles(storableServiceAccountRoles, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, rolesNames)
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) GetWithoutRoles(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// passing []string{} (not nil to prevent panics) roles as the function isn't supposed to put roles.
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, []string{})
|
||||
return serviceAccount, nil
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
|
||||
storableServiceAccounts, err := module.store.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storableServiceAccountRoles, err := module.store.ListServiceAccountRolesByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert the service account roles to structured data
|
||||
saIDToRoleIDs, roleIDs := serviceaccounttypes.GetUniqueRolesAndServiceAccountMapping(storableServiceAccountRoles)
|
||||
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fill in the role fetched data back to service account
|
||||
serviceAccounts := serviceaccounttypes.NewServiceAccountsFromRoles(storableServiceAccounts, roles, saIDToRoleIDs)
|
||||
return serviceAccounts, nil
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the cache when updating status for service account
|
||||
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(id))
|
||||
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, input.Roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// gets the role diff if any to modify grants.
|
||||
grants, revokes := serviceAccount.PatchRoles(input)
|
||||
err = module.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableServiceAccountRoles := serviceaccounttypes.NewStorableServiceAccountRoles(serviceAccount.ID, roles)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete all the service account roles and create new rather than diff here.
|
||||
err = module.store.DeleteServiceAccountRoles(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRoles(ctx, storableServiceAccountRoles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
|
||||
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// revoke all the API keys on disable
|
||||
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status but do not delete the role mappings as we will use them for audits
|
||||
err = module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
serviceAccount, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// revoke from authz first as this cannot run in sql transaction
|
||||
err = module.authz.Revoke(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.RevokeAllFactorAPIKeys(ctx, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.Delete(ctx, serviceAccount.OrgID, serviceAccount.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.identifyUser(ctx, orgID.String(), id.String(), serviceAccount.Traits())
|
||||
module.trackUser(ctx, orgID.String(), id.String(), "Service Account Deleted", map[string]any{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
err := module.store.CreateFactorAPIKey(ctx, factorAPIKey)
|
||||
storableFactorAPIKey := serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey)
|
||||
|
||||
err := module.store.CreateFactorAPIKey(ctx, storableFactorAPIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
|
||||
if err == nil {
|
||||
module.trackUser(ctx, serviceAccount.OrgID.StringValue(), serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "New API Key created for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
|
||||
"Name": serviceAccount.Name,
|
||||
"KeyName": factorAPIKey.Name,
|
||||
"KeyID": factorAPIKey.ID.String(),
|
||||
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) GetOrCreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
existingFactorAPIKey, err := module.store.GetFactorAPIKeyByName(ctx, factorAPIKey.ServiceAccountID, factorAPIKey.Name)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingFactorAPIKey != nil {
|
||||
return existingFactorAPIKey, nil
|
||||
}
|
||||
|
||||
err = module.CreateFactorAPIKey(ctx, factorAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return factorAPIKey, nil
|
||||
}
|
||||
|
||||
func (module *module) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
factorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
|
||||
storableFactorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return factorAPIKey, nil
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorable(storableFactorAPIKey), nil
|
||||
}
|
||||
|
||||
func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error) {
|
||||
factorAPIKeys, err := module.store.ListFactorAPIKey(ctx, serviceAccountID)
|
||||
storables, err := module.store.ListFactorAPIKey(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return factorAPIKeys, nil
|
||||
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, orgID valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, factorAPIKey)
|
||||
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
|
||||
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the cache when updating the factor api key
|
||||
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
|
||||
module.trackUser(ctx, orgID.String(), serviceAccountID.String(), "API Key updated", factorAPIKey.Traits())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -278,143 +322,14 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the cache when revoking the factor api key
|
||||
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
|
||||
module.trackUser(ctx, serviceAccount.OrgID.StringValue(), serviceAccountID.String(), "API Key revoked", factorAPIKey.Traits())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Config() serviceaccount.Config {
|
||||
return module.config
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
count, err := module.store.CountByOrgID(ctx, orgID)
|
||||
if err == nil {
|
||||
stats["serviceaccount.count"] = count
|
||||
}
|
||||
|
||||
count, err = module.store.CountFactorAPIKeysByOrgID(ctx, orgID)
|
||||
if err == nil {
|
||||
stats["serviceaccount.keys.count"] = count
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (module *module) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
|
||||
apiKey, err := module.getOrGetSetAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := apiKey.IsExpired(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity, err := module.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (module *module) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
|
||||
return module.store.UpdateLastObservedAt(ctx, key, lastObservedAt)
|
||||
}
|
||||
|
||||
func (module *module) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
factorAPIKey := new(serviceaccounttypes.FactorAPIKey)
|
||||
err := module.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIKey)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return factorAPIKey, nil
|
||||
}
|
||||
|
||||
factorAPIKey, err = module.store.GetFactorAPIKeyByKey(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIKey, time.Duration(factorAPIKey.ExpiresAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return factorAPIKey, nil
|
||||
}
|
||||
|
||||
func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID valuer.UUID) (*authtypes.Identity, error) {
|
||||
identity := new(authtypes.Identity)
|
||||
err := module.cache.Get(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity = storableServiceAccount.ToIdentity()
|
||||
err = module.cache.Set(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
serviceAccountRole, err := serviceAccount.AddRole(role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, id.String(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
|
||||
"Name": serviceAccount.Name,
|
||||
"KeyName": factorAPIKey.Name,
|
||||
"KeyID": factorAPIKey.ID.String(),
|
||||
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
|
||||
}); err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) trackUser(ctx context.Context, orgID string, userID string, event string, attrs map[string]any) {
|
||||
if module.config.Analytics.Enabled {
|
||||
module.analytics.TrackUser(ctx, orgID, userID, event, attrs)
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) identifyUser(ctx context.Context, orgID string, userID string, traits map[string]any) {
|
||||
if module.config.Analytics.Enabled {
|
||||
module.analytics.IdentifyUser(ctx, orgID, userID, traits)
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyCacheKey(apiKey string) string {
|
||||
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
|
||||
}
|
||||
|
||||
func identityCacheKey(serviceAccountID valuer.UUID) string {
|
||||
return "identity::" + serviceAccountID.String()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package implserviceaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
@@ -17,7 +16,7 @@ func NewStore(sqlstore sqlstore.SQLStore) serviceaccounttypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, storable *serviceaccounttypes.ServiceAccount) error {
|
||||
func (store *store) Create(ctx context.Context, storable *serviceaccounttypes.StorableServiceAccount) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -31,8 +30,8 @@ func (store *store) Create(ctx context.Context, storable *serviceaccounttypes.Se
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -49,28 +48,8 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetWithRoles(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccountWithRoles)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Relation("ServiceAccountRoles").
|
||||
Relation("ServiceAccountRoles.Role").
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with id: %s doesn't exist in org: %s", id, orgID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -79,17 +58,17 @@ func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UU
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Where("status = ?", serviceaccounttypes.ServiceAccountStatusActive).
|
||||
Where("status = ?", serviceaccounttypes.StatusActive).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "active service account with name: %s doesn't exist in org: %s", name, orgID.String())
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with name: %s doesn't exist in org: %s", name, orgID.String())
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storable := new(serviceaccounttypes.StorableServiceAccount)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -105,25 +84,8 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
storable := new(serviceaccounttypes.ServiceAccount)
|
||||
|
||||
count, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int64(count), nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
|
||||
storables := make([]*serviceaccounttypes.ServiceAccount, 0)
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccount, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccount, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -139,7 +101,7 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceacco
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storable *serviceaccounttypes.ServiceAccount) error {
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storable *serviceaccounttypes.StorableServiceAccount) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -148,21 +110,6 @@ func (store *store) Update(ctx context.Context, orgID valuer.UUID, storable *ser
|
||||
WherePK().
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountAlreadyExists, "service account with name: %s already exists", storable.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccountRole *serviceaccounttypes.ServiceAccountRole) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(serviceAccountRole).
|
||||
On("CONFLICT (service_account_id, role_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,14 +117,14 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
|
||||
func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.ServiceAccountRole)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Where("role_id = ?", roleID).
|
||||
Model(new(serviceaccounttypes.StorableServiceAccount)).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -186,7 +133,73 @@ func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceaccounttypes.FactorAPIKey) error {
|
||||
func (store *store) CreateServiceAccountRoles(ctx context.Context, storables []*serviceaccounttypes.StorableServiceAccountRole) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&storables).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleAlreadyExists, "duplicate role assignments for service account")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetServiceAccountRoles(ctx context.Context, id valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccountRole, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccountRole, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("service_account_id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
// no need to wrap not found here as this is many to many table
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) ListServiceAccountRolesByOrgID(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccountRole, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableServiceAccountRole, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Join("JOIN service_account").
|
||||
JoinOn("service_account.id = service_account_role.service_account_id").
|
||||
Where("service_account.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteServiceAccountRoles(ctx context.Context, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.StorableServiceAccountRole)).
|
||||
Where("service_account_id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceaccounttypes.StorableFactorAPIKey) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
@@ -200,8 +213,8 @@ func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceacc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.FactorAPIKey)
|
||||
func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.StorableFactorAPIKey)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -218,62 +231,8 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetFactorAPIKeyByKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.FactorAPIKey)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("key = ?", key).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with key: %s doesn't exist.", key)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetFactorAPIKeyByName(ctx context.Context, serviceAccountID valuer.UUID, name string) (*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storable := new(serviceaccounttypes.FactorAPIKey)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("service_account_id = ?", serviceAccountID.String()).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with name: %s doesn't exist in service account: %s", name, serviceAccountID.String())
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) CountFactorAPIKeysByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
storable := new(serviceaccounttypes.FactorAPIKey)
|
||||
|
||||
count, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Join("JOIN service_account").
|
||||
JoinOn("service_account.id = factor_api_key.service_account_id").
|
||||
Where("service_account.org_id = ?", orgID).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int64(count), nil
|
||||
}
|
||||
|
||||
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error) {
|
||||
storables := make([]*serviceaccounttypes.FactorAPIKey, 0)
|
||||
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
|
||||
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
@@ -289,31 +248,14 @@ func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID value
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, storable *serviceaccounttypes.FactorAPIKey) error {
|
||||
func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, storable *serviceaccounttypes.StorableFactorAPIKey) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(storable).
|
||||
WherePK().
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeAPIKeyAlreadyExists, "api key with name: %s already exists in service account: %s", storable.Name, storable.ServiceAccountID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
TableExpr("factor_api_key").
|
||||
Set("last_observed_at = ?", lastObservedAt).
|
||||
Where("key = ?", key).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -326,7 +268,7 @@ func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID val
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.FactorAPIKey)).
|
||||
Model(new(serviceaccounttypes.StorableFactorAPIKey)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
@@ -342,7 +284,7 @@ func (store *store) RevokeAllFactorAPIKeys(ctx context.Context, serviceAccountID
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(serviceaccounttypes.FactorAPIKey)).
|
||||
Model(new(serviceaccounttypes.StorableFactorAPIKey)).
|
||||
Where("service_account_id = ?", serviceAccountID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,7 @@ package serviceaccount
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -14,14 +12,14 @@ type Module interface {
|
||||
// Creates a new service account for an organization.
|
||||
Create(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Gets a service account with roles by id.
|
||||
GetWithRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, error)
|
||||
// Gets a service account by id.
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets or creates a service account by name
|
||||
GetOrCreate(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
|
||||
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// Gets a service account by id
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
// Gets a service account by id without fetching roles.
|
||||
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
|
||||
|
||||
// List all service accounts for an organization.
|
||||
List(context.Context, valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error)
|
||||
@@ -29,14 +27,8 @@ type Module interface {
|
||||
// Updates an existing service account
|
||||
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) 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, 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
|
||||
// Updates an existing service account status
|
||||
UpdateStatus(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
|
||||
|
||||
// Deletes an existing service account by id
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
@@ -47,25 +39,14 @@ type Module interface {
|
||||
// Gets a factor API key by id
|
||||
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Gets or creates a factor api key by name
|
||||
GetOrCreateFactorAPIKey(context.Context, *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Lists all the API keys for a service account
|
||||
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
|
||||
|
||||
// Updates an existing API key for a service account
|
||||
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
|
||||
|
||||
// Set the last observed at for an api key.
|
||||
SetLastObservedAt(context.Context, string, time.Time) error
|
||||
|
||||
// Revokes an existing API key for a service account
|
||||
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Gets the identity for service account based on the factor api key.
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
|
||||
Config() Config
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -73,19 +54,11 @@ type Handler interface {
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetRoles(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetMe(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateMe(http.ResponseWriter, *http.Request)
|
||||
|
||||
SetRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteRole(http.ResponseWriter, *http.Request)
|
||||
UpdateStatus(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user