Compare commits

..

96 Commits

Author SHA1 Message Date
swapnil-signoz
8158a85e86 chore: adding TODO comments 2026-04-01 23:39:00 +05:30
swapnil-signoz
b7d7a5422e Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 22:07:00 +05:30
swapnil-signoz
4f273b296e refactor: removing dashboard overview images (#10801)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-01 16:25:55 +00:00
swapnil-signoz
74b7f4b4e8 feat: adding service definition store 2026-04-01 20:55:55 +05:30
swapnil-signoz
985d66539a Merge branch 'refactor/remove-overview-images' into refactor/cloud-integration-modules 2026-04-01 20:43:31 +05:30
swapnil-signoz
b15f817ba3 refactor: removing dashboard overview images 2026-04-01 20:38:45 +05:30
swapnil-signoz
5e1cf14de9 Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 19:59:21 +05:30
swapnil-signoz
3dc0a7c8ce feat: adding aws service definitions in types (#10798)
* feat: adding aws service definitions in types

* refactor: moving definitions to module fs
2026-04-01 14:08:46 +00:00
Liapis Nikolaos
1080553905 feat(logs): pretty-print JSON attribute values when copying to clipboard (#10778)
When copying log attribute values that contain valid JSON objects or
arrays, the value is now pretty-printed with 2-space indentation.
This makes it easy to paste into JSON tools or editors.

Non-JSON values (strings, numbers, booleans) are unaffected.

Closes #8208
2026-04-01 13:01:09 +00:00
swapnil-signoz
99944b5f92 refactor: renaming tests and cleanup 2026-04-01 15:49:57 +05:30
Nikhil Soni
23e3c75d24 feat: return all spans for flamegraph under a limit (#10757)
* feat: return all spans for flamegraph under a limit

* feat: increase fg limits and add timestamp boundaries

* fix: set default value for ts boundary

* fix: use correct value for boundary end ts

* chore: change info log of flamegraph to debug
2026-04-01 10:18:36 +00:00
Pandey
42415e0873 feat(audit): handler-level AuditDef, audit middleware, and response capture (#10791)
* feat(audit): handler-level AuditDef and response-capturing wrapper

Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.

* refactor(audit): move audit logic to middleware, merge with logging

Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).

* refactor(audit): rename Logging middleware to Audit, merge into single file

Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.

* refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes

Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.

* refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode

Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.

* refactor(audit): move AuditDef onto Handler interface, consolidate files

Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.

* feat(audit): add option.go with AuditDef, Option, and WithAuditDef

* refactor(audit): decompose AuditEvent into attribute sub-structs, add tests

Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.

* refactor(audit): shorten attribute struct names, drop error message

Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.

* fix(audit): update auditorserver test and otlphttp provider for new struct layout

Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.

* fix(audit): fix gjson path in ErrorCodeFromBody, add tests

Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.

* fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware

Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.

* test(audit): add unit tests for responseCapture

Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.

* fix(audit): check rw.Write return values in response_test.go

* style(audit): rename want prefix to expected in test fields

* refactor(audit): replace Sprintf with strings.Builder in newBody

Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.

* chore: fix formatting

* chore: remove json tags

* fix: rebase with main
2026-04-01 10:10:52 +00:00
swapnil-signoz
f8eda16533 feat: using service account for API key 2026-04-01 15:27:24 +05:30
swapnil-signoz
a2eb8ab00a Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 13:16:54 +05:30
swapnil-signoz
601007cba1 chore: lint changes 2026-04-01 13:07:58 +05:30
swapnil-signoz
925a29d2df refactor: reverting older tests and adding new tests 2026-04-01 13:03:56 +05:30
Vikrant Gupta
bad80399a6 feat(serviceaccount): integrate service account (#10681)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(serviceaccount): integrate service account

* feat(serviceaccount): integrate service account with better types

* feat(serviceaccount): fix lint and testing changes

* feat(serviceaccount): update integration tests

* feat(serviceaccount): fix formatting

* feat(serviceaccount): fix openapi spec

* feat(serviceaccount): update txlock to immediate to avoid busy snapshot errors

* feat(serviceaccount): add restrictions for factor_api_key

* feat(serviceaccount): add restrictions for factor_api_key

* feat: enabled service account and deprecated API Keys (#10715)

* feat: enabled service account and deprecated API Keys

* feat: deprecated API Keys

* feat: service account spec updates and role management changes

* feat: updated the error component for roles management

* feat: updated test case

* feat: updated the error component and added retries

* feat: refactored code and added retry to happend 3 times total

* feat: fixed feedbacks and added test case

* feat: refactored code and removed retry

* feat: updated the test cases

---------

Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
2026-04-01 07:20:59 +00:00
swapnil-signoz
d54fc50236 Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 11:55:09 +05:30
swapnil-signoz
a2ad5b1172 refactor: adding validation on update account request 2026-03-30 21:37:03 +05:30
swapnil-signoz
802a11ee2b Merge branch 'main' into refactor/cloud-integration-modules 2026-03-30 18:45:55 +05:30
swapnil-signoz
a8124f6e73 refactor: python lint changes 2026-03-30 18:41:35 +05:30
swapnil-signoz
8811aaefe8 fix: new storable account func was unsetting provider account id 2026-03-30 18:28:15 +05:30
swapnil-signoz
66aaaea918 refactor: python formatting change 2026-03-30 12:30:59 +05:30
swapnil-signoz
900c489d91 refactor: ci lint changes 2026-03-30 12:06:03 +05:30
swapnil-signoz
743fe56523 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-29 19:50:35 +05:30
swapnil-signoz
3a9e93ebdf feat: adding module implementation for AWS 2026-03-29 19:49:58 +05:30
swapnil-signoz
cdbb78a93d refactor: simplify ingestion key retrieval logic 2026-03-27 12:03:23 +05:30
swapnil-signoz
c11186f7bf fix: module test 2026-03-27 11:57:40 +05:30
swapnil-signoz
51dbb0b5b9 fix: returning valid error instead of panic 2026-03-27 11:32:25 +05:30
swapnil-signoz
2545d7df61 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-26 01:25:53 +05:30
swapnil-signoz
3f91821825 feat: adding module implementation for create account 2026-03-26 01:22:09 +05:30
swapnil-signoz
ee5d182539 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-24 17:50:54 +05:30
swapnil-signoz
0bc12f02bc Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-24 10:59:04 +05:30
swapnil-signoz
e5f00421fe Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-23 21:05:26 +05:30
swapnil-signoz
539252e10c feat: adding frontend openapi schema 2026-03-23 12:33:14 +05:30
swapnil-signoz
d65f426254 chore: removing todo comment 2026-03-23 12:24:04 +05:30
swapnil-signoz
6e52f2c8f0 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-22 17:13:53 +05:30
swapnil-signoz
d9f8a4ae5a Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-22 17:13:40 +05:30
swapnil-signoz
eefe3edffd Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-22 17:13:02 +05:30
swapnil-signoz
2051861a03 feat: adding handler skeleton 2026-03-22 17:12:35 +05:30
swapnil-signoz
4b01a40fb9 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-20 20:53:54 +05:30
swapnil-signoz
2d8a00bf18 fix: update error code for service not found 2026-03-20 20:53:33 +05:30
swapnil-signoz
f1b26b310f Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-20 20:51:44 +05:30
swapnil-signoz
2c438b6c32 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-20 20:48:34 +05:30
swapnil-signoz
1814c2d13c Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-20 17:52:31 +05:30
swapnil-signoz
e6cd771f11 Merge origin/main into refactor/cloud-integration-handlers 2026-03-20 16:46:36 +05:30
swapnil-signoz
6b94f87ca0 Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-19 11:43:21 +05:30
swapnil-signoz
bf315253ae fix: lint issues 2026-03-19 11:43:09 +05:30
swapnil-signoz
668ff7bc39 fix: lint and ci issues 2026-03-19 11:34:27 +05:30
swapnil-signoz
07f2aa52fd feat: adding handlers 2026-03-19 01:35:01 +05:30
swapnil-signoz
3416b3ad55 Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-18 21:50:40 +05:30
swapnil-signoz
d6caa4f2c7 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-18 14:08:14 +05:30
swapnil-signoz
f86371566d refactor: clean up 2026-03-18 13:45:31 +05:30
swapnil-signoz
9115803084 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-18 13:42:43 +05:30
swapnil-signoz
0c14d8f966 refactor: review comments 2026-03-18 13:40:17 +05:30
swapnil-signoz
7afb461af8 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-18 11:14:33 +05:30
swapnil-signoz
a21fbb4ee0 refactor: clean up 2026-03-18 11:14:05 +05:30
swapnil-signoz
0369842f3d refactor: clean up 2026-03-17 23:40:14 +05:30
swapnil-signoz
59cd96562a Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 23:10:54 +05:30
swapnil-signoz
cc4475cab7 refactor: updating store methods 2026-03-17 23:10:15 +05:30
swapnil-signoz
ac8c648420 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 21:09:47 +05:30
swapnil-signoz
bede6be4b8 feat: adding method for service id creation 2026-03-17 21:09:26 +05:30
swapnil-signoz
dd3d60e6df Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 20:49:31 +05:30
swapnil-signoz
538ab686d2 refactor: using serviceID type 2026-03-17 20:49:17 +05:30
swapnil-signoz
936a325cb9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 17:25:58 +05:30
swapnil-signoz
c6cdcd0143 refactor: renaming service type to service id 2026-03-17 17:25:29 +05:30
swapnil-signoz
cd9211d718 refactor: clean up types 2026-03-17 17:04:27 +05:30
swapnil-signoz
0601c28782 feat: adding integration test 2026-03-17 11:02:46 +05:30
swapnil-signoz
580610dbfa Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-16 23:02:19 +05:30
swapnil-signoz
2d2aa02a81 refactor: split upsert store method 2026-03-16 18:27:42 +05:30
swapnil-signoz
dd9723ad13 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:42:03 +05:30
swapnil-signoz
3651469416 Merge branch 'main' of https://github.com/SigNoz/signoz into refactor/cloud-integration-types 2026-03-16 17:41:52 +05:30
swapnil-signoz
febce75734 refactor: update Dashboard struct comments and remove unused fields 2026-03-16 17:41:28 +05:30
swapnil-signoz
e1616f3487 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:36:15 +05:30
swapnil-signoz
4b94287ac7 refactor: add comments for backward compatibility in PostableAgentCheckInRequest 2026-03-16 15:48:20 +05:30
swapnil-signoz
1575c7c54c refactor: streamlining types 2026-03-16 15:39:32 +05:30
swapnil-signoz
8def3f835b refactor: adding comments and removed wrong code 2026-03-16 11:10:53 +05:30
swapnil-signoz
11ed15f4c5 feat: implement cloud integration store 2026-03-14 17:05:02 +05:30
swapnil-signoz
f47877cca9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 17:01:51 +05:30
swapnil-signoz
bb2b9215ba fix: correct GetService signature and remove shadowed Data field 2026-03-14 16:59:07 +05:30
swapnil-signoz
3111904223 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 16:36:35 +05:30
swapnil-signoz
003e2c30d8 Merge branch 'main' into refactor/cloud-integration-types 2026-03-14 16:25:35 +05:30
swapnil-signoz
00fe516d10 refactor: update cloud integration types and module interface 2026-03-14 16:25:16 +05:30
swapnil-signoz
0305f4f7db refactor: using struct for map 2026-03-13 16:09:26 +05:30
swapnil-signoz
c60019a6dc Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 23:41:22 +05:30
swapnil-signoz
acde2a37fa feat: adding updated types for cloud integration 2026-03-12 23:40:44 +05:30
swapnil-signoz
945241a52a Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 19:45:50 +05:30
swapnil-signoz
e967f80c86 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 16:39:42 +05:30
swapnil-signoz
a09dc325de Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 16:39:20 +05:30
swapnil-signoz
379b4f7fc4 refactor: removing interface check 2026-03-02 14:50:37 +05:30
swapnil-signoz
5e536ae077 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-02 14:49:35 +05:30
swapnil-signoz
234585e642 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 14:49:19 +05:30
swapnil-signoz
2cc14f1ad4 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 14:49:00 +05:30
swapnil-signoz
dc4ed4d239 feat: adding sql store implementation 2026-03-02 14:44:56 +05:30
swapnil-signoz
7281c36873 refactor: store interfaces to use local types and error 2026-03-02 13:27:46 +05:30
swapnil-signoz
40288776e8 feat: adding cloud integration type for refactor 2026-02-28 16:59:14 +05:30
250 changed files with 72477 additions and 6343 deletions

View File

@@ -51,6 +51,7 @@ jobs:
- alerts
- ingestionkeys
- rootuser
- serviceaccount
sqlstore-provider:
- postgres
- sqlite

View File

@@ -19,9 +19,12 @@ import (
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -29,6 +32,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
@@ -96,6 +100,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},
func(_ cloudintegrationtypes.Store, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
@@ -30,9 +31,11 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -40,6 +43,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -125,7 +129,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
@@ -137,8 +140,10 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)
},
func(store cloudintegrationtypes.Store, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(store, config.Global, zeus, gateway, licensing, serviceAccount)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
return err

View File

@@ -354,3 +354,13 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalOrgID.IsZero() {
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.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}

View File

@@ -34,9 +34,22 @@ 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, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
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
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)

View File

@@ -0,0 +1,184 @@
package implcloudprovider
import (
"context"
"encoding/json"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type awscloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) (cloudintegration.CloudProviderModule, error) {
return &awscloudprovider{serviceDefinitions: defStore}, nil
}
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, creds *cloudintegrationtypes.SignozCredentials, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// TODO: get this from config
agentVersion := "v0.0.8"
baseURL := fmt.Sprintf("https://%s.console.aws.amazon.com/cloudformation/home", req.Aws.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Aws.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", "signoz-integration")
q.Set("templateURL", fmt.Sprintf("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json", agentVersion))
q.Set("param_SigNozIntegrationAgentVersion", agentVersion)
q.Set("param_SigNozApiUrl", creds.SigNozAPIURL)
q.Set("param_SigNozApiKey", creds.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", creds.IngestionURL)
q.Set("param_IngestionKey", creds.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
},
}, nil
}
func (provider *awscloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return provider.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeAWS)
}
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
return provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAWS, serviceID)
}
func (provider *awscloudprovider) StorableConfigFromServiceConfig(ctx context.Context, cfg *cloudintegrationtypes.ServiceConfig, supported cloudintegrationtypes.SupportedSignals) (string, error) {
if cfg == nil || cfg.AWS == nil {
return "", nil
}
// Strip signal configs the service does not support before storing.
if !supported.Logs {
cfg.AWS.Logs = nil
}
if !supported.Metrics {
cfg.AWS.Metrics = nil
}
b, err := json.Marshal(cfg.AWS)
if err != nil {
return "", err
}
return string(b), nil
}
func (provider *awscloudprovider) ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*cloudintegrationtypes.ServiceConfig, error) {
if config == "" {
return nil, errors.NewInternalf(errors.CodeInternal, "service config is empty")
}
var awsCfg cloudintegrationtypes.AWSServiceConfig
if err := json.Unmarshal([]byte(config), &awsCfg); err != nil {
return nil, err
}
return &cloudintegrationtypes.ServiceConfig{AWS: &awsCfg}, nil
}
func (provider *awscloudprovider) IsServiceEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
if config == nil || config.AWS == nil {
return false
}
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
}
func (provider *awscloudprovider) IsMetricsEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
if config == nil || config.AWS == nil {
return false
}
return awsMetricsEnabled(config.AWS)
}
func (provider *awscloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// Sort services for deterministic output
sort.Slice(services, func(i, j int) bool {
return services[i].Type.StringValue() < services[j].Type.StringValue()
})
compiledMetrics := &cloudintegrationtypes.AWSMetricsStrategy{}
compiledLogs := &cloudintegrationtypes.AWSLogsStrategy{}
var compiledS3Buckets map[string][]string
for _, storedSvc := range services {
svcCfg, err := provider.ServiceConfigFromStorableServiceConfig(ctx, storedSvc.Config)
if err != nil || svcCfg == nil || svcCfg.AWS == nil {
continue
}
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil || svcDef == nil || svcDef.Strategy == nil || svcDef.Strategy.AWS == nil {
continue
}
strategy := svcDef.Strategy.AWS
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
if awsLogsEnabled(svcCfg.AWS) && svcCfg.AWS.Logs.S3Buckets != nil {
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
}
continue
}
if awsLogsEnabled(svcCfg.AWS) && strategy.Logs != nil {
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
}
if awsMetricsEnabled(svcCfg.AWS) && strategy.Metrics != nil {
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
}
}
awsTelemetry := &cloudintegrationtypes.AWSCollectionStrategy{}
if len(compiledMetrics.StreamFilters) > 0 {
awsTelemetry.Metrics = compiledMetrics
}
if len(compiledLogs.Subscriptions) > 0 {
awsTelemetry.Logs = compiledLogs
}
if compiledS3Buckets != nil {
awsTelemetry.S3Buckets = compiledS3Buckets
}
enabledRegions := []string{}
if account.Config != nil && account.Config.AWS != nil && account.Config.AWS.Regions != nil {
enabledRegions = account.Config.AWS.Regions
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: enabledRegions,
Telemetry: awsTelemetry,
},
}, nil
}
// awsLogsEnabled returns true if the AWS service config has logs explicitly enabled.
func awsLogsEnabled(cfg *cloudintegrationtypes.AWSServiceConfig) bool {
return cfg.Logs != nil && cfg.Logs.Enabled
}
// awsMetricsEnabled returns true if the AWS service config has metrics explicitly enabled.
func awsMetricsEnabled(cfg *cloudintegrationtypes.AWSServiceConfig) bool {
return cfg.Metrics != nil && cfg.Metrics.Enabled
}

View File

@@ -0,0 +1,50 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type azurecloudprovider struct{}
func NewAzureCloudProvider() cloudintegration.CloudProviderModule {
return &azurecloudprovider{}
}
func (provider *azurecloudprovider) GetConnectionArtifact(ctx context.Context, creds *cloudintegrationtypes.SignozCredentials, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
panic("implement me")
}
func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
}
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
}
func (provider *azurecloudprovider) StorableConfigFromServiceConfig(ctx context.Context, cfg *cloudintegrationtypes.ServiceConfig, supported cloudintegrationtypes.SupportedSignals) (string, error) {
panic("implement me")
}
func (provider *azurecloudprovider) ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*cloudintegrationtypes.ServiceConfig, error) {
panic("implement me")
}
func (provider *azurecloudprovider) IsServiceEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
panic("implement me")
}
func (provider *azurecloudprovider) IsMetricsEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
panic("implement me")
}
func (provider *azurecloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
panic("implement me")
}

View File

@@ -0,0 +1,533 @@
package implcloudintegration
import (
"context"
"fmt"
"sort"
"time"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
pkgimpl "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
)
type module struct {
store cloudintegrationtypes.Store
gateway gateway.Gateway
zeus zeus.Zeus
licensing licensing.Licensing
globalConfig global.Config
serviceAccount serviceaccount.Module
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
}
func NewModule(
store cloudintegrationtypes.Store,
globalConfig global.Config,
zeus zeus.Zeus,
gateway gateway.Gateway,
licensing licensing.Licensing,
serviceAccount serviceaccount.Module,
) (cloudintegration.Module, error) {
defStore := pkgimpl.NewServiceDefinitionStore()
awsCloudProviderModule, err := implcloudprovider.NewAWSCloudProvider(defStore)
if err != nil {
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
}
return &module{
store: store,
globalConfig: globalConfig,
zeus: zeus,
gateway: gateway,
licensing: licensing,
serviceAccount: serviceAccount,
cloudProvidersMap: cloudProvidersMap,
}, nil
}
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
_, err := module.licensing.GetActive(ctx, account.OrgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableCloudIntegration, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return err
}
return module.store.CreateAccount(ctx, storableCloudIntegration)
}
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// TODO: evaluate if this check is really required and remove if the deployment promises to always have this configured.
if module.globalConfig.IngestionURL == nil {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "ingestion URL is not configured")
}
// get license to get the deployment details
license, err := module.licensing.GetActive(ctx, account.OrgID)
if err != nil {
return nil, err
}
// get deployment details from zeus
respBytes, err := module.zeus.GetDeployment(ctx, license.Key)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't get deployment")
}
// parse deployment details
deployment, err := zeustypes.NewGettableDeployment(respBytes)
if err != nil {
return nil, err
}
apiKey, err := module.getOrCreateAPIKey(ctx, account.OrgID, account.Provider)
if err != nil {
return nil, err
}
ingestionKey, err := module.getOrCreateIngestionKey(ctx, account.OrgID, account.Provider)
if err != nil {
return nil, err
}
creds := &cloudintegrationtypes.SignozCredentials{
SigNozAPIURL: deployment.SignozAPIUrl,
SigNozAPIKey: apiKey,
IngestionURL: module.globalConfig.IngestionURL.String(),
IngestionKey: ingestionKey,
}
cloudProviderModule, err := module.GetCloudProvider(account.Provider)
if err != nil {
return nil, err
}
return cloudProviderModule.GetConnectionArtifact(ctx, creds, account, req)
}
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, accountID, provider)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
}
// ListAccounts return only agent connected accounts.
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAccountsFromStorables(storableAccounts)
}
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
connectedAccount, err := module.store.GetConnectedAccount(ctx, orgID, provider, req.ProviderAccountID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
}
account.AccountID = &req.ProviderAccountID
account.LastAgentReport = &cloudintegrationtypes.StorableAgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
err = module.store.UpdateAccount(ctx, account)
if err != nil {
return nil, err
}
// If account has been removed (disconnected), return a minimal response with empty integration config.
// The agent doesn't act on config for removed accounts.
if account.RemovedAt != nil {
return &cloudintegrationtypes.AgentCheckInResponse{
CloudIntegrationID: account.ID.StringValue(),
ProviderAccountID: req.ProviderAccountID,
IntegrationConfig: &cloudintegrationtypes.ProviderIntegrationConfig{},
RemovedAt: account.RemovedAt,
}, nil
}
// Get account as domain object for config access (enabled regions, etc.)
accountDomain, err := cloudintegrationtypes.NewAccountFromStorable(account)
if err != nil {
return nil, err
}
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return nil, err
}
storedServices, err := module.store.ListServices(ctx, req.CloudIntegrationID)
if err != nil {
return nil, err
}
// Delegate integration config building entirely to the provider module
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, accountDomain, storedServices)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.AgentCheckInResponse{
CloudIntegrationID: account.ID.StringValue(),
ProviderAccountID: req.ProviderAccountID,
IntegrationConfig: integrationConfig,
RemovedAt: account.RemovedAt,
}, nil
}
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
_, err := module.licensing.GetActive(ctx, account.OrgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return err
}
return module.store.UpdateAccount(ctx, storableAccount)
}
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
}
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID *valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return nil, err
}
serviceDefinitions, err := cloudProvider.ListServiceDefinitions(ctx)
if err != nil {
return nil, err
}
enabledServiceIDs := map[string]bool{}
if integrationID != nil {
_, err := module.store.GetAccountByID(ctx, orgID, *integrationID, provider)
if err != nil {
return nil, err
}
storedServices, err := module.store.ListServices(ctx, *integrationID)
if err != nil {
return nil, err
}
for _, svc := range storedServices {
serviceConfig, err := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, svc.Config)
if err != nil {
return nil, err
}
if cloudProvider.IsServiceEnabled(ctx, serviceConfig) {
enabledServiceIDs[svc.Type.StringValue()] = true
}
}
}
resp := make([]*cloudintegrationtypes.ServiceMetadata, 0, len(serviceDefinitions))
for _, serviceDefinition := range serviceDefinitions {
resp = append(resp, cloudintegrationtypes.NewServiceMetadata(*serviceDefinition, enabledServiceIDs[serviceDefinition.ID]))
}
return resp, nil
}
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Service, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return nil, err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, serviceID)
if err != nil {
return nil, err
}
var integrationService *cloudintegrationtypes.CloudIntegrationService
if integrationID != nil {
_, err := module.store.GetAccountByID(ctx, orgID, *integrationID, provider)
if err != nil {
return nil, err
}
storedService, err := module.store.GetServiceByServiceID(ctx, *integrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
}
}
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, service.Type)
if err != nil {
return err
}
configJSON, err := cloudProvider.StorableConfigFromServiceConfig(ctx, service.Config, serviceDefinition.SupportedSignals)
if err != nil {
return err
}
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, configJSON))
}
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, integrationService.Type)
if err != nil {
return err
}
configJSON, err := cloudProvider.StorableConfigFromServiceConfig(ctx, integrationService.Config, serviceDefinition.SupportedSignals)
if err != nil {
return err
}
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, configJSON)
return module.store.UpdateService(ctx, storableService)
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
func (module *module) listDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
var allDashboards []*dashboardtypes.Dashboard
for provider := range module.cloudProvidersMap {
cloudProvider, err := module.GetCloudProvider(provider)
if err != nil {
return nil, err
}
connectedAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
if err != nil {
return nil, err
}
for _, storableAccount := range connectedAccounts {
storedServices, err := module.store.ListServices(ctx, storableAccount.ID)
if err != nil {
return nil, err
}
for _, storedSvc := range storedServices {
serviceConfig, err := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, storedSvc.Config)
if err != nil || !cloudProvider.IsMetricsEnabled(ctx, serviceConfig) {
continue
}
svcDef, err := cloudProvider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil || svcDef == nil {
continue
}
dashboards := cloudintegrationtypes.GetDashboardsFromAssets(
storedSvc.Type.StringValue(),
orgID,
provider,
storableAccount.CreatedAt,
svcDef.Assets,
)
allDashboards = append(allDashboards, dashboards...)
}
}
}
sort.Slice(allDashboards, func(i, j int) bool {
return allDashboards[i].ID < allDashboards[j].ID
})
return allDashboards, nil
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
_, _, _, err = cloudintegrationtypes.ParseCloudIntegrationDashboardID(id)
if err != nil {
return nil, err
}
allDashboards, err := module.listDashboards(ctx, orgID)
if err != nil {
return nil, err
}
for _, d := range allDashboards {
if d.ID == id {
return d, nil
}
}
return nil, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration dashboard not found")
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return module.listDashboards(ctx, orgID)
}
func (module *module) GetCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil
}
return nil, errors.NewInvalidInputf(cloudintegrationtypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
keyName := cloudintegrationtypes.NewIngestionKeyName(provider)
result, err := module.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't search ingestion keys")
}
// ideally there should be only one key per cloud integration provider
if len(result.Keys) > 0 {
return result.Keys[0].Value, nil
}
createdIngestionKey, err := module.gateway.CreateIngestionKey(ctx, orgID, keyName, []string{"integration"}, time.Time{})
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't create ingestion key")
}
return createdIngestionKey.Value, nil
}
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
domain := module.serviceAccount.Config().Email.Domain
serviceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgID)
serviceAccount, err := module.serviceAccount.GetOrCreate(ctx, orgID, serviceAccount)
if err != nil {
return "", err
}
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}
factorAPIKey, err := serviceAccount.NewFactorAPIKey(provider.StringValue(), 0)
if err != nil {
return "", err
}
factorAPIKey, err = module.serviceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return "", err
}
return factorAPIKey.Key, nil
}

View File

@@ -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, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
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) MustGetTypeables() []authtypes.Typeable {

View File

@@ -14,10 +14,9 @@ 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"
)
@@ -50,7 +49,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't provision PAT for cloud integration:",
@@ -110,84 +109,44 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
ah.Respond(w, result)
}
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
string, *basemodel.ApiError,
) {
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
integrationPATName := fmt.Sprintf("%s", cloudProvider)
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
if apiErr != nil {
return "", apiErr
}
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,
)
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
return newPAT.Token, nil
return factorAPIKey.Key, nil
}
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)
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)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
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))
}
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
return cloudIntegrationServiceAccount, nil
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -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.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -306,11 +306,19 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /settings/access-tokens to /settings/api-keys', () => {
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
renderPrivateRoute({ initialRoute: '/settings/access-tokens' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/settings/api-keys',
'/settings/service-accounts',
);
});
it('should redirect /settings/api-keys to /settings/service-accounts', () => {
renderPrivateRoute({ initialRoute: '/settings/api-keys' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/settings/service-accounts',
);
});

View File

@@ -157,10 +157,6 @@ 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'),
);

View File

@@ -513,6 +513,7 @@ export const oldRoutes = [
'/logs-save-views',
'/traces-save-views',
'/settings/access-tokens',
'/settings/api-keys',
'/messaging-queues',
'/alerts/edit',
];
@@ -523,7 +524,8 @@ 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/api-keys',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
};

View File

@@ -628,6 +628,103 @@ export const useUpdateAccount = <
return useMutation(mutationOptions);
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
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 updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
@@ -941,101 +1038,3 @@ export const invalidateGetService = async (
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
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 updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -23,9 +23,15 @@ import type {
CreateServiceAccount201,
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRolePathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
GetServiceAccountPathParameters,
GetServiceAccountRoles200,
GetServiceAccountRolesPathParameters,
ListServiceAccountKeys200,
ListServiceAccountKeysPathParameters,
ListServiceAccounts200,
@@ -33,12 +39,10 @@ import type {
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
ServiceaccounttypesUpdatableFactorAPIKeyDTO,
ServiceaccounttypesUpdatableServiceAccountDTO,
ServiceaccounttypesUpdatableServiceAccountStatusDTO,
UpdateServiceAccountKeyPathParameters,
UpdateServiceAccountPathParameters,
UpdateServiceAccountStatusPathParameters,
} from '../sigNoz.schemas';
/**
@@ -399,13 +403,13 @@ export const invalidateGetServiceAccount = async (
*/
export const updateServiceAccount = (
{ id }: UpdateServiceAccountPathParameters,
serviceaccounttypesUpdatableServiceAccountDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>,
serviceaccounttypesPostableServiceAccountDTO: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesUpdatableServiceAccountDTO,
data: serviceaccounttypesPostableServiceAccountDTO,
});
};
@@ -418,7 +422,7 @@ export const getUpdateServiceAccountMutationOptions = <
TError,
{
pathParams: UpdateServiceAccountPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
},
TContext
>;
@@ -427,7 +431,7 @@ export const getUpdateServiceAccountMutationOptions = <
TError,
{
pathParams: UpdateServiceAccountPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
},
TContext
> => {
@@ -444,7 +448,7 @@ export const getUpdateServiceAccountMutationOptions = <
Awaited<ReturnType<typeof updateServiceAccount>>,
{
pathParams: UpdateServiceAccountPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -458,7 +462,7 @@ export const getUpdateServiceAccountMutationOptions = <
export type UpdateServiceAccountMutationResult = NonNullable<
Awaited<ReturnType<typeof updateServiceAccount>>
>;
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
export type UpdateServiceAccountMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
export type UpdateServiceAccountMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -473,7 +477,7 @@ export const useUpdateServiceAccount = <
TError,
{
pathParams: UpdateServiceAccountPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
},
TContext
>;
@@ -482,7 +486,7 @@ export const useUpdateServiceAccount = <
TError,
{
pathParams: UpdateServiceAccountPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountDTO>;
data: BodyType<ServiceaccounttypesPostableServiceAccountDTO>;
},
TContext
> => {
@@ -871,44 +875,150 @@ export const useUpdateServiceAccountKey = <
return useMutation(mutationOptions);
};
/**
* This endpoint updates an existing service account status
* @summary Updates a service account status
* This endpoint gets all the roles for the existing service account
* @summary Gets service account roles
*/
export const updateServiceAccountStatus = (
{ id }: UpdateServiceAccountStatusPathParameters,
serviceaccounttypesUpdatableServiceAccountStatusDTO: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>,
export const getServiceAccountRoles = (
{ id }: GetServiceAccountRolesPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/service_accounts/${id}/status`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesUpdatableServiceAccountStatusDTO,
return GeneratedAPIInstance<GetServiceAccountRoles200>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'GET',
signal,
});
};
export const getUpdateServiceAccountStatusMutationOptions = <
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',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: UpdateServiceAccountStatusPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: UpdateServiceAccountStatusPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateServiceAccountStatus'];
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -918,52 +1028,299 @@ export const getUpdateServiceAccountStatusMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
{
pathParams: UpdateServiceAccountStatusPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateServiceAccountStatus(pathParams, data);
return createServiceAccountRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceAccountStatusMutationResult = NonNullable<
Awaited<ReturnType<typeof updateServiceAccountStatus>>
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type UpdateServiceAccountStatusMutationBody = BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
export type UpdateServiceAccountStatusMutationError = ErrorType<RenderErrorResponseDTO>;
export type CreateServiceAccountRoleMutationBody = BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
export type CreateServiceAccountRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Updates a service account status
* @summary Create service account role
*/
export const useUpdateServiceAccountStatus = <
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: UpdateServiceAccountStatusPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateServiceAccountStatus>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: UpdateServiceAccountStatusPathParameters;
data: BodyType<ServiceaccounttypesUpdatableServiceAccountStatusDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceAccountStatusMutationOptions(options);
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);
return useMutation(mutationOptions);
};

View File

@@ -550,12 +550,12 @@ export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
};
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
logs?: CloudintegrationtypesAWSLogsStrategyDTO;
metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
s3Buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
@@ -588,11 +588,11 @@ export type CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsIt
/**
* @type string
*/
filter_pattern?: string;
filterPattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
logGroupNamePrefix?: string;
};
export interface CloudintegrationtypesAWSLogsStrategyDTO {
@@ -600,7 +600,7 @@ export interface CloudintegrationtypesAWSLogsStrategyDTO {
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
cloudwatchLogsSubscriptions?:
| CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
@@ -621,7 +621,7 @@ export interface CloudintegrationtypesAWSMetricsStrategyDTO {
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
cloudwatchMetricStreamFilters?:
| CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
@@ -726,6 +726,32 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
/**
* @type string
*/
cloudIntegrationId?: string;
config?: CloudintegrationtypesServiceConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
type?: CloudintegrationtypesServiceIDDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
} | null;
export interface CloudintegrationtypesCollectedLogAttributeDTO {
/**
* @type string
@@ -864,9 +890,68 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
} | null;
export type CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesOldAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesOldAWSMetricsStrategyDTO;
/**
* @type string
*/
provider?: string;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
}
export type CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesOldAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesOldAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
/**
* @nullable
*/
@@ -904,6 +989,7 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -917,8 +1003,7 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
/**
* @type string
@@ -930,6 +1015,21 @@ export interface CloudintegrationtypesServiceConfigDTO {
aws: CloudintegrationtypesAWSServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
alb = 'alb',
'api-gateway' = 'api-gateway',
dynamodb = 'dynamodb',
ec2 = 'ec2',
ecs = 'ecs',
eks = 'eks',
elasticache = 'elasticache',
lambda = 'lambda',
msk = 'msk',
rds = 'rds',
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
* @type boolean
@@ -2843,7 +2943,7 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
state: RulestatehistorytypesAlertStateDTO;
}
export interface ServiceaccounttypesFactorAPIKeyDTO {
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
* @format date-time
@@ -2858,10 +2958,6 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
* @type string
*/
id: string;
/**
* @type string
*/
key: string;
/**
* @type string
* @format date-time
@@ -2909,15 +3005,14 @@ export interface ServiceaccounttypesPostableServiceAccountDTO {
/**
* @type string
*/
email: string;
name: string;
}
export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
/**
* @type string
*/
name: string;
/**
* @type array
*/
roles: string[];
id: string;
}
export interface ServiceaccounttypesServiceAccountDTO {
@@ -2926,11 +3021,65 @@ 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
*/
deletedAt: Date;
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;
/**
* @type string
*/
@@ -2949,8 +3098,9 @@ export interface ServiceaccounttypesServiceAccountDTO {
orgId: string;
/**
* @type array
* @nullable true
*/
roles: string[];
serviceAccountRoles: ServiceaccounttypesServiceAccountRoleDTO[] | null;
/**
* @type string
*/
@@ -2974,28 +3124,6 @@ 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',
@@ -3139,63 +3267,6 @@ 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
@@ -3278,22 +3349,6 @@ 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
@@ -3373,51 +3428,6 @@ 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
@@ -3622,6 +3632,11 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type AgentCheckInPathParameters = {
cloudProvider: string;
};
@@ -3656,10 +3671,6 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**
@@ -3956,31 +3967,6 @@ 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;
};
@@ -4085,7 +4071,7 @@ export type GetServiceAccountPathParameters = {
id: string;
};
export type GetServiceAccount200 = {
data: ServiceaccounttypesServiceAccountDTO;
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**
* @type string
*/
@@ -4102,7 +4088,7 @@ export type ListServiceAccountKeys200 = {
/**
* @type array
*/
data: ServiceaccounttypesFactorAPIKeyDTO[];
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
/**
* @type string
*/
@@ -4128,9 +4114,44 @@ export type UpdateServiceAccountKeyPathParameters = {
id: string;
fid: string;
};
export type UpdateServiceAccountStatusPathParameters = {
export type GetServiceAccountRolesPathParameters = {
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

View File

@@ -21,7 +21,6 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
ChangePasswordPathParameters,
CreateAPIKey201,
CreateInvite201,
DeleteUserPathParameters,
GetMyUser200,
@@ -36,24 +35,19 @@ 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,
@@ -428,349 +422,6 @@ 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

View File

@@ -1,28 +0,0 @@
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;

View File

@@ -1,19 +0,0 @@
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;

View File

@@ -1,20 +0,0 @@
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;

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -12,17 +12,13 @@ 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 {
@@ -41,8 +37,6 @@ function CreateServiceAccountModal(): JSX.Element {
mode: 'onChange',
defaultValues: {
name: '',
email: '',
roles: [],
},
});
@@ -70,13 +64,6 @@ function CreateServiceAccountModal(): JSX.Element {
},
},
});
const {
roles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
function handleClose(): void {
reset();
@@ -87,8 +74,6 @@ function CreateServiceAccountModal(): JSX.Element {
createServiceAccount({
data: {
name: values.name.trim(),
email: values.email.trim(),
roles: values.roles,
},
});
}
@@ -134,68 +119,6 @@ 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>

View File

@@ -1,5 +1,4 @@
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';
@@ -12,7 +11,6 @@ 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> {
@@ -27,9 +25,6 @@ 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: {} })),
),
@@ -48,38 +43,11 @@ 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,
@@ -116,13 +84,6 @@ 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,
@@ -164,16 +125,4 @@ 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');
});
});

View File

@@ -34,7 +34,7 @@ export function useRoles(): {
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.name ?? '',
value: role.id ?? '',
}));
}

View File

@@ -1,13 +1,13 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { PowerOff, X } from '@signozhq/icons';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountQueryKey,
invalidateListServiceAccounts,
useUpdateServiceAccountStatus,
useDeleteServiceAccount,
} 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 DisableAccountModal(): JSX.Element {
function DeleteAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDisableOpen, setIsDisableOpen] = useQueryState(
SA_QUERY_PARAMS.DISABLE_SA,
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_SA,
parseAsBoolean.withDefault(false),
);
const open = !!isDisableOpen && !!accountId;
const open = !!isDeleteOpen && !!accountId;
const cachedAccount = accountId
? queryClient.getQueryData<{
@@ -34,13 +34,13 @@ function DisableAccountModal(): JSX.Element {
const accountName = cachedAccount?.data?.name;
const {
mutate: updateStatus,
isLoading: isDisabling,
} = useUpdateServiceAccountStatus({
mutate: deleteAccount,
isLoading: isDeleting,
} = useDeleteServiceAccount({
mutation: {
onSuccess: async () => {
toast.success('Service account disabled', { richColors: true });
await setIsDisableOpen(null);
toast.success('Service account deleted', { richColors: true });
await setIsDeleteOpen(null);
await setAccountId(null);
await invalidateListServiceAccounts(queryClient);
},
@@ -48,7 +48,7 @@ function DisableAccountModal(): JSX.Element {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to disable service account';
)?.getErrorMessage() || 'Failed to delete service account';
toast.error(errMessage, { richColors: true });
},
},
@@ -58,14 +58,13 @@ function DisableAccountModal(): JSX.Element {
if (!accountId) {
return;
}
updateStatus({
deleteAccount({
pathParams: { id: accountId },
data: { status: 'DISABLED' },
});
}
function handleCancel(): void {
setIsDisableOpen(null);
setIsDeleteOpen(null);
}
return (
@@ -76,17 +75,18 @@ function DisableAccountModal(): JSX.Element {
handleCancel();
}
}}
title={`Disable service account ${accountName ?? ''}?`}
title={`Delete service account ${accountName ?? ''}?`}
width="narrow"
className="alert-dialog sa-disable-dialog"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<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 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>
<DialogFooter className="sa-disable-dialog__footer">
<DialogFooter className="sa-delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
<X size={12} />
Cancel
@@ -95,15 +95,15 @@ function DisableAccountModal(): JSX.Element {
variant="solid"
color="destructive"
size="sm"
loading={isDisabling}
loading={isDeleting}
onClick={handleConfirm}
>
<PowerOff size={12} />
Disable
<Trash2 size={12} />
Delete
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DisableAccountModal;
export default DeleteAccountModal;

View File

@@ -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 { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } 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: ServiceaccounttypesFactorAPIKeyDTO | null;
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
isSaving: boolean;
isDirty: boolean;
onSubmit: () => void;

View File

@@ -11,7 +11,7 @@ import {
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
ServiceaccounttypesGettableFactorAPIKeyDTO,
} 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: ServiceaccounttypesFactorAPIKeyDTO | null;
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null;
}
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {

View File

@@ -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 { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } 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: ServiceaccounttypesFactorAPIKeyDTO[];
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
currentPage: number;
@@ -44,7 +44,7 @@ function buildColumns({
isDisabled,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
return [
{
title: 'Name',
@@ -183,7 +183,7 @@ function KeysTab({
return (
<>
{/* Todo: use new table component from periscope when ready */}
<Table<ServiceaccounttypesFactorAPIKeyDTO>
<Table<ServiceaccounttypesGettableFactorAPIKeyDTO>
columns={columns}
dataSource={keys}
rowKey="id"

View File

@@ -9,6 +9,9 @@ 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;
@@ -21,6 +24,7 @@ interface OverviewTabProps {
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;
onRefetchRoles?: () => void;
saveErrors?: SaveError[];
}
function OverviewTab({
@@ -35,6 +39,7 @@ function OverviewTab({
rolesError,
rolesErrorObj,
onRefetchRoles,
saveErrors = [],
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -92,11 +97,14 @@ function OverviewTab({
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((r) => (
<Badge key={r} color="vanilla">
{r}
</Badge>
))
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="sa-drawer__input-text"></span>
)}
@@ -126,9 +134,13 @@ 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">
DISABLED
{account.status ? account.status.toUpperCase() : 'UNKNOWN'}
</Badge>
)}
</div>
@@ -143,6 +155,19 @@ 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>
)}
</>
);
}

View File

@@ -11,7 +11,7 @@ import {
} from 'api/generated/services/serviceaccount';
import type {
RenderErrorResponseDTO,
ServiceaccounttypesFactorAPIKeyDTO,
ServiceaccounttypesGettableFactorAPIKeyDTO,
} 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: ServiceaccounttypesFactorAPIKeyDTO[] }>(
getListServiceAccountKeysQueryKey({ id: accountId }),
)
? queryClient.getQueryData<{
data: ServiceaccounttypesGettableFactorAPIKeyDTO[];
}>(getListServiceAccountKeysQueryKey({ id: accountId }))
: null;
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;

View File

@@ -0,0 +1,74 @@
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;

View File

@@ -92,6 +92,23 @@
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 {
@@ -239,6 +256,113 @@
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 {
@@ -429,7 +553,7 @@
}
}
.sa-disable-dialog {
.sa-delete-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);

View File

@@ -1,25 +1,29 @@
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, PowerOff, X } from '@signozhq/icons';
import { Key, LayoutGrid, Plus, Trash2, 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 { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { 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,
@@ -27,12 +31,14 @@ import {
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DisableAccountModal from './DisableAccountModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
import OverviewTab from './OverviewTab';
import type { SaveError } from './utils';
import { ServiceAccountDrawerTab } from './utils';
import './ServiceAccountDrawer.styles.scss';
@@ -69,12 +75,16 @@ function ServiceAccountDrawer({
SA_QUERY_PARAMS.ADD_KEY,
parseAsBoolean.withDefault(false),
);
const [, setIsDisableOpen] = useQueryState(
SA_QUERY_PARAMS.DISABLE_SA,
const [, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_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,
@@ -93,21 +103,30 @@ function ServiceAccountDrawer({
[accountData],
);
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
selectedAccountId ?? '',
);
useEffect(() => {
if (account) {
setLocalName(account.name ?? '');
setLocalRoles(account.roles ?? []);
if (account?.id) {
setLocalName(account?.name ?? '');
setKeysPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account?.id]);
setSaveErrors([]);
}, [account?.id, account?.name, setKeysPage]);
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
useEffect(() => {
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
}, [currentRoles]);
const isDeleted =
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
JSON.stringify([...localRoles].sort()) !==
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
const {
roles: availableRoles,
@@ -133,51 +152,189 @@ function ServiceAccountDrawer({
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
{
mutation: {
onSuccess: () => {
toast.success('Service account updated successfully', {
richColors: true,
});
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 });
},
},
},
// 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>),
[],
);
function handleSave(): void {
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),
};
});
return [...rest, ...roleErrors];
});
}
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === 'Roles update' ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
return;
}
updateAccount({
pathParams: { id: account.id },
data: { name: localName, email: account.email, roles: localRoles },
});
}
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,
]);
const handleClose = useCallback((): void => {
setIsDisableOpen(null);
setIsDeleteOpen(null);
setIsAddKeyOpen(null);
setSelectedAccountId(null);
setActiveTab(null);
setKeysPage(null);
setEditKeyId(null);
setSaveErrors([]);
}, [
setSelectedAccountId,
setActiveTab,
setKeysPage,
setEditKeyId,
setIsAddKeyOpen,
setIsDisableOpen,
setIsDeleteOpen,
]);
const drawerContent = (
@@ -220,7 +377,7 @@ function ServiceAccountDrawer({
variant="outlined"
size="sm"
color="secondary"
disabled={isDisabled}
disabled={isDeleted}
onClick={(): void => {
setIsAddKeyOpen(true);
}}
@@ -251,22 +408,23 @@ function ServiceAccountDrawer({
<OverviewTab
account={account}
localName={localName}
onNameChange={setLocalName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDisabled}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDisabled}
isDisabled={isDeleted}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
@@ -298,20 +456,20 @@ function ServiceAccountDrawer({
/>
) : (
<>
{!isDisabled && (
{!isDeleted && (
<Button
variant="ghost"
color="destructive"
className="sa-drawer__footer-btn"
onClick={(): void => {
setIsDisableOpen(true);
setIsDeleteOpen(true);
}}
>
<PowerOff size={12} />
Disable Service Account
<Trash2 size={12} />
Delete Service Account
</Button>
)}
{!isDisabled && (
{!isDeleted && (
<div className="sa-drawer__footer-right">
<Button
variant="solid"
@@ -359,7 +517,7 @@ function ServiceAccountDrawer({
className="sa-drawer"
/>
<DisableAccountModal />
<DeleteAccountModal />
<AddKeyModal />
</>

View File

@@ -1,5 +1,5 @@
import { toast } from '@signozhq/sonner';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } 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,17 +14,16 @@ const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_abc123',
serviceAccountId: 'sa-1',
};
function renderModal(
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
keyItem: ServiceaccounttypesGettableFactorAPIKeyDTO | null = mockKey,
searchParams: Record<string, string> = {
account: 'sa-1',
'edit-key': 'key-1',

View File

@@ -1,5 +1,5 @@
import { toast } from '@signozhq/sonner';
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { ServiceaccounttypesGettableFactorAPIKeyDTO } 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,13 +14,12 @@ const mockToast = jest.mocked(toast);
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
{
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as any,
key: 'snz_prod_123',
serviceAccountId: 'sa-1',
},
{
@@ -28,7 +27,6 @@ const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
name: 'Staging Key',
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
key: 'snz_stag_456',
serviceAccountId: 'sa-1',
},
];

View File

@@ -23,7 +23,9 @@ 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_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
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 activeAccountResponse = {
id: 'sa-1',
@@ -35,10 +37,10 @@ const activeAccountResponse = {
updatedAt: '2026-01-02T00:00:00Z',
};
const disabledAccountResponse = {
const deletedAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DISABLED',
status: 'DELETED',
};
function renderDrawer(
@@ -67,7 +69,23 @@ describe('ServiceAccountDrawer', () => {
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
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: {} })),
),
);
@@ -115,8 +133,6 @@ describe('ServiceAccountDrawer', () => {
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'CI Bot Updated',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
}),
);
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
@@ -125,6 +141,7 @@ 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(
@@ -132,6 +149,10 @@ 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();
@@ -146,21 +167,22 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(
expect(updateSpy).not.toHaveBeenCalled();
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
id: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
});
});
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
const statusSpy = jest.fn();
it('"Delete Service Account" opens confirm dialog; confirming sends delete request', async () => {
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
statusSpy(await req.json());
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
@@ -170,19 +192,19 @@ describe('ServiceAccountDrawer', () => {
await screen.findByDisplayValue('CI Bot');
await user.click(
screen.getByRole('button', { name: /Disable Service Account/i }),
screen.getByRole('button', { name: /Delete Service Account/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /Disable service account CI Bot/i,
name: /Delete service account CI Bot/i,
});
expect(dialog).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
expect(deleteSpy).toHaveBeenCalled();
});
await waitFor(() => {
@@ -190,14 +212,17 @@ describe('ServiceAccountDrawer', () => {
});
});
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
it('deleted account shows read-only name, no Save button, no Delete button', async () => {
server.use(
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
res(ctx.status(200), ctx.json({ data: deletedAccountResponse })),
),
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' });
@@ -208,7 +233,7 @@ describe('ServiceAccountDrawer', () => {
screen.queryByRole('button', { name: /Save Changes/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Disable Service Account/i }),
screen.queryByRole('button', { name: /Delete Service Account/i }),
).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
@@ -248,3 +273,169 @@ 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();
});
});
});

View File

@@ -1,6 +1,13 @@
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',

View File

@@ -8,7 +8,6 @@ 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',
@@ -18,7 +17,6 @@ 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',
@@ -39,7 +37,6 @@ 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();
});
@@ -49,8 +46,6 @@ 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 () => {

View File

@@ -25,32 +25,6 @@ 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 (
@@ -59,9 +33,16 @@ 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">
DISABLED
{status ? status.toUpperCase() : 'UNKNOWN'}
</Badge>
);
}
@@ -98,13 +79,6 @@ 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',

View File

@@ -38,7 +38,6 @@ 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',

View File

@@ -248,15 +248,5 @@ 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),
},
];
}

View File

@@ -26,7 +26,6 @@ export const GlobalShortcuts = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsAPIKeys: 'shift+g+k',
NavigateToSettingsNotificationChannels: 'shift+g+n',
};
@@ -47,7 +46,6 @@ 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',
@@ -72,7 +70,6 @@ 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',

View File

@@ -1,685 +0,0 @@
.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);
}
}

View File

@@ -1,99 +0,0 @@
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);
});
});
});

View File

@@ -1,874 +0,0 @@
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;

View File

@@ -16,7 +16,6 @@ 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';
@@ -265,23 +264,21 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{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),
}}
/>
)}
<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

View File

@@ -284,6 +284,15 @@ export default function TableViewActions(
error,
);
}
// If the value is valid JSON (object or array), pretty-print it for copying
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2);
}
} catch {
// not JSON, return as-is
}
return text;
}, [fieldData.value]);

View File

@@ -1,18 +1,10 @@
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 {
getGetServiceAccountQueryKey,
useListServiceAccounts,
} from 'api/generated/services/serviceaccount';
import type {
GetServiceAccount200,
ListServiceAccounts200,
} from 'api/generated/services/sigNoz.schemas';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
@@ -59,29 +51,13 @@ 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({
query: { onSuccess: seedAccountCache },
});
} = useListServiceAccounts();
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
@@ -97,10 +73,10 @@ function ServiceAccountsSettings(): JSX.Element {
[allAccounts],
);
const disabledCount = useMemo(
const deletedCount = useMemo(
() =>
allAccounts.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
).length,
[allAccounts],
);
@@ -112,9 +88,9 @@ function ServiceAccountsSettings(): JSX.Element {
result = result.filter(
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
);
} else if (filterMode === FilterMode.Disabled) {
} else if (filterMode === FilterMode.Deleted) {
result = result.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Deleted,
);
}
@@ -122,9 +98,7 @@ function ServiceAccountsSettings(): JSX.Element {
const q = searchQuery.trim().toLowerCase();
result = result.filter(
(a) =>
a.name?.toLowerCase().includes(q) ||
a.email?.toLowerCase().includes(q) ||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
);
}
@@ -174,15 +148,15 @@ function ServiceAccountsSettings(): JSX.Element {
},
},
{
key: FilterMode.Disabled,
key: FilterMode.Deleted,
label: (
<div className="sa-settings-filter-option">
<span>Disabled {disabledCount}</span>
{filterMode === FilterMode.Disabled && <Check size={14} />}
<span>Deleted {deletedCount}</span>
{filterMode === FilterMode.Deleted && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Disabled);
setFilterMode(FilterMode.Deleted);
setPage(1);
},
},
@@ -192,8 +166,8 @@ function ServiceAccountsSettings(): JSX.Element {
switch (filterMode) {
case FilterMode.Active:
return `Active ⎯ ${activeCount}`;
case FilterMode.Disabled:
return `Disabled ⎯ ${disabledCount}`;
case FilterMode.Deleted:
return `Deleted ⎯ ${deletedCount}`;
default:
return `All accounts ⎯ ${totalCount}`;
}

View File

@@ -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 } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ServiceAccountsSettings from '../ServiceAccountsSettings';
@@ -149,7 +149,7 @@ describe('ServiceAccountsSettings (integration)', () => {
);
expect(
await screen.findByRole('button', { name: /Disable Service Account/i }),
await screen.findByRole('button', { name: /Delete Service Account/i }),
).toBeInTheDocument();
});
@@ -187,14 +187,16 @@ describe('ServiceAccountsSettings (integration)', () => {
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
await screen.findByDisplayValue('CI Bot Updated');
expect(listRefetchSpy).toHaveBeenCalled();
await waitFor(() => {
expect(listRefetchSpy).toHaveBeenCalled();
});
});
it('"New Service Account" button opens the Create Service Account modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<NuqsTestingAdapter hasMemory>
<ServiceAccountsSettings />
</NuqsTestingAdapter>,
);

View File

@@ -1 +0,0 @@
export const IS_SERVICE_ACCOUNTS_ENABLED = false;

View File

@@ -9,5 +9,5 @@ export const SA_QUERY_PARAMS = {
ADD_KEY: 'add-key',
EDIT_KEY: 'edit-key',
REVOKE_KEY: 'revoke-key',
DISABLE_SA: 'disable-sa',
DELETE_SA: 'delete-sa',
} as const;

View File

@@ -8,7 +8,6 @@ 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),
@@ -18,19 +17,18 @@ export function toServiceAccountRow(
export enum FilterMode {
All = 'all',
Active = 'active',
Disabled = 'disabled',
Deleted = 'deleted',
}
export enum ServiceAccountStatus {
Active = 'ACTIVE',
Disabled = 'DISABLED',
Deleted = 'DELETED',
}
export interface ServiceAccountRow {
id: string;
name: string;
email: string;
roles: string[];
status: string;
createdAt: string | null;
updatedAt: string | null;

View File

@@ -692,9 +692,6 @@ 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),
);
@@ -720,7 +717,6 @@ 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);

View File

@@ -19,7 +19,6 @@ import {
Github,
HardDrive,
Home,
Key,
Keyboard,
Layers2,
LayoutGrid,
@@ -366,13 +365,6 @@ 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',

View File

@@ -158,7 +158,6 @@ export const routesToSkip = [
ROUTES.MEMBERS_SETTINGS,
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,

View File

@@ -1,14 +0,0 @@
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(),
});

View File

@@ -0,0 +1,113 @@
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,
};
}

View File

@@ -1,541 +0,0 @@
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,
},
};

View File

@@ -5,7 +5,6 @@ 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';
@@ -84,12 +83,10 @@ 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 ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -118,11 +115,9 @@ 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 ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -146,11 +141,9 @@ 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 ||
(IS_SERVICE_ACCOUNTS_ENABLED &&
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS)
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -53,7 +53,6 @@ describe('SettingsPage nav sections', () => {
'billing',
'roles',
'members',
'api-keys',
'sso',
'integrations',
'ingestion',
@@ -82,12 +81,9 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['billing', 'roles', 'api-keys'])(
'does not render "%s" element',
(id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
},
);
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
});
describe('Self-hosted Admin', () => {
@@ -99,7 +95,7 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['roles', 'members', 'api-keys', 'integrations', 'sso', 'ingestion'])(
it.each(['roles', 'members', 'integrations', 'sso', 'ingestion'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();

View File

@@ -1,7 +1,6 @@
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';
@@ -22,7 +21,6 @@ import {
Cpu,
CreditCard,
Keyboard,
KeySquare,
Pencil,
Plus,
Shield,
@@ -114,19 +112,6 @@ 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,

View File

@@ -1,11 +1,9 @@
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,
@@ -64,11 +62,7 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...apiKeys(t), ...membersSettings(t));
if (IS_SERVICE_ACCOUNTS_ENABLED) {
settings.push(...serviceAccountsSettings(t));
}
settings.push(...membersSettings(t), ...serviceAccountsSettings(t));
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve

View File

@@ -47,6 +47,9 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
},
mutations: {
retry: false,
},
},
});

View File

@@ -1,56 +0,0 @@
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;
};

View File

@@ -108,7 +108,6 @@ 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'],

View File

@@ -59,7 +59,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
Response: new(citypes.Account),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
@@ -139,7 +139,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableService),
Response: new(citypes.Service),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -150,7 +150,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",

View File

@@ -50,6 +50,11 @@ 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),

View File

@@ -5,6 +5,7 @@ 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"
)
@@ -44,6 +45,23 @@ 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"},
@@ -51,7 +69,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccount),
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -61,6 +79,74 @@ 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"},
@@ -78,23 +164,6 @@ 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"},
@@ -136,7 +205,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.FactorAPIKey, 0),
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -43,74 +43,6 @@ 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"},

View File

@@ -21,11 +21,15 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
}
}

View File

@@ -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, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,11 +30,5 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
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
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
}

View File

@@ -97,11 +97,7 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
}
if len(roles) != len(names) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided names: %v", names,
)
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", names)
}
return roles, nil
@@ -122,11 +118,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
}
if len(roles) != len(ids) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided ids: %v", ids,
)
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", ids)
}
return roles, nil

View File

@@ -148,7 +148,12 @@ func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []
return err
}
return provider.Write(ctx, tuples, nil)
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
}
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleNames []string, updatedRoleNames []string, subject string) error {
@@ -180,7 +185,13 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
if err != nil {
return err
}
return provider.Write(ctx, nil, tuples)
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
}
func (provider *provider) CreateManagedRoles(ctx context.Context, _ valuer.UUID, managedRoles []*authtypes.Role) error {

View File

@@ -140,9 +140,22 @@ 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, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
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
}
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)

View File

@@ -20,6 +20,12 @@ 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_]+$`)
)

View File

@@ -15,14 +15,16 @@ 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) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) 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
@@ -36,10 +38,16 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -120,5 +128,8 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -0,0 +1,24 @@
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
}
}

View File

@@ -0,0 +1,169 @@
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]
}

View File

@@ -40,17 +40,6 @@ 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),
@@ -90,17 +79,6 @@ 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),
@@ -139,17 +117,6 @@ 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),
}
@@ -186,13 +153,28 @@ func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
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
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
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)
})
}

View File

@@ -61,8 +61,10 @@ func (m *IdentN) Wrap(next http.Handler) http.Handler {
ctx = authtypes.NewContextWithClaims(ctx, claims)
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("identn_provider", claims.IdentNProvider)
comment.Set("identn_provider", claims.IdentNProvider.StringValue())
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)

View File

@@ -1,81 +0,0 @@
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...)
}
})
}

View File

@@ -2,7 +2,6 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -10,118 +9,156 @@ import (
)
const (
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
)
type badResponseLoggingWriter interface {
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
http.ResponseWriter
// Get the status code.
// StatusCode returns the HTTP status code written to the response.
StatusCode() int
// Get the error while writing.
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
rw: rw,
buffer: buffer,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingBadResponseLoggingWriter{b, f}
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
}
return &b
}
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error // The error returned when downstream Write() fails.
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
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 {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
f http.Flusher
}
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
func (writer *nonFlushingResponseCapture) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) 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.logBody {
if writer.captureBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingResponseCapture) 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 *nonFlushingBadResponseLoggingWriter) StatusCode() int {
func (writer *nonFlushingResponseCapture) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
func (writer *nonFlushingResponseCapture) WriteError() error {
return writer.writeError
}
func (writer *flushingBadResponseLoggingWriter) Flush() {
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = io.WriteString(writer.buffer, "...")
_, _ = writer.buffer.WriteString("...")
writer.bodyBytesLeft = 0
writer.logBody = false
writer.captureBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -0,0 +1,88 @@
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))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -42,6 +43,45 @@ 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)

View File

@@ -58,6 +58,31 @@ 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)

View File

@@ -10,33 +10,29 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
// todo: will move this in types layer with service account integration
type apiKeyTokenKey struct{}
type provider struct {
store sqlstore.SQLStore
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
serviceAccount serviceaccount.Module
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
func NewFactory(serviceAccount serviceaccount.Module) 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(providerSettings, store, config)
return New(serviceAccount, config, providerSettings)
})
}
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, config identn.Config) (identn.IdentN, error) {
func New(serviceAccount serviceaccount.Module, config identn.Config, providerSettings factory.ProviderSettings) (identn.IdentN, error) {
return &provider{
store: store,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
serviceAccount: serviceAccount,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
}, nil
}
@@ -54,75 +50,44 @@ func (provider *provider) Test(req *http.Request) bool {
}
func (provider *provider) Pre(req *http.Request) *http.Request {
token := provider.extractToken(req)
if token == "" {
apiKey := provider.extractToken(req)
if apiKey == "" {
return req
}
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
ctx := authtypes.NewContextWithAPIKey(req.Context(), apiKey)
return req.WithContext(ctx)
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
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)
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
return nil, err
}
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)
identity, err := provider.serviceAccount.GetIdentity(ctx, apiKey)
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) {
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
return
}
_, _, _ = 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))
_, _, _ = 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
}
return true, nil
})
}
func (provider *provider) extractToken(req *http.Request) string {

View File

@@ -79,22 +79,15 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
rootUser, _, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
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(
provider.identity = authtypes.NewPrincipalUserIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
role,
authtypes.IdentNProviderImpersonation,
)

View File

@@ -13,16 +13,16 @@ type Module interface {
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
@@ -30,17 +30,20 @@ type Module interface {
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
// CreateService creates a new service for a cloud integration account.
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
@@ -50,6 +53,39 @@ type Module interface {
// ListDashboards returns list of dashboards across all connected cloud integration accounts
// for enabled services in the org. This list gets added to dashboard list page
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
// GetCloudProvider returns cloud provider specific module
GetCloudProvider(provider citypes.CloudProviderType) (CloudProviderModule, error)
}
type CloudProviderModule interface {
GetConnectionArtifact(ctx context.Context, creds *citypes.SignozCredentials, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServiceDefinitions returns all service definitions for this cloud provider.
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
// GetServiceDefinition returns the service definition for the given service ID.
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
// IsServiceEnabled returns true if the service has at least one signal (logs or metrics) enabled.
IsServiceEnabled(ctx context.Context, config *citypes.ServiceConfig) bool
// IsMetricsEnabled returns true if the service config has metrics explicitly enabled.
// Used to gate dashboard availability — dashboards are only shown when metrics are enabled.
IsMetricsEnabled(ctx context.Context, config *citypes.ServiceConfig) bool
// ServiceConfigFromStorableServiceConfig converts a stored service's provider-specific JSON config
// into a *ServiceConfig wrapper.
ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*citypes.ServiceConfig, error)
// StorableConfigFromServiceConfig serializes a ServiceConfig into the raw provider-specific
// JSON string suitable for storing in StorableCloudIntegrationService.Config.
// It strips any signal config (logs/metrics) that the service's SupportedSignals declares unsupported.
StorableConfigFromServiceConfig(ctx context.Context, cfg *citypes.ServiceConfig, supported citypes.SupportedSignals) (string, error)
// BuildIntegrationConfig compiles the provider-specific integration config from the account
// and list of configured services. This is the config returned to the agent on check-in.
BuildIntegrationConfig(ctx context.Context, account *citypes.Account, services []*citypes.StorableCloudIntegrationService) (*citypes.ProviderIntegrationConfig, error)
}
type Handler interface {

View File

@@ -0,0 +1,174 @@
package implcloudintegration
import (
"bytes"
"context"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*
var definitionFiles embed.FS
type definitionStore struct{}
// NewServiceDefinitionStore creates a new ServiceDefinitionStore backed by the embedded filesystem.
func NewServiceDefinitionStore() citypes.ServiceDefinitionStore {
return &definitionStore{}
}
// Get reads and hydrates the service definition for the given provider and service ID.
func (s *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
svcDir := path.Join(definitionsRoot, provider.StringValue(), serviceID.StringValue())
def, err := readServiceDefinition(svcDir)
if err != nil {
return nil, errors.New(errors.TypeNotFound, citypes.ErrCodeServiceDefinitionNotFound, fmt.Sprintf("service definition not found for service id %q", serviceID.StringValue()))
}
return def, nil
}
// List reads and hydrates all service definitions for the given provider, sorted by ID.
func (s *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
providerDir := path.Join(definitionsRoot, provider.StringValue())
entries, err := fs.ReadDir(definitionFiles, providerDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition dirs for %s", provider.StringValue())
}
var result []*citypes.ServiceDefinition
for _, entry := range entries {
if !entry.IsDir() {
continue
}
svcDir := path.Join(providerDir, entry.Name())
def, err := readServiceDefinition(svcDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition for %s/%s", provider.StringValue(), entry.Name())
}
result = append(result, def)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}
func readServiceDefinition(svcDir string) (*citypes.ServiceDefinition, error) {
integrationJSONPath := path.Join(svcDir, "integration.json")
raw, err := definitionFiles.ReadFile(integrationJSONPath)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", integrationJSONPath)
}
var specMap map[string]any
if err := json.Unmarshal(raw, &specMap); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse %s", integrationJSONPath)
}
hydrated, err := hydrateFileURIs(specMap, definitionFiles, svcDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't hydrate file URIs in %s", integrationJSONPath)
}
reEncoded, err := json.Marshal(hydrated)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't re-encode hydrated spec from %s", integrationJSONPath)
}
var def citypes.ServiceDefinition
decoder := json.NewDecoder(bytes.NewReader(reEncoded))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&def); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't decode service definition from %s", integrationJSONPath)
}
if err := validateServiceDefinition(&def); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "invalid service definition in %s", svcDir)
}
return &def, nil
}
func validateServiceDefinition(def *citypes.ServiceDefinition) error {
if def.Strategy == nil {
return errors.NewInternalf(errors.CodeInternal, "telemetryCollectionStrategy is required")
}
seenDashboardIDs := map[string]struct{}{}
for _, d := range def.Assets.Dashboards {
if _, seen := seenDashboardIDs[d.ID]; seen {
return errors.NewInternalf(errors.CodeInternal, "duplicate dashboard id %q", d.ID)
}
seenDashboardIDs[d.ID] = struct{}{}
}
return nil
}
// hydrateFileURIs walks a JSON-decoded value and replaces any "file://<path>" strings
// with the actual file contents (text for .md, base64 data URI for .svg, parsed JSON for .json).
func hydrateFileURIs(v any, embeddedFS embed.FS, basedir string) (any, error) {
switch val := v.(type) {
case map[string]any:
result := make(map[string]any, len(val))
for k, child := range val {
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
if err != nil {
return nil, err
}
result[k] = hydrated
}
return result, nil
case []any:
result := make([]any, len(val))
for i, child := range val {
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
if err != nil {
return nil, err
}
result[i] = hydrated
}
return result, nil
case string:
if !strings.HasPrefix(val, "file://") {
return val, nil
}
return readEmbeddedFile(embeddedFS, path.Join(basedir, val[len("file://"):]))
}
return v, nil
}
func readEmbeddedFile(embeddedFS embed.FS, filePath string) (any, error) {
contents, err := embeddedFS.ReadFile(filePath)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read embedded file %s", filePath)
}
switch {
case strings.HasSuffix(filePath, ".md"):
return string(contents), nil
case strings.HasSuffix(filePath, ".svg"):
return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(contents)), nil
case strings.HasSuffix(filePath, ".json"):
var parsed any
if err := json.Unmarshal(contents, &parsed); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse JSON file %s", filePath)
}
return parsed, nil
default:
return nil, errors.NewInternalf(errors.CodeInternal, "unsupported file type for embedded reference: %s", filePath)
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 85 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x="2.5" y="2.5"/><symbol id="A" overflow="visible"><g stroke="none"><path d="M0 41.579C0 20.293 17.84 3.157 40 3.157s40 17.136 40 38.422S62.16 80 40 80 0 62.864 0 41.579z" fill="#9d5025"/><path d="M0 38.422C0 17.136 17.84 0 40 0s40 17.136 40 38.422-17.84 38.422-40 38.422S0 59.707 0 38.422z" fill="#f58536"/><path d="M51.672 7.387v13.952H28.327V7.387zm18.061 40.378v11.364h-11.83V47.765zm-14.958 0v11.364h-11.83V47.765zm-18.206 0v11.364h-11.83V47.765zm-14.959 0v11.364H9.78V47.765z"/><path d="M14.63 37.929h2.13v11.149h-2.13z"/><path d="M14.63 37.929h17.088v2.045H14.63z"/><path d="M29.589 37.929h2.13v11.149H29.59zm18.206 0h2.13v11.149h-2.13z"/><path d="M47.795 37.929h17.088v2.045H47.795z"/><path d="M62.754 37.929h2.13v11.149h-2.129zm-40.631-7.954h2.13v8.977h-2.13zM38.935 19.28h2.13v10.859h-2.129z"/><path d="M22.123 29.116h35.32v2.045h-35.32z"/><path d="M55.314 29.975h2.13v8.977h-2.129z"/></g></symbol></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,468 @@
{
"id": "alb",
"title": "ALB",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
},
"dataCollected": {
"metrics": [
{
"name": "aws_ApplicationELB_ActiveConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ActiveConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_AnomalousHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ConsumedLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_2XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HTTPCode_Target_4XX_Count_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_HealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_MitigatedHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_NewConnectionCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_PeakLCUs_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_count",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_max",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_min",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_ProcessedBytes_sum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCountPerTarget_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_RequestCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_count",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_max",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_min",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_TargetResponseTime_sum",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_max",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_min",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnHealthyHostCount_sum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateDNS_sum",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_count",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_max",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_min",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "aws_ApplicationELB_UnhealthyStateRouting_sum",
"unit": "None",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
{
"Namespace": "AWS/ApplicationELB"
}
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "ALB Overview",
"description": "Overview of Application Load Balancer",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor Application Load Balancers with SigNoz
Collect key ALB metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +1,14 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
<stop stop-color="#4D27A8" offset="0%"></stop>
<stop stop-color="#A166FF" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="url(#linearGradient-1)">
<rect id="Rectangle" x="0" y="0" width="24" height="24"></rect>
</g>
<path d="M6,6.76751613 L8,5.43446738 L8,18.5659476 L6,17.2328988 L6,6.76751613 Z M5,6.49950633 L5,17.4999086 C5,17.6669147 5.084,17.8239204 5.223,17.9159238 L8.223,19.9159969 C8.307,19.971999 8.403,20 8.5,20 C8.581,20 8.662,19.9809993 8.736,19.9409978 C8.898,19.8539947 9,19.6849885 9,19.4999817 L9,16.9998903 L10,16.9998903 L10,15.9998537 L9,15.9998537 L9,7.99956118 L10,7.99956118 L10,6.99952461 L9,6.99952461 L9,4.49943319 C9,4.31542646 8.898,4.14542025 8.736,4.0594171 C8.574,3.97241392 8.377,3.98141425 8.223,4.08341798 L5.223,6.08349112 C5.084,6.17649452 5,6.33250022 5,6.49950633 L5,6.49950633 Z M19,17.2328988 L17,18.5659476 L17,5.43446738 L19,6.76751613 L19,17.2328988 Z M19.777,6.08349112 L16.777,4.08341798 C16.623,3.98141425 16.426,3.97241392 16.264,4.0594171 C16.102,4.14542025 16,4.31542646 16,4.49943319 L16,6.99952461 L15,6.99952461 L15,7.99956118 L16,7.99956118 L16,15.9998537 L15,15.9998537 L15,16.9998903 L16,16.9998903 L16,19.4999817 C16,19.6849885 16.102,19.8539947 16.264,19.9409978 C16.338,19.9809993 16.419,20 16.5,20 C16.597,20 16.693,19.971999 16.777,19.9159969 L19.777,17.9159238 C19.916,17.8239204 20,17.6669147 20,17.4999086 L20,6.49950633 C20,6.33250022 19.916,6.17649452 19.777,6.08349112 L19.777,6.08349112 Z M13,7.99956118 L14,7.99956118 L14,6.99952461 L13,6.99952461 L13,7.99956118 Z M11,7.99956118 L12,7.99956118 L12,6.99952461 L11,6.99952461 L11,7.99956118 Z M13,16.9998903 L14,16.9998903 L14,15.9998537 L13,15.9998537 L13,16.9998903 Z M11,16.9998903 L12,16.9998903 L12,15.9998537 L11,15.9998537 L11,16.9998903 Z M13.18,14.884813 L10.18,12.3847215 C10.065,12.288718 10,12.1487129 10,11.9997075 C10,11.851702 10.065,11.7106969 10.18,11.6156934 L13.18,9.11560199 L13.82,9.88463011 L11.281,11.9997075 L13.82,14.1157848 L13.18,14.884813 Z" id="Amazon-API-Gateway_Icon_16_Squid" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Some files were not shown because too many files have changed in this diff Show More