Compare commits

..

65 Commits

Author SHA1 Message Date
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
82 changed files with 2224 additions and 2422 deletions

View File

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

View File

@@ -144,8 +144,6 @@ telemetrystore:
##################### Prometheus #####################
prometheus:
# The maximum time a PromQL query is allowed to run before being aborted.
timeout: 2m
active_query_tracker:
# Whether to enable the active query tracker.
enabled: true

View File

@@ -2263,28 +2263,27 @@ components:
type: object
ServiceaccounttypesPostableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
items:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
type: string
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesServiceAccountRole:
properties:
name:
type: string
required:
- name
type: object
ServiceaccounttypesServiceAccountWithRoles:
ServiceaccounttypesServiceAccount:
properties:
createdAt:
format: date-time
type: string
deletedAt:
format: date-time
type: string
email:
type: string
id:
@@ -2295,7 +2294,7 @@ components:
type: string
roles:
items:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
type: string
type: array
status:
type: string
@@ -2306,9 +2305,10 @@ components:
- id
- name
- email
- roles
- status
- orgId
- roles
- deletedAt
type: object
ServiceaccounttypesUpdatableFactorAPIKey:
properties:
@@ -2323,14 +2323,17 @@ components:
type: object
ServiceaccounttypesUpdatableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
items:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
type: string
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesUpdatableServiceAccountStatus:
@@ -2463,6 +2466,43 @@ components:
required:
- id
type: object
TypesGettableAPIKey:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
createdByUser:
$ref: '#/components/schemas/TypesUser'
expiresAt:
format: int64
type: integer
id:
type: string
lastUsed:
format: int64
type: integer
name:
type: string
revoked:
type: boolean
role:
type: string
token:
type: string
updatedAt:
format: date-time
type: string
updatedBy:
type: string
updatedByUser:
$ref: '#/components/schemas/TypesUser'
userId:
type: string
required:
- id
type: object
TypesIdentifiable:
properties:
id:
@@ -2517,6 +2557,16 @@ components:
required:
- id
type: object
TypesPostableAPIKey:
properties:
expiresInDays:
format: int64
type: integer
name:
type: string
role:
type: string
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
@@ -2570,6 +2620,56 @@ components:
required:
- id
type: object
TypesStorableAPIKey:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
name:
type: string
revoked:
type: boolean
role:
type: string
token:
type: string
updatedAt:
format: date-time
type: string
updatedBy:
type: string
userId:
type: string
required:
- id
type: object
TypesUser:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
email:
type: string
id:
type: string
isRoot:
type: boolean
orgId:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -4616,6 +4716,222 @@ paths:
summary: Update org preference
tags:
- preferences
/api/v1/pats:
get:
deprecated: false
description: This endpoint lists all api keys
operationId: ListAPIKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/TypesGettableAPIKey'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List api keys
tags:
- users
post:
deprecated: false
description: This endpoint creates an api key
operationId: CreateAPIKey
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableAPIKey'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesGettableAPIKey'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create api key
tags:
- users
/api/v1/pats/{id}:
delete:
deprecated: false
description: This endpoint revokes an api key
operationId: RevokeAPIKey
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Revoke api key
tags:
- users
put:
deprecated: false
description: This endpoint updates an api key
operationId: UpdateAPIKey
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesStorableAPIKey'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update api key
tags:
- users
/api/v1/public/dashboards/{id}:
get:
deprecated: false
@@ -5226,7 +5542,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
type: array
status:
type: string
@@ -5390,7 +5706,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountWithRoles'
$ref: '#/components/schemas/ServiceaccounttypesServiceAccount'
status:
type: string
required:

View File

@@ -34,22 +34,9 @@ func (server *Server) Stop(ctx context.Context) error {
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser:
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount:
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)

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, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {

View File

@@ -14,9 +14,10 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -49,7 +50,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't provision PAT for cloud integration:",
@@ -109,40 +110,84 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
ah.Respond(w, result)
}
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
string, *basemodel.ApiError,
) {
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
if apiErr != nil {
return "", apiErr
}
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
orgIdUUID, err := valuer.NewUUID(orgId)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't parse orgId: %w", err,
))
}
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
))
}
for _, p := range allPats {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
return p.Token, nil
}
}
slog.InfoContext(ctx, "no PAT found for cloud integration, creating a new one",
"cloud_provider", cloudProvider,
)
newPAT, err := types.NewStorableAPIKey(
integrationPATName,
integrationUser.ID,
types.RoleViewer,
0,
)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
return factorAPIKey.Key, nil
return newPAT.Token, nil
}
func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Context, orgId valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, *basemodel.ApiError) {
prefix := ah.Signoz.Modules.ServiceAccount.Config().Email.Domain
cloudIntegrationServiceAccount := serviceaccounttypes.NewServiceAccountWithRoles("integration", prefix, []*serviceaccounttypes.ServiceAccountRole{{Name: authtypes.SigNozViewerRoleName}}, serviceaccounttypes.ServiceAccountStatusActive, orgId)
cloudIntegrationServiceAccount, err := ah.Signoz.Modules.ServiceAccount.GetOrCreate(ctx, cloudIntegrationServiceAccount)
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
ctx context.Context, orgId string, cloudProvider string,
) (*types.User, *basemodel.ApiError) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
return cloudIntegrationServiceAccount, nil
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
ctx,
cloudIntegrationUser,
user.WithFactorPassword(password),
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
}
return cloudIntegrationUser, nil
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -257,7 +257,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, store)
},
ManagerOptionsHook: func(opts *rules.ManagerOptions) {
// Set Prometheus provider for PromQL queries

View File

@@ -2642,26 +2642,28 @@ export interface ServiceaccounttypesPostableServiceAccountDTO {
/**
* @type string
*/
name: string;
/**
* @type array
*/
roles: ServiceaccounttypesServiceAccountRoleDTO[];
}
export interface ServiceaccounttypesServiceAccountRoleDTO {
email: string;
/**
* @type string
*/
name: string;
/**
* @type array
*/
roles: string[];
}
export interface ServiceaccounttypesServiceAccountWithRolesDTO {
export interface ServiceaccounttypesServiceAccountDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/**
* @type string
*/
@@ -2681,7 +2683,7 @@ export interface ServiceaccounttypesServiceAccountWithRolesDTO {
/**
* @type array
*/
roles: ServiceaccounttypesServiceAccountRoleDTO[];
roles: string[];
/**
* @type string
*/
@@ -2706,6 +2708,10 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -2713,7 +2719,7 @@ export interface ServiceaccounttypesUpdatableServiceAccountDTO {
/**
* @type array
*/
roles: ServiceaccounttypesServiceAccountRoleDTO[];
roles: string[];
}
export interface ServiceaccounttypesUpdatableServiceAccountStatusDTO {
@@ -2866,6 +2872,63 @@ export interface TypesDeprecatedUserDTO {
updatedAt?: Date;
}
export interface TypesGettableAPIKeyDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
createdByUser?: TypesUserDTO;
/**
* @type integer
* @format int64
*/
expiresAt?: number;
/**
* @type string
*/
id: string;
/**
* @type integer
* @format int64
*/
lastUsed?: number;
/**
* @type string
*/
name?: string;
/**
* @type boolean
*/
revoked?: boolean;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
token?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
updatedByUser?: TypesUserDTO;
/**
* @type string
*/
userId?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -2948,6 +3011,22 @@ export interface TypesOrganizationDTO {
updatedAt?: Date;
}
export interface TypesPostableAPIKeyDTO {
/**
* @type integer
* @format int64
*/
expiresInDays?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
role?: string;
}
export interface TypesPostableBulkInviteRequestDTO {
/**
* @type array
@@ -3020,6 +3099,88 @@ 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 TypesUserDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email?: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isRoot?: boolean;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface ZeustypesGettableHostDTO {
/**
* @type array
@@ -3514,6 +3675,31 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type ListAPIKeys200 = {
/**
* @type array
*/
data: TypesGettableAPIKeyDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAPIKey201 = {
data: TypesGettableAPIKeyDTO;
/**
* @type string
*/
status: string;
};
export type RevokeAPIKeyPathParameters = {
id: string;
};
export type UpdateAPIKeyPathParameters = {
id: string;
};
export type GetPublicDashboardDataPathParameters = {
id: string;
};
@@ -3596,7 +3782,7 @@ export type ListServiceAccounts200 = {
/**
* @type array
*/
data: ServiceaccounttypesServiceAccountWithRolesDTO[];
data: ServiceaccounttypesServiceAccountDTO[];
/**
* @type string
*/
@@ -3618,7 +3804,7 @@ export type GetServiceAccountPathParameters = {
id: string;
};
export type GetServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
data: ServiceaccounttypesServiceAccountDTO;
/**
* @type string
*/

View File

@@ -21,6 +21,7 @@ import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
ChangePasswordPathParameters,
CreateAPIKey201,
CreateInvite201,
DeleteUserPathParameters,
GetMyUser200,
@@ -28,14 +29,19 @@ import type {
GetResetPasswordTokenPathParameters,
GetUser200,
GetUserPathParameters,
ListAPIKeys200,
ListUsers200,
RenderErrorResponseDTO,
RevokeAPIKeyPathParameters,
TypesChangePasswordRequestDTO,
TypesDeprecatedUserDTO,
TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesStorableAPIKeyDTO,
UpdateAPIKeyPathParameters,
UpdateUser200,
UpdateUserPathParameters,
} from '../sigNoz.schemas';
@@ -409,6 +415,349 @@ export const useCreateBulkInvite = <
return useMutation(mutationOptions);
};
/**
* This endpoint lists all api keys
* @summary List api keys
*/
export const listAPIKeys = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListAPIKeys200>({
url: `/api/v1/pats`,
method: 'GET',
signal,
});
};
export const getListAPIKeysQueryKey = () => {
return [`/api/v1/pats`] as const;
};
export const getListAPIKeysQueryOptions = <
TData = Awaited<ReturnType<typeof listAPIKeys>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAPIKeys>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListAPIKeysQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAPIKeys>>> = ({
signal,
}) => listAPIKeys(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listAPIKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAPIKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof listAPIKeys>>
>;
export type ListAPIKeysQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List api keys
*/
export function useListAPIKeys<
TData = Awaited<ReturnType<typeof listAPIKeys>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAPIKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAPIKeysQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List api keys
*/
export const invalidateListAPIKeys = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListAPIKeysQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates an api key
* @summary Create api key
*/
export const createAPIKey = (
typesPostableAPIKeyDTO: BodyType<TypesPostableAPIKeyDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAPIKey201>({
url: `/api/v1/pats`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableAPIKeyDTO,
signal,
});
};
export const getCreateAPIKeyMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createAPIKey>>,
TError,
{ data: BodyType<TypesPostableAPIKeyDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createAPIKey>>,
TError,
{ data: BodyType<TypesPostableAPIKeyDTO> },
TContext
> => {
const mutationKey = ['createAPIKey'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createAPIKey>>,
{ data: BodyType<TypesPostableAPIKeyDTO> }
> = (props) => {
const { data } = props ?? {};
return createAPIKey(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateAPIKeyMutationResult = NonNullable<
Awaited<ReturnType<typeof createAPIKey>>
>;
export type CreateAPIKeyMutationBody = BodyType<TypesPostableAPIKeyDTO>;
export type CreateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create api key
*/
export const useCreateAPIKey = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createAPIKey>>,
TError,
{ data: BodyType<TypesPostableAPIKeyDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createAPIKey>>,
TError,
{ data: BodyType<TypesPostableAPIKeyDTO> },
TContext
> => {
const mutationOptions = getCreateAPIKeyMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint revokes an api key
* @summary Revoke api key
*/
export const revokeAPIKey = ({ id }: RevokeAPIKeyPathParameters) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/pats/${id}`,
method: 'DELETE',
});
};
export const getRevokeAPIKeyMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof revokeAPIKey>>,
TError,
{ pathParams: RevokeAPIKeyPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof revokeAPIKey>>,
TError,
{ pathParams: RevokeAPIKeyPathParameters },
TContext
> => {
const mutationKey = ['revokeAPIKey'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof revokeAPIKey>>,
{ pathParams: RevokeAPIKeyPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return revokeAPIKey(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type RevokeAPIKeyMutationResult = NonNullable<
Awaited<ReturnType<typeof revokeAPIKey>>
>;
export type RevokeAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Revoke api key
*/
export const useRevokeAPIKey = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof revokeAPIKey>>,
TError,
{ pathParams: RevokeAPIKeyPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof revokeAPIKey>>,
TError,
{ pathParams: RevokeAPIKeyPathParameters },
TContext
> => {
const mutationOptions = getRevokeAPIKeyMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint updates an api key
* @summary Update api key
*/
export const updateAPIKey = (
{ id }: UpdateAPIKeyPathParameters,
typesStorableAPIKeyDTO: BodyType<TypesStorableAPIKeyDTO>,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/pats/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: typesStorableAPIKeyDTO,
});
};
export const getUpdateAPIKeyMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateAPIKey>>,
TError,
{
pathParams: UpdateAPIKeyPathParameters;
data: BodyType<TypesStorableAPIKeyDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateAPIKey>>,
TError,
{
pathParams: UpdateAPIKeyPathParameters;
data: BodyType<TypesStorableAPIKeyDTO>;
},
TContext
> => {
const mutationKey = ['updateAPIKey'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateAPIKey>>,
{
pathParams: UpdateAPIKeyPathParameters;
data: BodyType<TypesStorableAPIKeyDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateAPIKey(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateAPIKeyMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAPIKey>>
>;
export type UpdateAPIKeyMutationBody = BodyType<TypesStorableAPIKeyDTO>;
export type UpdateAPIKeyMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update api key
*/
export const useUpdateAPIKey = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateAPIKey>>,
TError,
{
pathParams: UpdateAPIKeyPathParameters;
data: BodyType<TypesStorableAPIKeyDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateAPIKey>>,
TError,
{
pathParams: UpdateAPIKeyPathParameters;
data: BodyType<TypesStorableAPIKeyDTO>;
},
TContext
> => {
const mutationOptions = getUpdateAPIKeyMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint resets the password by token
* @summary Reset password

View File

@@ -34,7 +34,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccountWithRoles, 0),
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -51,7 +51,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
Response: new(serviceaccounttypes.ServiceAccount),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},

View File

@@ -43,6 +43,74 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
ID: "CreateAPIKey",
Tags: []string{"users"},
Summary: "Create api key",
Description: "This endpoint creates an api key",
Request: new(types.PostableAPIKey),
RequestContentType: "application/json",
Response: new(types.GettableAPIKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
ID: "ListAPIKeys",
Tags: []string{"users"},
Summary: "List api keys",
Description: "This endpoint lists all api keys",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
ID: "UpdateAPIKey",
Tags: []string{"users"},
Summary: "Update api key",
Description: "This endpoint updates an api key",
Request: new(types.StorableAPIKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
ID: "RevokeAPIKey",
Tags: []string{"users"},
Summary: "Revoke api key",
Description: "This endpoint revokes an api key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},

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, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,5 +30,11 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(user.ID, orgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
}

View File

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

View File

@@ -136,22 +136,9 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector, roleSelectors []authtypes.Selector) error {
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser:
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount:
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)

View File

@@ -40,6 +40,17 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -79,6 +90,17 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -117,6 +139,17 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
@@ -153,28 +186,13 @@ func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(
req.Context(),
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
}
next(rw, req)
})
}

View File

@@ -61,10 +61,8 @@ func (m *IdentN) Wrap(next http.Handler) http.Handler {
ctx = authtypes.NewContextWithClaims(ctx, claims)
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("identn_provider", claims.IdentNProvider.StringValue())
comment.Set("identn_provider", claims.IdentNProvider)
comment.Set("user_id", claims.UserID)
comment.Set("service_account_id", claims.ServiceAccountID)
comment.Set("principal", claims.Principal.StringValue())
comment.Set("org_id", claims.OrgID)
ctx = ctxtypes.NewContextWithComment(ctx, comment)

View File

@@ -10,29 +10,33 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
// todo: will move this in types layer with service account integration
type apiKeyTokenKey struct{}
type provider struct {
serviceAccount serviceaccount.Module
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
store sqlstore.SQLStore
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func NewFactory(serviceAccount serviceaccount.Module) factory.ProviderFactory[identn.IdentN, identn.Config] {
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(serviceAccount, config, providerSettings)
return New(providerSettings, store, config)
})
}
func New(serviceAccount serviceaccount.Module, config identn.Config, providerSettings factory.ProviderSettings) (identn.IdentN, error) {
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, config identn.Config) (identn.IdentN, error) {
return &provider{
serviceAccount: serviceAccount,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
store: store,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
}, nil
}
@@ -50,44 +54,75 @@ func (provider *provider) Test(req *http.Request) bool {
}
func (provider *provider) Pre(req *http.Request) *http.Request {
apiKey := provider.extractToken(req)
if apiKey == "" {
token := provider.extractToken(req)
if token == "" {
return req
}
ctx := authtypes.NewContextWithAPIKey(req.Context(), apiKey)
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
return req.WithContext(ctx)
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
apiKey, err := authtypes.APIKeyFromContext(ctx)
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing api key")
}
var apiKey types.StorableAPIKey
err := provider.
store.
BunDB().
NewSelect().
Model(&apiKey).
Where("token = ?", apiKeyToken).
Scan(ctx)
if err != nil {
return nil, err
}
identity, err := provider.serviceAccount.GetIdentity(ctx, apiKey)
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.User
err = provider.
store.
BunDB().
NewSelect().
Model(&user).
Where("id = ?", apiKey.UserID).
Scan(ctx)
if err != nil {
return nil, err
}
identity := authtypes.NewIdentity(user.ID, user.OrgID, user.Email, apiKey.Role, provider.Name())
return identity, nil
}
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return
}
_, _, _ = provider.sfGroup.Do(apiKey, func() (any, error) {
if err := provider.serviceAccount.SetLastObservedAt(ctx, apiKey, time.Now()); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
return false, err
_, _, _ = provider.sfGroup.Do(apiKeyToken, func() (any, error) {
_, err := provider.
store.
BunDB().
NewUpdate().
Model(new(types.StorableAPIKey)).
Set("last_used = ?", time.Now()).
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to update last used of api key", errors.Attr(err))
}
return true, nil
})
}
func (provider *provider) extractToken(req *http.Request) string {

View File

@@ -79,15 +79,22 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
rootUser, _, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
provider.identity = authtypes.NewPrincipalUserIdentity(
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
role,
authtypes.IdentNProviderImpersonation,
)

View File

@@ -43,7 +43,7 @@ type Module interface {
Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error)
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error

View File

@@ -7,7 +7,6 @@ import (
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
@@ -23,12 +22,11 @@ import (
type handler struct {
module dashboard.Module
authz authz.AuthZ
providerSettings factory.ProviderSettings
}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, authz authz.AuthZ) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings, authz: authz}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
@@ -59,7 +57,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
dashboardMigrator.Migrate(ctx, req)
}
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.UserID), req)
if err != nil {
render.Error(rw, err)
return
@@ -110,7 +108,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
diff := 0
// Allow multiple deletions for API key requests; enforce for others
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer {
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer.StringValue() {
diff = 1
}
@@ -157,24 +155,7 @@ func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
return
}
isAdmin := false
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, isAdmin, *req.Locked)
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, claims.Role, *req.Locked)
if err != nil {
render.Error(rw, err)
return

View File

@@ -100,13 +100,13 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return dashboard, nil
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
err = dashboard.LockUnlock(lock, role, updatedBy)
if err != nil {
return err
}

View File

@@ -1,46 +0,0 @@
package serviceaccount
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
)
type Config struct {
// Email config for service accounts
Email EmailConfig `mapstructure:"email"`
// Analytics collection config for service accounts
Analytics AnalyticsConfig `mapstructure:"analytics"`
}
type EmailConfig struct {
Domain string `mapstructure:"domain"`
}
type AnalyticsConfig struct {
Enabled bool `mapstructure:"enabled"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("serviceaccount"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Email: EmailConfig{
Domain: "signozserviceaccount.com",
},
Analytics: AnalyticsConfig{
Enabled: true,
},
}
}
func (c Config) Validate() error {
if c.Email.Domain == "" {
return errors.New(errors.TypeInvalidInput, serviceaccounttypes.ErrCodeServiceAccountInvalidConfig, "email domain cannot be empty")
}
return nil
}

View File

@@ -35,7 +35,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
serviceAccount := serviceaccounttypes.NewServiceAccountWithRoles(req.Name, handler.module.Config().Email.Domain, req.Roles, serviceaccounttypes.ServiceAccountStatusActive, valuer.MustNewUUID(claims.OrgID))
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, req.Email, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
err = handler.module.Create(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
if err != nil {
render.Error(rw, err)
@@ -111,7 +111,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = serviceAccount.Update(req.Name, req.Roles)
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
if err != nil {
render.Error(rw, err)
return
@@ -299,12 +299,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
return
}
err = factorAPIKey.Update(req.Name, req.ExpiresAt)
if err != nil {
render.Error(rw, err)
return
}
factorAPIKey.Update(req.Name, req.ExpiresAt)
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
if err != nil {
render.Error(rw, err)

View File

@@ -2,47 +2,39 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
emptyOrgID valuer.UUID = valuer.UUID{}
)
type module struct {
store serviceaccounttypes.Store
authz authz.AuthZ
cache cache.Cache
analytics analytics.Analytics
settings factory.ScopedProviderSettings
config serviceaccount.Config
store serviceaccounttypes.Store
authz authz.AuthZ
emailing emailing.Emailing
settings factory.ScopedProviderSettings
}
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, cache cache.Cache, analytics analytics.Analytics, providerSettings factory.ProviderSettings, config serviceaccount.Config) serviceaccount.Module {
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
return &module{store: store, authz: authz, cache: cache, analytics: analytics, settings: settings, config: config}
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccountWithRoles) error {
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
// validates the presence of all roles passed in the create request
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, serviceAccount.RoleNames())
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, serviceAccount.Roles)
if err != nil {
return err
}
// authz actions cannot run in sql transactions
err = module.authz.Grant(ctx, orgID, serviceAccount.RoleNames(), authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
err = module.authz.Grant(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
if err != nil {
return err
}
@@ -66,20 +58,17 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
return err
}
module.identifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.trackUser(ctx, orgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return nil
}
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccountWithRoles) (*serviceaccounttypes.ServiceAccountWithRoles, error) {
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
existingServiceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, serviceAccount.OrgID, serviceAccount.Name)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingServiceAccount != nil {
return serviceaccounttypes.NewServiceAccountRoleFromStorables(existingServiceAccount, serviceAccount.Roles), nil
return serviceAccount, nil
}
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
@@ -90,7 +79,7 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
return serviceAccount, nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, error) {
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
@@ -112,13 +101,13 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return nil, err
}
serviceAccountRoles, err := serviceaccounttypes.NewRolesFromStorableServiceAccountRoles(storableServiceAccountRoles, roles)
rolesNames, err := serviceaccounttypes.NewRolesFromStorableServiceAccountRoles(storableServiceAccountRoles, roles)
if err != nil {
return nil, err
}
serviceAccountWithRoles := serviceaccounttypes.NewServiceAccountRoleFromStorables(storableServiceAccount, serviceAccountRoles)
return serviceAccountWithRoles, nil
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, rolesNames)
return serviceAccount, nil
}
func (module *module) GetWithoutRoles(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
@@ -127,11 +116,12 @@ func (module *module) GetWithoutRoles(ctx context.Context, orgID valuer.UUID, id
return nil, err
}
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount)
// passing []string{} (not nil to prevent panics) roles as the function isn't supposed to put roles.
serviceAccount := serviceaccounttypes.NewServiceAccountFromStorables(storableServiceAccount, []string{})
return serviceAccount, nil
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccountWithRoles, error) {
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error) {
storableServiceAccounts, err := module.store.List(ctx, orgID)
if err != nil {
return nil, err
@@ -150,17 +140,17 @@ func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*serviceac
}
// fill in the role fetched data back to service account
serviceAccountsWithRoles := serviceaccounttypes.NewServiceAccountsWithRolesFromRoles(storableServiceAccounts, roles, saIDToRoleIDs)
return serviceAccountsWithRoles, nil
serviceAccounts := serviceaccounttypes.NewServiceAccountsFromRoles(storableServiceAccounts, roles, saIDToRoleIDs)
return serviceAccounts, nil
}
func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccountWithRoles) error {
func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
serviceAccount, err := module.Get(ctx, orgID, input.ID)
if err != nil {
return err
}
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, input.RoleNames())
roles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, input.Roles)
if err != nil {
return err
}
@@ -196,13 +186,11 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
return err
}
module.identifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.trackUser(ctx, orgID.String(), input.ID.String(), "Service Account Updated", input.Traits())
return nil
}
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccountWithRoles) error {
err := module.authz.Revoke(ctx, orgID, input.RoleNames(), authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
if err != nil {
return err
}
@@ -226,11 +214,6 @@ func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input
return err
}
// delete the cache when updating status for service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(input.ID))
module.identifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.trackUser(ctx, orgID.String(), input.ID.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -241,7 +224,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
}
// revoke from authz first as this cannot run in sql transaction
err = module.authz.Revoke(ctx, orgID, serviceAccount.RoleNames(), authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
err = module.authz.Revoke(ctx, orgID, serviceAccount.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, serviceAccount.ID.String(), orgID, nil))
if err != nil {
return err
}
@@ -268,11 +251,6 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
// delete the cache when deleting service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(id))
module.identifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.trackUser(ctx, orgID.String(), id.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -285,31 +263,22 @@ func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serv
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err == nil {
module.trackUser(ctx, serviceAccount.OrgID.StringValue(), serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
if err != nil {
return err
}
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "New API Key created for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
return nil
}
func (module *module) GetOrCreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error) {
existingFactorAPIKey, err := module.store.GetFactorAPIKeyByName(ctx, factorAPIKey.ServiceAccountID, factorAPIKey.Name)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingFactorAPIKey != nil {
return serviceaccounttypes.NewFactorAPIKeyFromStorable(existingFactorAPIKey), nil
}
err = module.CreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return nil, err
}
return factorAPIKey, nil
}
func (module *module) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
storableFactorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
if err != nil {
@@ -328,15 +297,12 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
}
func (module *module) UpdateFactorAPIKey(ctx context.Context, orgID valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
if err != nil {
return err
}
// delete the cache when updating the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.trackUser(ctx, orgID.String(), serviceAccountID.String(), "API Key updated", factorAPIKey.Traits())
return nil
}
@@ -356,120 +322,14 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
return err
}
// delete the cache when revoking the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.trackUser(ctx, serviceAccount.OrgID.StringValue(), serviceAccountID.String(), "API Key revoked", factorAPIKey.Traits())
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
return nil
}
func (module *module) Config() serviceaccount.Config {
return module.config
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.count"] = count
}
count, err = module.store.CountFactorAPIKeysByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.keys.count"] = count
}
return stats, nil
}
func (module *module) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
apiKey, err := module.getOrGetSetAPIKey(ctx, key)
if err != nil {
return nil, err
}
if err := apiKey.IsExpired(); err != nil {
return nil, err
}
identity, err := module.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
if err != nil {
return nil, err
}
return identity, nil
}
func (module *module) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
return module.store.UpdateLastObservedAt(ctx, key, lastObservedAt)
}
func (module *module) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
factorAPIkey := new(serviceaccounttypes.FactorAPIKey)
err := module.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return factorAPIkey, nil
}
storable, err := module.store.GetFactorAPIKeyByKey(ctx, key)
if err != nil {
return nil, err
}
factorAPIkey = serviceaccounttypes.NewFactorAPIKeyFromStorable(storable)
err = module.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey, time.Duration(factorAPIkey.ExpiresAt))
if err != nil {
return nil, err
}
return factorAPIkey, nil
}
func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID valuer.UUID) (*authtypes.Identity, error) {
identity := new(authtypes.Identity)
err := module.cache.Get(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return identity, nil
}
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
if err != nil {
return nil, err
}
identity = storableServiceAccount.ToIdentity()
err = module.cache.Set(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity, 0)
if err != nil {
return nil, err
}
return identity, nil
}
func (module *module) trackUser(ctx context.Context, orgID string, userID string, event string, attrs map[string]any) {
if module.config.Analytics.Enabled {
module.analytics.TrackUser(ctx, orgID, userID, event, attrs)
}
}
func (module *module) identifyUser(ctx context.Context, orgID string, userID string, traits map[string]any) {
if module.config.Analytics.Enabled {
module.analytics.IdentifyUser(ctx, orgID, userID, traits)
}
}
func apiKeyCacheKey(apiKey string) string {
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
}
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -2,7 +2,6 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
@@ -59,7 +58,7 @@ func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UU
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Where("status = ?", serviceaccounttypes.ServiceAccountStatusActive).
Where("status = ?", serviceaccounttypes.StatusActive).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with name: %s doesn't exist in org: %s", name, orgID.String())
@@ -85,23 +84,6 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
return storable, nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableServiceAccount)
count, err := store.
sqlstore.
BunDB().
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccount, error) {
storables := make([]*serviceaccounttypes.StorableServiceAccount, 0)
@@ -249,60 +231,6 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
return storable, nil
}
func (store *store) GetFactorAPIKeyByKey(ctx context.Context, key string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("key = ?", key).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with key: %s doesn't exist.", key)
}
return storable, nil
}
func (store *store) GetFactorAPIKeyByName(ctx context.Context, serviceAccountID valuer.UUID, name string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("service_account_id = ?", serviceAccountID.String()).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with name: %s doesn't exist in service account: %s", name, serviceAccountID.String())
}
return storable, nil
}
func (store *store) CountFactorAPIKeysByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
count, err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Join("JOIN service_account").
JoinOn("service_account.id = factor_api_key.service_account_id").
Where("service_account.org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
@@ -326,7 +254,6 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
BunDBCtx(ctx).
NewUpdate().
Model(storable).
WherePK().
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
@@ -336,22 +263,6 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
return nil
}
func (store *store) UpdateLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
TableExpr("factor_api_key").
Set("last_observed_at = ?", lastObservedAt).
Where("key = ?", key).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -3,34 +3,32 @@ package serviceaccount
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// Creates a new service account for an organization.
Create(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccountWithRoles) error
Create(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
// Gets a service account by id.
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccountWithRoles, error)
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
// Gets or creates a service account by name
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccountWithRoles) (*serviceaccounttypes.ServiceAccountWithRoles, error)
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
// Gets a service account by id without fetching roles.
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
// List all service accounts for an organization.
List(context.Context, valuer.UUID) ([]*serviceaccounttypes.ServiceAccountWithRoles, error)
List(context.Context, valuer.UUID) ([]*serviceaccounttypes.ServiceAccount, error)
// Updates an existing service account
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccountWithRoles) error
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
// Updates an existing service account status
UpdateStatus(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccountWithRoles) error
UpdateStatus(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
// Deletes an existing service account by id
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -41,24 +39,14 @@ type Module interface {
// Gets a factor API key by id
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error)
GetOrCreateFactorAPIKey(context.Context, *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error)
// Lists all the API keys for a service account
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
// Updates an existing API key for a service account
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
// Set the last observed at for an api key.
SetLastObservedAt(context.Context, string, time.Time) error
// Revokes an existing API key for a service account
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
// Gets the identity for service account based on the factor api key.
GetIdentity(context.Context, string) (*authtypes.Identity, error)
Config() Config
}
type Handler interface {

View File

@@ -160,7 +160,18 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewPrincipalUserIdentity(newUser.ID, newUser.OrgID, newUser.Email, authtypes.IdentNProviderTokenizer), map[string]string{})
userRoles, err := module.userGetter.GetUserRoles(ctx, newUser.ID)
if err != nil {
return "", err
}
if len(userRoles) == 0 {
return "", errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
finalRole := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(newUser.ID, newUser.OrgID, newUser.Email, finalRole, authtypes.IdentNProviderTokenizer), map[string]string{})
if err != nil {
return "", err
}

View File

@@ -35,7 +35,7 @@ func (h *handler) GetSpanPercentileDetails(w http.ResponseWriter, r *http.Reques
return
}
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), spanPercentileRequest)
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
if err != nil {
render.Error(w, err)
return

View File

@@ -28,7 +28,7 @@ func NewModule(
}
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "spanpercentile",
instrumentationtypes.CodeFunctionName: "GetSpanPercentile",

View File

@@ -9,7 +9,7 @@ import (
)
type Module interface {
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -12,6 +13,7 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -41,7 +43,7 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
return
}
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &types.PostableBulkInviteRequest{
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &types.PostableBulkInviteRequest{
Invites: []types.PostableInvite{req},
})
if err != nil {
@@ -74,7 +76,7 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
return
}
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &req)
if err != nil {
render.Error(rw, err)
return
@@ -160,7 +162,7 @@ func (h *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user, claims.UserID)
if err != nil {
render.Error(w, err)
return
@@ -181,7 +183,7 @@ func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.IdentityID()); err != nil {
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
render.Error(w, err)
return
}
@@ -272,3 +274,172 @@ func (h *handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := new(types.PostableAPIKey)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
apiKey, err := types.NewStorableAPIKey(
req.Name,
valuer.MustNewUUID(claims.UserID),
req.Role,
req.ExpiresInDays,
)
if err != nil {
render.Error(w, err)
return
}
err = h.setter.CreateAPIKey(ctx, apiKey)
if err != nil {
render.Error(w, err)
return
}
createdApiKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), apiKey.ID)
if err != nil {
render.Error(w, err)
return
}
// just corrected the status code, response is same,
render.Success(w, http.StatusCreated, createdApiKey)
}
func (h *handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
apiKeys, err := h.setter.ListAPIKeys(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(w, err)
return
}
// for backward compatibility
if len(apiKeys) == 0 {
render.Success(w, http.StatusOK, []types.GettableAPIKey{})
return
}
result := make([]*types.GettableAPIKey, len(apiKeys))
for i, apiKey := range apiKeys {
result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey)
}
render.Success(w, http.StatusOK, result)
}
func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := types.StorableAPIKey{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
err = h.setter.UpdateAPIKey(ctx, id, &req, valuer.MustNewUUID(claims.UserID))
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
if err := h.setter.RevokeAPIKey(ctx, id, valuer.MustNewUUID(claims.UserID)); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@@ -56,7 +56,12 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
}
// CreateBulk implements invite.Module.
func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
creator, err := module.store.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
@@ -123,7 +128,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, i
// send password reset emails to all the invited users
for idx, userWithToken := range newUsersWithResetToken {
module.analytics.TrackUser(ctx, orgID.String(), identityID.String(), "Invite Sent", map[string]any{
module.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.Role,
})
@@ -157,7 +162,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, i
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
if err := module.emailing.SendHTML(ctx, userWithToken.User.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": identityEmail.StringValue(),
"inviter_email": creator.Email,
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
@@ -215,12 +220,7 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
return nil
}
func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser, updatedBy string) (*types.DeprecatedUser, error) {
existingUser, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return nil, err
@@ -234,29 +234,19 @@ func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, id stri
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
}
requestor, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
}
roleChange := user.Role != "" && user.Role != existingUser.Role
if roleChange {
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = module.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
if roleChange && requestor.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// make sure the user is not demoting self from admin
if roleChange && existingUser.ID == valuer.MustNewUUID(claims.IdentityID()) && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
if roleChange && existingUser.ID == requestor.ID && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot change self role")
}
@@ -638,6 +628,26 @@ func (module *setter) GetOrCreateUser(ctx context.Context, user *types.User, opt
return user, nil
}
func (module *setter) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
return module.store.CreateAPIKey(ctx, apiKey)
}
func (module *setter) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
return module.store.UpdateAPIKey(ctx, id, apiKey, updaterID)
}
func (module *setter) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
return module.store.ListAPIKeys(ctx, orgID)
}
func (module *setter) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
return module.store.GetAPIKey(ctx, orgID, id)
}
func (module *setter) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error {
return module.store.RevokeAPIKey(ctx, id, removedByUserID)
}
func (module *setter) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, passwd string) (*types.User, error) {
user, err := types.NewRootUser(name, email, organization.ID)
if err != nil {
@@ -693,6 +703,11 @@ func (module *setter) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["user.count.pending_invite"] = counts[types.UserStatusPendingInvite]
}
count, err := module.store.CountAPIKeyByOrgID(ctx, orgID)
if err == nil {
stats["factor.api_key.count"] = count
}
return stats, nil
}

View File

@@ -3,6 +3,7 @@ package impluser
import (
"context"
"database/sql"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -181,6 +182,15 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
}
// delete api keys
_, err = tx.NewDelete().
Model(&types.StorableAPIKey{}).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys")
}
// delete user_preference
_, err = tx.NewDelete().
Model(new(preferencetypes.StorableUserPreference)).
@@ -256,6 +266,15 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
}
// delete api keys
_, err = tx.NewDelete().
Model(&types.StorableAPIKey{}).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys")
}
// delete user_preference
_, err = tx.NewDelete().
Model(new(preferencetypes.StorableUserPreference)).
@@ -402,6 +421,111 @@ func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.Fa
return nil
}
// --- API KEY ---
func (store *store) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
_, err := store.sqlstore.BunDB().NewInsert().
Model(apiKey).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrAPIKeyAlreadyExists, "API key with token: %s already exists", apiKey.Token)
}
return nil
}
func (store *store) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
apiKey.UpdatedBy = updaterID.String()
apiKey.UpdatedAt = time.Now()
_, err := store.sqlstore.BunDB().NewUpdate().
Model(apiKey).
Column("role", "name", "updated_at", "updated_by").
Where("id = ?", id).
Where("revoked = false").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
return nil
}
func (store *store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
orgUserAPIKeys := new(types.OrgUserAPIKey)
if err := store.sqlstore.BunDB().NewSelect().
Model(orgUserAPIKeys).
Relation("Users").
Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("revoked = false")
},
).
Relation("Users.APIKeys.CreatedByUser").
Relation("Users.APIKeys.UpdatedByUser").
Where("id = ?", orgID).
Scan(ctx); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to fetch API keys")
}
// Flatten the API keys from all users
var allAPIKeys []*types.StorableAPIKeyUser
for _, user := range orgUserAPIKeys.Users {
if user.APIKeys != nil {
allAPIKeys = append(allAPIKeys, user.APIKeys...)
}
}
// sort the API keys by updated_at
sort.Slice(allAPIKeys, func(i, j int) bool {
return allAPIKeys[i].UpdatedAt.After(allAPIKeys[j].UpdatedAt)
})
return allAPIKeys, nil
}
func (store *store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUID) error {
updatedAt := time.Now().Unix()
_, err := store.sqlstore.BunDB().NewUpdate().
Model(&types.StorableAPIKey{}).
Set("revoked = ?", true).
Set("updated_by = ?", revokedByUserID).
Set("updated_at = ?", updatedAt).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to revoke API key")
}
return nil
}
func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
apiKey := new(types.OrgUserAPIKey)
if err := store.sqlstore.BunDB().NewSelect().
Model(apiKey).
Relation("Users").
Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("revoked = false").Where("storable_api_key.id = ?", id).
OrderExpr("storable_api_key.updated_at DESC").Limit(1)
},
).
Relation("Users.APIKeys.CreatedByUser").
Relation("Users.APIKeys.UpdatedByUser").
Scan(ctx); err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
// flatten the API keys
flattenedAPIKeys := []*types.StorableAPIKeyUser{}
for _, user := range apiKey.Users {
if user.APIKeys != nil {
flattenedAPIKeys = append(flattenedAPIKeys, user.APIKeys...)
}
}
if len(flattenedAPIKeys) == 0 {
return nil, store.sqlstore.WrapNotFoundErrf(errors.New(errors.TypeNotFound, errors.CodeNotFound, "API key with id: %s does not exist"), types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id)
}
return flattenedAPIKeys[0], nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
user := new(types.User)
@@ -449,6 +573,24 @@ func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UU
return counts, nil
}
func (store *store) CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
apiKey := new(types.StorableAPIKey)
count, err := store.
sqlstore.
BunDB().
NewSelect().
Model(apiKey).
Join("JOIN users ON users.id = storable_api_key.user_id").
Where("org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)

View File

@@ -34,14 +34,21 @@ type Setter interface {
// Initiate forgot password flow for a user
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser, updatedBy string) (*types.DeprecatedUser, error)
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.DeprecatedUser) error
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
// invite
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
// API KEY
CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error)
// Roles
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
@@ -97,4 +104,10 @@ type Handler interface {
ResetPassword(http.ResponseWriter, *http.Request)
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
// API KEY
CreateAPIKey(http.ResponseWriter, *http.Request)
ListAPIKeys(http.ResponseWriter, *http.Request)
UpdateAPIKey(http.ResponseWriter, *http.Request)
RevokeAPIKey(http.ResponseWriter, *http.Request)
}

View File

@@ -3,7 +3,6 @@ package prometheus
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
@@ -21,9 +20,6 @@ type Config struct {
//
// If not set, the prometheus default is used (currently 5m).
LookbackDelta time.Duration `mapstructure:"lookback_delta"`
// Timeout is the maximum time a query is allowed to run before being aborted.
Timeout time.Duration `mapstructure:"timeout"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -37,14 +33,10 @@ func newConfig() factory.Config {
Path: "",
MaxConcurrent: 20,
},
Timeout: 2 * time.Minute,
}
}
func (c Config) Validate() error {
if c.Timeout <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::timeout must be greater than 0")
}
return nil
}

View File

@@ -0,0 +1,3 @@
package prometheus
const FingerprintAsPromLabelName = "fingerprint"

View File

@@ -2,6 +2,7 @@ package prometheus
import (
"log/slog"
"time"
"github.com/prometheus/prometheus/promql"
)
@@ -20,7 +21,7 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
Logger: logger,
Reg: nil,
MaxSamples: 5_0000_000,
Timeout: cfg.Timeout,
Timeout: 2 * time.Minute,
ActiveQueryTracker: activeQueryTracker,
LookbackDelta: cfg.LookbackDelta,
})

View File

@@ -6,8 +6,6 @@ import (
"github.com/prometheus/prometheus/promql"
)
const FingerprintAsPromLabelName string = "fingerprint"
func RemoveExtraLabels(res *promql.Result, labelsToRemove ...string) error {
if len(labelsToRemove) == 0 || res == nil {
return nil

View File

@@ -268,9 +268,9 @@ func (handler *handler) logEvent(ctx context.Context, referrer string, event *qb
}
if !event.HasData {
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
}

View File

@@ -36,28 +36,6 @@ var unquotedDottedNamePattern = regexp.MustCompile(`(?:^|[{,(\s])([a-zA-Z_][a-zA
// This is a common mistake when migrating to UTF-8 syntax.
var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
// tryEnhancePromQLExecError attempts to convert a PromQL execution error into
// a properly typed error. Returns nil if the error is not a recognized execution error.
func tryEnhancePromQLExecError(execErr error) error {
var eqc promql.ErrQueryCanceled
var eqt promql.ErrQueryTimeout
var es promql.ErrStorage
switch {
case errors.As(execErr, &eqc):
return errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled").WithAdditional(eqc.Error())
case errors.As(execErr, &eqt):
return errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timed out").WithAdditional(eqt.Error())
case errors.Is(execErr, context.DeadlineExceeded):
return errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timed out")
case errors.Is(execErr, context.Canceled):
return errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
case errors.As(execErr, &es):
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", execErr)
default:
return nil
}
}
// enhancePromQLError adds helpful context to PromQL parse errors,
// particularly for UTF-8 syntax migration issues where metric and label
// names containing dots need to be quoted.
@@ -235,20 +213,27 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
// NewRangeQuery can fail with execution errors (e.g. context deadline exceeded)
// during the query queue/scheduling stage, not just parse errors.
if err := tryEnhancePromQLExecError(err); err != nil {
return nil, err
}
if err != nil {
return nil, enhancePromQLError(query, err)
}
res := qry.Exec(ctx)
if res.Err != nil {
if err := tryEnhancePromQLExecError(res.Err); err != nil {
return nil, err
var eqc promql.ErrQueryCanceled
var eqt promql.ErrQueryTimeout
var es promql.ErrStorage
switch {
case errors.As(res.Err, &eqc):
return nil, errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
case errors.As(res.Err, &eqt):
return nil, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timeout")
case errors.As(res.Err, &es):
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
if errors.Is(res.Err, context.Canceled) {
return nil, errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)

View File

@@ -1564,7 +1564,7 @@ func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
if errv2 == nil {
switch request.EventType {
case model.TrackEvent:
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), request.EventName, request.Attributes)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, request.EventName, request.Attributes)
}
aH.WriteJSON(w, r, map[string]string{"data": "Event Processed Successfully"})
} else {
@@ -4667,7 +4667,7 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
// Check if result is empty or has no data
if len(result) == 0 {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
@@ -4677,18 +4677,18 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
if len(result[0].List) == 0 {
// Check if first result has no table data
if result[0].Table == nil {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
if len(result[0].Table.Rows) == 0 {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
}
}
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
}

View File

@@ -1407,7 +1407,7 @@ func Test_querier_Traces_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1633,7 +1633,7 @@ func Test_querier_Traces_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1934,7 +1934,7 @@ func Test_querier_Logs_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -2162,7 +2162,7 @@ func Test_querier_Logs_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,

View File

@@ -1459,7 +1459,7 @@ func Test_querier_Traces_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1685,7 +1685,7 @@ func Test_querier_Traces_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1985,7 +1985,7 @@ func Test_querier_Logs_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -2213,7 +2213,7 @@ func Test_querier_Logs_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
"",
time.Duration(time.Second),
nil,

View File

@@ -698,7 +698,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), settings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
prometheustest.New(context.Background(), settings, prometheus.Config{}, telemetryStore),
"",
time.Second,
nil,

View File

@@ -253,7 +253,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, store)
},
ManagerOptionsHook: func(opts *ManagerOptions) {
// Set Prometheus provider for PromQL queries

View File

@@ -99,7 +99,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
providerSettings := instrumentationtest.New().ToProviderSettings()
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(
instrumentationtest.New().Logger(),
nil,

View File

@@ -940,7 +940,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
postableRule.RuleCondition.CompareOp = ruletypes.CompareOp(c.compareOp)
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
@@ -1061,7 +1061,7 @@ func _Enable_this_after_9146_issue_fix_is_merged_TestPromRuleNoData(t *testing.T
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
var target float64 = 0
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
@@ -1281,7 +1281,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
postableRule.RuleCondition.CompareOp = ruletypes.CompareOp(c.compareOp)
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
@@ -1441,7 +1441,7 @@ func TestPromRule_NoData(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{Timeout: 2 * time.Minute},
prometheus.Config{},
telemetryStore,
)
defer func() {
@@ -1590,7 +1590,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{Timeout: 2 * time.Minute},
prometheus.Config{},
telemetryStore,
)
defer func() {
@@ -1748,7 +1748,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{Timeout: 2 * time.Minute, LookbackDelta: lookBackDelta},
prometheus.Config{LookbackDelta: lookBackDelta},
telemetryStore,
)
defer func() {

View File

@@ -779,7 +779,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
},
)
require.NoError(t, err)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
"signoz_calls_total": {
@@ -894,7 +894,7 @@ func TestThresholdRuleNoData(t *testing.T) {
)
assert.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1014,7 +1014,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
}
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1151,7 +1151,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
}
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1418,7 +1418,7 @@ func TestMultipleThresholdRule(t *testing.T) {
},
)
require.NoError(t, err)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Second, nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Second, nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
"signoz_calls_total": {
@@ -2220,7 +2220,7 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
)
require.NoError(t, err)
prometheusProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
prometheusProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheusProvider, "", time.Second, nil, readerCache, options)
rule, err := NewThresholdRule("some-id", valuer.GenerateUUID(), &postableRule, reader, nil, logger)

View File

@@ -22,7 +22,6 @@ import (
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/pprof"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -120,9 +119,6 @@ type Config struct {
// IdentN config
IdentN identn.Config `mapstructure:"identn"`
// ServiceAccount config
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -152,7 +148,6 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
flagger.NewConfigFactory(),
user.NewConfigFactory(),
identn.NewConfigFactory(),
serviceaccount.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -78,7 +78,7 @@ func NewHandlers(
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, authz),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),

View File

@@ -114,6 +114,6 @@ func NewModules(
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, emailing, providerSettings),
}
}

View File

@@ -31,7 +31,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
"github.com/SigNoz/signoz/pkg/modules/promote/implpromote"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -191,9 +190,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdatePlannedMaintenanceRuleFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserRoleFactory(sqlstore, sqlschema),
sqlmigration.NewDropUserRoleColumnFactory(sqlstore, sqlschema),
sqlmigration.NewAddServiceAccountFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateAPIKeyFactory(sqlstore, sqlschema),
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
)
}
@@ -296,11 +292,11 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore
)
}
func NewIdentNProviderFactories(tokenizer tokenizer.Tokenizer, serviceAccount serviceaccount.Module, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
func NewIdentNProviderFactories(sqlstore sqlstore.SQLStore, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.NamedMap[factory.ProviderFactory[identn.IdentN, identn.Config]] {
return factory.MustNewNamedMap(
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
tokenizeridentn.NewFactory(tokenizer),
apikeyidentn.NewFactory(serviceAccount),
apikeyidentn.NewFactory(sqlstore),
)
}

View File

@@ -411,7 +411,7 @@ func New(
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore)
// Initialize identN resolver
identNFactories := NewIdentNProviderFactories(tokenizer, modules.ServiceAccount, orgGetter, userGetter, config.User)
identNFactories := NewIdentNProviderFactories(sqlstore, tokenizer, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err

View File

@@ -1,133 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addServiceAccount struct {
sqlschema sqlschema.SQLSchema
sqlstore sqlstore.SQLStore
}
func NewAddServiceAccountFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_service_account"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addServiceAccount{
sqlschema: sqlschema,
sqlstore: sqlstore,
}, nil
})
}
func (migration *addServiceAccount) Register(migrations *migrate.Migrations) error {
err := migrations.Register(migration.Up, migration.Down)
if err != nil {
return err
}
return nil
}
func (migration *addServiceAccount) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "service_account",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "email", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "status", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "service_account",
ColumnNames: []sqlschema.ColumnName{"name", "org_id"},
Where: "status != 'deleted'",
})
sqls = append(sqls, indexSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "service_account",
ColumnNames: []sqlschema.ColumnName{"email", "org_id"},
Where: "status != 'deleted'",
})
sqls = append(sqls, indexSQLs...)
tableSQLs = migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "service_account_role",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "service_account_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "role_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("service_account_id"),
ReferencedTableName: sqlschema.TableName("service_account"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("role_id"),
ReferencedTableName: sqlschema.TableName("role"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "service_account_role", ColumnNames: []sqlschema.ColumnName{"service_account_id", "role_id"}})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (a *addServiceAccount) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,359 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// sanitizeForEmail converts an arbitrary string into a valid email local part
// by replacing any character that is not alphanumeric, dot, hyphen, or underscore
// with a hyphen, then collapsing consecutive hyphens and trimming leading/trailing hyphens.
var nonEmailLocalPartRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func sanitizeForEmail(name string) string {
s := nonEmailLocalPartRe.ReplaceAllString(name, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "service-account"
}
return s
}
type oldFactorAPIKey68 struct {
bun.BaseModel `bun:"table:factor_api_key"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Token string `bun:"token"`
Role string `bun:"role"`
Name string `bun:"name"`
ExpiresAt time.Time `bun:"expires_at"`
LastUsed time.Time `bun:"last_used"`
Revoked bool `bun:"revoked"`
UserID string `bun:"user_id"`
}
type oldUser68 struct {
bun.BaseModel `bun:"table:users"`
types.Identifiable
DisplayName string `bun:"display_name"`
Email string `bun:"email"`
OrgID string `bun:"org_id"`
}
type oldRole68 struct {
bun.BaseModel `bun:"table:role"`
types.Identifiable
Name string `bun:"name"`
OrgID string `bun:"org_id"`
}
type newServiceAccount68 struct {
bun.BaseModel `bun:"table:service_account"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name"`
Email string `bun:"email"`
Status string `bun:"status"`
OrgID string `bun:"org_id"`
}
type newServiceAccountRole68 struct {
bun.BaseModel `bun:"table:service_account_role"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
ServiceAccountID string `bun:"service_account_id"`
RoleID string `bun:"role_id"`
}
type newFactorAPIKey68 struct {
bun.BaseModel `bun:"table:factor_api_key"`
types.Identifiable
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name"`
Key string `bun:"key"`
ExpiresAt uint64 `bun:"expires_at"`
LastObservedAt time.Time `bun:"last_observed_at"`
ServiceAccountID string `bun:"service_account_id"`
}
type deprecateAPIKey struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewDeprecateAPIKeyFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("deprecate_api_key"), func(_ context.Context, _ factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deprecateAPIKey{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *deprecateAPIKey) Register(migrations *migrate.Migrations) error {
err := migrations.Register(migration.Up, migration.Down)
if err != nil {
return err
}
return nil
}
func (migration *deprecateAPIKey) Up(ctx context.Context, db *bun.DB) error {
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("factor_api_key"))
if err != nil {
return err
}
hasOldSchema := false
for _, col := range table.Columns {
if col.Name == "user_id" {
hasOldSchema = true
break
}
}
if !hasOldSchema {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// get all the api keys
oldKeys := make([]*oldFactorAPIKey68, 0)
err = tx.NewSelect().Model(&oldKeys).Where("revoked = ?", false).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
// get all the unique users
userIDs := make(map[string]struct{})
for _, key := range oldKeys {
userIDs[key.UserID] = struct{}{}
}
userIDList := make([]string, 0, len(userIDs))
for uid := range userIDs {
userIDList = append(userIDList, uid)
}
userMap := make(map[string]*oldUser68)
if len(userIDList) > 0 {
users := make([]*oldUser68, 0)
err = tx.NewSelect().Model(&users).Where("id IN (?)", bun.In(userIDList)).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
for _, u := range users {
userMap[u.ID.String()] = u
}
}
// get the role ids
type orgRoleKey struct {
OrgID string
RoleName string
}
roleMap := make(map[orgRoleKey]string)
if len(userMap) > 0 {
orgIDs := make(map[string]struct{})
for _, u := range userMap {
orgIDs[u.OrgID] = struct{}{}
}
orgIDList := make([]string, 0, len(orgIDs))
for oid := range orgIDs {
orgIDList = append(orgIDList, oid)
}
roles := make([]*oldRole68, 0)
err = tx.NewSelect().Model(&roles).Where("org_id IN (?)", bun.In(orgIDList)).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
for _, r := range roles {
roleMap[orgRoleKey{OrgID: r.OrgID, RoleName: r.Name}] = r.ID.String()
}
}
serviceAccounts := make([]*newServiceAccount68, 0)
serviceAccountRoles := make([]*newServiceAccountRole68, 0)
newKeys := make([]*newFactorAPIKey68, 0)
// Track used names per org for deduplication.
// Names are sanitized first so that dedup, Name, and email all derive
// from the same value — avoiding collisions on the unique (email, org_id) index.
orgNameCount := make(map[string]map[string]int) // orgID -> sanitized name -> count
now := time.Now()
for _, oldKey := range oldKeys {
user, ok := userMap[oldKey.UserID]
if !ok {
// this should never happen as a key cannot exist without a user
continue
}
// Sanitize first, then deduplicate within the same org
if orgNameCount[user.OrgID] == nil {
orgNameCount[user.OrgID] = make(map[string]int)
}
baseName := sanitizeForEmail(oldKey.Name)
count := orgNameCount[user.OrgID][baseName]
finalName := baseName
if count > 0 {
finalName = fmt.Sprintf("%s-%d", baseName, count)
}
orgNameCount[user.OrgID][baseName] = count + 1
saID := valuer.GenerateUUID()
serviceAccounts = append(serviceAccounts, &newServiceAccount68{
Identifiable: types.Identifiable{ID: saID},
CreatedAt: now,
UpdatedAt: now,
Name: finalName,
Email: fmt.Sprintf("%s@signozserviceaccount.com", finalName),
Status: "active",
OrgID: user.OrgID,
})
managedRoleName, ok := authtypes.ExistingRoleToSigNozManagedRoleMap[types.Role(oldKey.Role)]
if !ok {
managedRoleName = authtypes.SigNozViewerRoleName
}
roleID, ok := roleMap[orgRoleKey{OrgID: user.OrgID, RoleName: managedRoleName}]
if ok {
serviceAccountRoles = append(serviceAccountRoles, &newServiceAccountRole68{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
CreatedAt: now,
UpdatedAt: now,
ServiceAccountID: saID.String(),
RoleID: roleID,
})
}
var expiresAtUnix uint64
if !oldKey.ExpiresAt.IsZero() && oldKey.ExpiresAt.Unix() > 0 {
expiresAtUnix = uint64(oldKey.ExpiresAt.Unix())
}
// Convert last_used to last_observed_at.
lastObservedAt := oldKey.LastUsed
if lastObservedAt.IsZero() {
lastObservedAt = oldKey.CreatedAt
}
newKeys = append(newKeys, &newFactorAPIKey68{
Identifiable: oldKey.Identifiable,
CreatedAt: oldKey.CreatedAt,
UpdatedAt: oldKey.UpdatedAt,
Name: oldKey.Name,
Key: oldKey.Token,
ExpiresAt: expiresAtUnix,
LastObservedAt: lastObservedAt,
ServiceAccountID: saID.String(),
})
}
if len(serviceAccounts) > 0 {
if _, err := tx.NewInsert().Model(&serviceAccounts).Exec(ctx); err != nil {
return err
}
}
if len(serviceAccountRoles) > 0 {
if _, err := tx.NewInsert().Model(&serviceAccountRoles).Exec(ctx); err != nil {
return err
}
}
sqls := [][]byte{}
deprecatedFactorAPIKey, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("factor_api_key"))
if err != nil {
return err
}
dropTableSQLS := migration.sqlschema.Operator().DropTable(deprecatedFactorAPIKey)
sqls = append(sqls, dropTableSQLS...)
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "factor_api_key",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "key", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "expires_at", DataType: sqlschema.DataTypeInteger, Nullable: false},
{Name: "last_observed_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "service_account_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("service_account_id"),
ReferencedTableName: sqlschema.TableName("service_account"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "factor_api_key", ColumnNames: []sqlschema.ColumnName{"key"}})
sqls = append(sqls, indexSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "factor_api_key", ColumnNames: []sqlschema.ColumnName{"name", "service_account_id"}})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if len(newKeys) > 0 {
if _, err := tx.NewInsert().Model(&newKeys).Exec(ctx); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *deprecateAPIKey) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,148 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addServiceAccountAuthz struct {
sqlstore sqlstore.SQLStore
}
func NewServiceAccountAuthzactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_service_account_authz"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addServiceAccountAuthz{sqlstore: sqlstore}, nil
})
}
func (migration *addServiceAccountAuthz) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addServiceAccountAuthz) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
type saRoleTuple struct {
ServiceAccountID string
OrgID string
RoleName string
}
rows, err := tx.QueryContext(ctx, `
SELECT sa.id, sa.org_id, r.name
FROM service_account sa
JOIN service_account_role sar ON sar.service_account_id = sa.id
JOIN role r ON r.id = sar.role_id
`)
if err != nil && err != sql.ErrNoRows {
return err
}
defer rows.Close()
tuples := make([]saRoleTuple, 0)
for rows.Next() {
var t saRoleTuple
if err := rows.Scan(&t.ServiceAccountID, &t.OrgID, &t.RoleName); err != nil {
return err
}
tuples = append(tuples, t)
}
for _, t := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + t.OrgID + "/role/" + t.RoleName
saUserID := "organization/" + t.OrgID + "/serviceaccount/" + t.ServiceAccountID
if migration.sqlstore.BunDB().Dialect().Name() == dialect.PG {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount:"+saUserID, "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount:"+saUserID, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount", saUserID, "", "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", objectID, "assignee", "serviceaccount", saUserID, "", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addServiceAccountAuthz) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -2,6 +2,7 @@ package jwttokenizer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/golang-jwt/jwt/v5"
)
@@ -9,15 +10,21 @@ var _ jwt.ClaimsValidator = (*Claims)(nil)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"id"`
Email string `json:"email"`
OrgID string `json:"orgId"`
UserID string `json:"id"`
Email string `json:"email"`
Role types.Role `json:"role"`
OrgID string `json:"orgId"`
}
func (c *Claims) Validate() error {
if c.UserID == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "id is required")
}
// The problem is that when the "role" field is missing entirely from the JSON (as opposed to being present but empty), the UnmarshalJSON method for Role isn't called at all.
// The JSON decoder just sets the Role field to its zero value ("").
if c.Role == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "role is required")
}
if c.OrgID == "" {
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "orgId is required")

View File

@@ -78,6 +78,7 @@ func (provider *provider) Start(ctx context.Context) error {
func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.Identity, meta map[string]string) (*authtypes.Token, error) {
accessTokenClaims := Claims{
UserID: identity.UserID.String(),
Role: identity.Role,
Email: identity.Email.String(),
OrgID: identity.OrgID.String(),
RegisteredClaims: jwt.RegisteredClaims{
@@ -93,6 +94,7 @@ func (provider *provider) CreateToken(ctx context.Context, identity *authtypes.I
refreshTokenClaims := Claims{
UserID: identity.UserID.String(),
Role: identity.Role,
Email: identity.Email.String(),
OrgID: identity.OrgID.String(),
RegisteredClaims: jwt.RegisteredClaims{
@@ -115,7 +117,17 @@ func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (
return nil, err
}
return authtypes.NewPrincipalUserIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), authtypes.IdentNProviderTokenizer), nil
// check claimed role
identity, err := provider.getOrSetIdentity(ctx, emptyOrgID, valuer.MustNewUUID(claims.UserID))
if err != nil {
return nil, err
}
if identity.Role != claims.Role {
return nil, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "claim role mismatch")
}
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role, authtypes.IdentNProviderTokenizer), nil
}
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
@@ -61,6 +62,7 @@ func TestLastObservedAt_Concurrent(t *testing.T) {
&authtypes.Identity{
UserID: valuer.GenerateUUID(),
OrgID: orgID,
Role: types.RoleAdmin,
Email: valuer.MustNewEmail("test@test.com"),
},
map[string]string{},
@@ -72,6 +74,7 @@ func TestLastObservedAt_Concurrent(t *testing.T) {
&authtypes.Identity{
UserID: valuer.GenerateUUID(),
OrgID: orgID,
Role: types.RoleAdmin,
Email: valuer.MustNewEmail("test@test.com"),
},
map[string]string{},

View File

@@ -3,6 +3,7 @@ package sqltokenizerstore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -46,7 +47,25 @@ func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist", userID)
}
return authtypes.NewPrincipalUserIdentity(userID, user.OrgID, user.Email, authtypes.IdentNProviderTokenizer), nil
userRoles := make([]*authtypes.UserRole, 0)
err = store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&userRoles).
Where("user_id = ?", userID).
Relation("Role").
Scan(ctx)
if err != nil {
return nil, err
}
if len(userRoles) == 0 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no roles found for user with id: %s", userID)
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(userID, user.OrgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
}
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {

View File

@@ -22,22 +22,14 @@ var (
AuthNProviderOIDC = AuthNProvider{valuer.NewString("oidc")}
)
var (
PrincipalUser = Principal{valuer.NewString("user")}
PrincipalServiceAccount = Principal{valuer.NewString("service_account")}
)
type AuthNProvider struct{ valuer.String }
type Principal struct{ valuer.String }
type Identity struct {
UserID valuer.UUID `json:"userId"`
ServiceAccountID valuer.UUID `json:"serviceAccountId"`
Principal Principal `json:"principal"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
UserID valuer.UUID `json:"userId"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
Role types.Role `json:"role"`
}
type CallbackIdentity struct {
@@ -87,37 +79,16 @@ func NewStateFromString(state string) (State, error) {
}, nil
}
func NewIdentity(userID valuer.UUID, serviceAccountID valuer.UUID, principal Principal, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
return &Identity{
UserID: userID,
ServiceAccountID: serviceAccountID,
Principal: principal,
OrgID: orgID,
Email: email,
IdenNProvider: identNProvider,
}
}
func NewPrincipalUserIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role, identNProvider IdentNProvider) *Identity {
return &Identity{
UserID: userID,
Principal: PrincipalUser,
OrgID: orgID,
Email: email,
Role: role,
IdenNProvider: identNProvider,
}
}
func NewPrincipalServiceAccountIdentity(serviceAccountID valuer.UUID, orgID valuer.UUID, email valuer.Email, identNProvider IdentNProvider) *Identity {
return &Identity{
ServiceAccountID: serviceAccountID,
Principal: PrincipalServiceAccount,
OrgID: orgID,
Email: email,
IdenNProvider: identNProvider,
}
}
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State, groups []string, role string) *CallbackIdentity {
return &CallbackIdentity{
Name: name,
@@ -147,12 +118,11 @@ func (typ *Identity) UnmarshalBinary(data []byte) error {
func (typ *Identity) ToClaims() Claims {
return Claims{
UserID: typ.UserID.String(),
ServiceAccountID: typ.ServiceAccountID.String(),
Principal: typ.Principal,
Email: typ.Email.String(),
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider,
UserID: typ.UserID.String(),
Email: typ.Email.String(),
Role: typ.Role,
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider.StringValue(),
}
}

View File

@@ -3,21 +3,21 @@ package authtypes
import (
"context"
"log/slog"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
)
type claimsKey struct{}
type accessTokenKey struct{}
type apiKeyKey struct{}
type Claims struct {
UserID string
ServiceAccountID string
Principal Principal
Email string
OrgID string
IdentNProvider IdentNProvider
UserID string
Email string
Role types.Role
OrgID string
IdentNProvider string
}
// NewContextWithClaims attaches individual claims to the context.
@@ -48,42 +48,48 @@ func AccessTokenFromContext(ctx context.Context) (string, error) {
return accessToken, nil
}
func NewContextWithAPIKey(ctx context.Context, apiKey string) context.Context {
return context.WithValue(ctx, apiKeyKey{}, apiKey)
}
func APIKeyFromContext(ctx context.Context) (string, error) {
apiKey, ok := ctx.Value(apiKeyKey{}).(string)
if !ok {
return "", errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
}
return apiKey, nil
}
func (c *Claims) LogValue() slog.Value {
return slog.GroupValue(
slog.String("user_id", c.UserID),
slog.String("service_account_id", c.ServiceAccountID),
slog.String("principal", c.Principal.StringValue()),
slog.String("email", c.Email),
slog.String("role", c.Role.String()),
slog.String("org_id", c.OrgID),
slog.String("identn_provider", c.IdentNProvider.StringValue()),
slog.String("identn_provider", c.IdentNProvider),
)
}
func (c *Claims) IsViewer() error {
if slices.Contains([]types.Role{types.RoleViewer, types.RoleEditor, types.RoleAdmin}, c.Role) {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only viewers/editors/admins can access this resource")
}
func (c *Claims) IsEditor() error {
if slices.Contains([]types.Role{types.RoleEditor, types.RoleAdmin}, c.Role) {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only editors/admins can access this resource")
}
func (c *Claims) IsAdmin() error {
if c.Role == types.RoleAdmin {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can access this resource")
}
func (c *Claims) IsSelfAccess(id string) error {
if c.UserID == id {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only the user/admin can access their own resource")
}
func (c *Claims) IdentityID() string {
if c.Principal == PrincipalUser {
return c.UserID
if c.Role == types.RoleAdmin {
return nil
}
return c.ServiceAccountID
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only the user/admin can access their own resource")
}

View File

@@ -284,15 +284,15 @@ func (dashboard *Dashboard) Update(ctx context.Context, updatableDashboard Updat
return nil
}
func (dashboard *Dashboard) CanLockUnlock(isAdmin bool, updatedBy string) error {
if dashboard.CreatedBy != updatedBy && !isAdmin {
func (dashboard *Dashboard) CanLockUnlock(role types.Role, updatedBy string) error {
if dashboard.CreatedBy != updatedBy && role != types.RoleAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (dashboard *Dashboard) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
err := dashboard.CanLockUnlock(isAdmin, updatedBy)
func (dashboard *Dashboard) LockUnlock(lock bool, role types.Role, updatedBy string) error {
err := dashboard.CanLockUnlock(role, updatedBy)
if err != nil {
return err
}

144
pkg/types/factor_api_key.go Normal file
View File

@@ -0,0 +1,144 @@
package types
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var NEVER_EXPIRES = time.Unix(0, 0)
type PostableAPIKey struct {
Name string `json:"name"`
Role Role `json:"role"`
ExpiresInDays int64 `json:"expiresInDays"`
}
type GettableAPIKey struct {
Identifiable
TimeAuditable
UserAuditable
Token string `json:"token"`
Role Role `json:"role"`
Name string `json:"name"`
ExpiresAt int64 `json:"expiresAt"`
LastUsed int64 `json:"lastUsed"`
Revoked bool `json:"revoked"`
UserID string `json:"userId"`
CreatedByUser *User `json:"createdByUser"`
UpdatedByUser *User `json:"updatedByUser"`
}
type OrgUserAPIKey struct {
*Organization `bun:",extend"`
Users []*UserWithAPIKey `bun:"rel:has-many,join:id=org_id"`
}
type UserWithAPIKey struct {
*User `bun:",extend"`
APIKeys []*StorableAPIKeyUser `bun:"rel:has-many,join:id=user_id"`
}
type StorableAPIKeyUser struct {
StorableAPIKey `bun:",extend"`
CreatedByUser *User `json:"createdByUser" bun:"created_by_user,rel:belongs-to,join:created_by=id"`
UpdatedByUser *User `json:"updatedByUser" bun:"updated_by_user,rel:belongs-to,join:updated_by=id"`
}
type StorableAPIKey struct {
bun.BaseModel `bun:"table:factor_api_key"`
Identifiable
TimeAuditable
UserAuditable
Token string `json:"token" bun:"token,type:text,notnull,unique"`
Role Role `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"`
Name string `json:"name" bun:"name,type:text,notnull"`
ExpiresAt time.Time `json:"-" bun:"expires_at,notnull,nullzero,type:timestamptz"`
LastUsed time.Time `json:"-" bun:"last_used,notnull,nullzero,type:timestamptz"`
Revoked bool `json:"revoked" bun:"revoked,notnull,default:false"`
UserID valuer.UUID `json:"userId" bun:"user_id,type:text,notnull"`
}
func NewStorableAPIKey(name string, userID valuer.UUID, role Role, expiresAt int64) (*StorableAPIKey, error) {
// validate
// we allow the APIKey if expiresAt is not set, which means it never expires
if expiresAt < 0 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "expiresAt must be greater than 0")
}
if name == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name cannot be empty")
}
if role == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role cannot be empty")
}
now := time.Now()
// convert expiresAt to unix timestamp from days
// expiresAt = now.Unix() + (expiresAt * 24 * 60 * 60)
expiresAtTime := now.AddDate(0, 0, int(expiresAt))
// if the expiresAt is 0, it means the APIKey never expires
if expiresAt == 0 {
expiresAtTime = NEVER_EXPIRES
}
// Generate a 32-byte random token.
token := make([]byte, 32)
_, err := rand.Read(token)
if err != nil {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to generate token")
}
// Encode the token in base64.
encodedToken := base64.StdEncoding.EncodeToString(token)
return &StorableAPIKey{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
UserAuditable: UserAuditable{
CreatedBy: userID.String(),
UpdatedBy: userID.String(),
},
Token: encodedToken,
Name: name,
Role: role,
UserID: userID,
ExpiresAt: expiresAtTime,
LastUsed: now,
Revoked: false,
}, nil
}
func NewGettableAPIKeyFromStorableAPIKey(storableAPIKey *StorableAPIKeyUser) *GettableAPIKey {
lastUsed := storableAPIKey.LastUsed.Unix()
if storableAPIKey.LastUsed == storableAPIKey.CreatedAt {
lastUsed = 0
}
return &GettableAPIKey{
Identifiable: storableAPIKey.Identifiable,
TimeAuditable: storableAPIKey.TimeAuditable,
UserAuditable: storableAPIKey.UserAuditable,
Token: storableAPIKey.Token,
Role: storableAPIKey.Role,
Name: storableAPIKey.Name,
ExpiresAt: storableAPIKey.ExpiresAt.Unix(),
LastUsed: lastUsed,
Revoked: storableAPIKey.Revoked,
UserID: storableAPIKey.UserID.String(),
CreatedByUser: storableAPIKey.CreatedByUser,
UpdatedByUser: storableAPIKey.UpdatedByUser,
}
}

View File

@@ -11,10 +11,11 @@ import (
)
var (
ErrCodeAPIKeyInvalidInput = errors.MustNewCode("api_key_invalid_input")
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
)
type StorableFactorAPIKey struct {
@@ -123,15 +124,10 @@ func NewGettableFactorAPIKeyWithKey(id valuer.UUID, key string) *GettableFactorA
}
}
func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) error {
if expiresAt != 0 && time.Now().After(time.Unix(int64(expiresAt), 0)) {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "cannot set api key expiry in the past")
}
func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) {
apiKey.Name = name
apiKey.ExpiresAt = expiresAt
apiKey.UpdatedAt = time.Now()
return nil
}
func (apiKey *FactorAPIKey) IsExpired() error {
@@ -146,6 +142,17 @@ func (apiKey *FactorAPIKey) IsExpired() error {
return nil
}
func (apiKey *FactorAPIKey) UpdateLastObservedAt(lastObservedAt time.Time) error {
if lastObservedAt.Before(apiKey.LastObservedAt) {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyOlderLastObservedAt, "last observed at is before the current last observed at")
}
apiKey.LastObservedAt = lastObservedAt
apiKey.UpdatedAt = time.Now()
return nil
}
func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
type Alias PostableFactorAPIKey
@@ -155,7 +162,7 @@ func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name cannot be empty")
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
}
*key = PostableFactorAPIKey(temp)
@@ -171,24 +178,9 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name cannot be empty")
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
}
*key = UpdatableFactorAPIKey(temp)
return nil
}
func (key FactorAPIKey) MarshalBinary() ([]byte, error) {
return json.Marshal(key)
}
func (key *FactorAPIKey) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, key)
}
func (key *FactorAPIKey) Traits() map[string]any {
return map[string]any{
"name": key.Name,
"expires_at": key.ExpiresAt,
}
}

View File

@@ -4,7 +4,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"time"
@@ -16,7 +15,6 @@ import (
)
var (
ErrCodeServiceAccountInvalidConfig = errors.MustNewCode("service_account_invalid_config")
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
@@ -25,101 +23,87 @@ var (
)
var (
ServiceAccountStatusActive = ServiceAccountStatus{valuer.NewString("active")}
ServiceAccountStatusDeleted = ServiceAccountStatus{valuer.NewString("deleted")}
StatusActive = valuer.NewString("active")
StatusDisabled = valuer.NewString("disabled")
ValidStatus = []valuer.String{StatusActive, StatusDisabled}
)
var (
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
)
type ServiceAccountStatus struct{ valuer.String }
type StorableServiceAccount struct {
bun.BaseModel `bun:"table:service_account,alias:service_account"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name"`
Email valuer.Email `bun:"email"`
Status ServiceAccountStatus `bun:"status"`
OrgID valuer.UUID `bun:"org_id"`
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
DeletedAt time.Time `bun:"deleted_at"`
}
type ServiceAccount struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Status ServiceAccountStatus `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
}
type ServiceAccountWithRoles struct {
*ServiceAccount
Roles []*ServiceAccountRole `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Status valuer.String `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
DeletedAt time.Time `json:"deletedAt" required:"true"`
}
type PostableServiceAccount struct {
Name string `json:"name" required:"true"`
Roles []*ServiceAccountRole `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccount struct {
Name string `json:"name" required:"true"`
Roles []*ServiceAccountRole `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccountStatus struct {
Status ServiceAccountStatus `json:"status" required:"true"`
Status valuer.String `json:"status" required:"true"`
}
func NewServiceAccountWithRoles(name string, emailDomain string, roles []*ServiceAccountRole, status ServiceAccountStatus, orgID valuer.UUID) *ServiceAccountWithRoles {
return &ServiceAccountWithRoles{
ServiceAccount: &ServiceAccount{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Email: valuer.MustNewEmail(fmt.Sprintf("%s@%s", name, emailDomain)),
Status: status,
OrgID: orgID,
func NewServiceAccount(name string, email valuer.Email, roles []string, status valuer.String, orgID valuer.UUID) *ServiceAccount {
return &ServiceAccount{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Roles: roles,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Email: email,
Roles: roles,
Status: status,
OrgID: orgID,
DeletedAt: time.Time{},
}
}
func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccount) *ServiceAccount {
func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccount, roles []string) *ServiceAccount {
return &ServiceAccount{
Identifiable: storableServiceAccount.Identifiable,
TimeAuditable: storableServiceAccount.TimeAuditable,
Name: storableServiceAccount.Name,
Email: storableServiceAccount.Email,
Email: valuer.MustNewEmail(storableServiceAccount.Email),
Roles: roles,
Status: storableServiceAccount.Status,
OrgID: storableServiceAccount.OrgID,
OrgID: valuer.MustNewUUID(storableServiceAccount.OrgID),
DeletedAt: storableServiceAccount.DeletedAt,
}
}
func NewServiceAccountRoleFromStorables(storableServiceAccount *StorableServiceAccount, roles []*ServiceAccountRole) *ServiceAccountWithRoles {
return &ServiceAccountWithRoles{
ServiceAccount: &ServiceAccount{
Identifiable: storableServiceAccount.Identifiable,
TimeAuditable: storableServiceAccount.TimeAuditable,
Name: storableServiceAccount.Name,
Email: storableServiceAccount.Email,
Status: storableServiceAccount.Status,
OrgID: storableServiceAccount.OrgID,
},
Roles: roles,
}
}
func NewServiceAccountsWithRolesFromRoles(storableServiceAccounts []*StorableServiceAccount, roles []*authtypes.Role, serviceAccountIDToRoleIDsMap map[string][]valuer.UUID) []*ServiceAccountWithRoles {
serviceAccountsWithRoles := make([]*ServiceAccountWithRoles, 0, len(storableServiceAccounts))
func NewServiceAccountsFromRoles(storableServiceAccounts []*StorableServiceAccount, roles []*authtypes.Role, serviceAccountIDToRoleIDsMap map[string][]valuer.UUID) []*ServiceAccount {
serviceAccounts := make([]*ServiceAccount, 0, len(storableServiceAccounts))
roleIDToRole := make(map[string]*authtypes.Role, len(roles))
for _, role := range roles {
@@ -129,54 +113,57 @@ func NewServiceAccountsWithRolesFromRoles(storableServiceAccounts []*StorableSer
for _, sa := range storableServiceAccounts {
roleIDs := serviceAccountIDToRoleIDsMap[sa.ID.String()]
roleNames := make([]*ServiceAccountRole, len(roleIDs))
roleNames := make([]string, len(roleIDs))
for idx, rid := range roleIDs {
if role, ok := roleIDToRole[rid.String()]; ok {
roleNames[idx] = &ServiceAccountRole{Name: role.Name}
roleNames[idx] = role.Name
}
}
serviceAccountWithRoles := NewServiceAccountRoleFromStorables(sa, roleNames)
serviceAccountsWithRoles = append(serviceAccountsWithRoles, serviceAccountWithRoles)
account := NewServiceAccountFromStorables(sa, roleNames)
serviceAccounts = append(serviceAccounts, account)
}
return serviceAccountsWithRoles
return serviceAccounts
}
func NewStorableServiceAccount(serviceAccountWithRoles *ServiceAccountWithRoles) *StorableServiceAccount {
func NewStorableServiceAccount(serviceAccount *ServiceAccount) *StorableServiceAccount {
return &StorableServiceAccount{
Identifiable: serviceAccountWithRoles.Identifiable,
TimeAuditable: serviceAccountWithRoles.TimeAuditable,
Name: serviceAccountWithRoles.Name,
Email: serviceAccountWithRoles.Email,
Status: serviceAccountWithRoles.Status,
OrgID: serviceAccountWithRoles.OrgID,
Identifiable: serviceAccount.Identifiable,
TimeAuditable: serviceAccount.TimeAuditable,
Name: serviceAccount.Name,
Email: serviceAccount.Email.String(),
Status: serviceAccount.Status,
OrgID: serviceAccount.OrgID.String(),
DeletedAt: serviceAccount.DeletedAt,
}
}
func (sa *ServiceAccountWithRoles) Update(name string, roles []*ServiceAccountRole) error {
if err := sa.ErrIfDeleted(); err != nil {
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) error {
if err := sa.ErrIfDisabled(); err != nil {
return err
}
sa.Name = name
sa.Email = email
sa.Roles = roles
sa.UpdatedAt = time.Now()
return nil
}
func (sa *ServiceAccountWithRoles) UpdateStatus(status ServiceAccountStatus) error {
if err := sa.ErrIfDeleted(); err != nil {
func (sa *ServiceAccount) UpdateStatus(status valuer.String) error {
if err := sa.ErrIfDisabled(); err != nil {
return err
}
sa.Status = status
sa.UpdatedAt = time.Now()
sa.DeletedAt = time.Now()
return nil
}
func (sa *ServiceAccount) ErrIfDeleted() error {
if sa.Status == ServiceAccountStatusDeleted {
func (sa *ServiceAccount) ErrIfDisabled() error {
if sa.Status == StatusDisabled {
return errors.New(errors.TypeUnsupported, ErrCodeServiceAccountOperationUnsupported, "this operation is not supported for disabled service account")
}
@@ -184,14 +171,10 @@ func (sa *ServiceAccount) ErrIfDeleted() error {
}
func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*FactorAPIKey, error) {
if err := sa.ErrIfDeleted(); err != nil {
if err := sa.ErrIfDisabled(); err != nil {
return nil, err
}
if expiresAt != 0 && time.Now().After(time.Unix(int64(expiresAt), 0)) {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "cannot set api key expiry in the past")
}
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
@@ -216,30 +199,30 @@ func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*Facto
}, nil
}
func (sa *ServiceAccountWithRoles) PatchRoles(input *ServiceAccountWithRoles) ([]string, []string) {
func (sa *ServiceAccount) PatchRoles(input *ServiceAccount) ([]string, []string) {
currentRolesSet := make(map[string]struct{}, len(sa.Roles))
inputRolesSet := make(map[string]struct{}, len(input.Roles))
for _, role := range sa.Roles {
currentRolesSet[role.Name] = struct{}{}
currentRolesSet[role] = struct{}{}
}
for _, role := range input.Roles {
inputRolesSet[role.Name] = struct{}{}
inputRolesSet[role] = struct{}{}
}
// additions: roles present in input but not in current
additions := []string{}
for _, role := range input.Roles {
if _, exists := currentRolesSet[role.Name]; !exists {
additions = append(additions, role.Name)
if _, exists := currentRolesSet[role]; !exists {
additions = append(additions, role)
}
}
// deletions: roles present in current but not in input
deletions := []string{}
for _, role := range sa.Roles {
if _, exists := inputRolesSet[role.Name]; !exists {
deletions = append(deletions, role.Name)
if _, exists := inputRolesSet[role]; !exists {
deletions = append(deletions, role)
}
}
@@ -294,38 +277,10 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
return err
}
if temp.Status != ServiceAccountStatusDeleted {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, ServiceAccountStatusDeleted)
if temp.Status != StatusDisabled {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, StatusDisabled)
}
*sa = UpdatableServiceAccountStatus(temp)
return nil
}
func (sa *StorableServiceAccount) ToIdentity() *authtypes.Identity {
return &authtypes.Identity{
ServiceAccountID: sa.ID,
Principal: authtypes.PrincipalServiceAccount,
OrgID: sa.OrgID,
IdenNProvider: authtypes.IdentNProviderAPIKey,
Email: sa.Email,
}
}
func (sa *ServiceAccount) Traits() map[string]any {
return map[string]any{
"name": sa.Name,
"email": sa.Email.String(),
"created_at": sa.CreatedAt,
"status": sa.Status.StringValue(),
}
}
func (sa *ServiceAccountWithRoles) RoleNames() []string {
names := []string{}
for _, role := range sa.Roles {
names = append(names, role.Name)
}
return names
}

View File

@@ -19,10 +19,6 @@ type StorableServiceAccountRole struct {
RoleID string `bun:"role_id"`
}
type ServiceAccountRole struct {
Name string `json:"name" required:"true"`
}
func NewStorableServiceAccountRoles(serviceAccountID valuer.UUID, roles []*authtypes.Role) []*StorableServiceAccountRole {
storableServiceAccountRoles := make([]*StorableServiceAccountRole, len(roles))
for idx, role := range roles {
@@ -42,19 +38,19 @@ func NewStorableServiceAccountRoles(serviceAccountID valuer.UUID, roles []*autht
return storableServiceAccountRoles
}
func NewRolesFromStorableServiceAccountRoles(storable []*StorableServiceAccountRole, roles []*authtypes.Role) ([]*ServiceAccountRole, error) {
func NewRolesFromStorableServiceAccountRoles(storable []*StorableServiceAccountRole, roles []*authtypes.Role) ([]string, error) {
roleIDToName := make(map[string]string, len(roles))
for _, role := range roles {
roleIDToName[role.ID.String()] = role.Name
}
names := make([]*ServiceAccountRole, 0, len(storable))
names := make([]string, 0, len(storable))
for _, sar := range storable {
roleName, ok := roleIDToName[sar.RoleID]
if !ok {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "role id %s not found in provided roles", sar.RoleID)
}
names = append(names, &ServiceAccountRole{Name: roleName})
names = append(names, roleName)
}
return names, nil

View File

@@ -2,7 +2,6 @@ package serviceaccounttypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -13,7 +12,6 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableServiceAccount, error)
GetActiveByOrgIDAndName(context.Context, valuer.UUID, string) (*StorableServiceAccount, error)
GetByID(context.Context, valuer.UUID) (*StorableServiceAccount, error)
CountByOrgID(context.Context, valuer.UUID) (int64, error)
List(context.Context, valuer.UUID) ([]*StorableServiceAccount, error)
Update(context.Context, valuer.UUID, *StorableServiceAccount) error
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -27,12 +25,8 @@ type Store interface {
// Service Account Factor API Key
CreateFactorAPIKey(context.Context, *StorableFactorAPIKey) error
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*StorableFactorAPIKey, error)
GetFactorAPIKeyByName(context.Context, valuer.UUID, string) (*StorableFactorAPIKey, error)
GetFactorAPIKeyByKey(context.Context, string) (*StorableFactorAPIKey, error)
CountFactorAPIKeysByOrgID(context.Context, valuer.UUID) (int64, error)
ListFactorAPIKey(context.Context, valuer.UUID) ([]*StorableFactorAPIKey, error)
UpdateFactorAPIKey(context.Context, valuer.UUID, *StorableFactorAPIKey) error
UpdateLastObservedAt(context.Context, string, time.Time) error
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
RevokeAllFactorAPIKeys(context.Context, valuer.UUID) error

View File

@@ -281,6 +281,14 @@ type UserStore interface {
DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error
UpdatePassword(ctx context.Context, password *FactorPassword) error
// API KEY
CreateAPIKey(ctx context.Context, apiKey *StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id valuer.UUID, revokedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error)
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.subject}}</title>
</head>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#333;background:#fff">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#fff">
<tr>
<td align="center" style="padding:0">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;width:100%">
{{ if .format.Header.Enabled }}
<tr>
<td align="center" style="padding:16px 20px 16px">
<img src="{{.format.Header.LogoURL}}" alt="SigNoz" width="160" height="40" style="display:block;border:0;outline:none;max-width:100%;height:auto">
</td>
</tr>
{{ end }}
<tr>
<td style="padding:16px 20px 16px">
<p style="margin:0 0 16px;font-size:16px;color:#333">
Hi there,
</p>
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
An API key was {{.Event}} for your service account <strong>{{.Name}}</strong>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
<tr>
<td style="padding:20px;background:#f5f5f5;border-radius:6px;border-left:4px solid #4E74F8">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Key ID:</strong> {{.KeyID}}
</p>
</td>
</tr>
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Key Name:</strong> {{.KeyName}}
</p>
</td>
</tr>
<tr>
<td style="padding:0 0 8px">
<p style="margin:0;font-size:15px;color:#333;line-height:1.6">
<strong>Created At:</strong> {{.KeyCreatedAt}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ if .format.Help.Enabled }}
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
Need help? Chat with our team in the SigNoz application or email us at <a href="mailto:{{.format.Help.Email}}" style="color:#4E74F8;text-decoration:none">{{.format.Help.Email}}</a>.
</p>
{{ end }}
<p style="margin:0;font-size:16px;color:#333;line-height:1.6">
Thanks,<br><strong>The SigNoz Team</strong>
</p>
</td>
</tr>
{{ if .format.Footer.Enabled }}
<tr>
<td align="center" style="padding:8px 16px 8px">
<p style="margin:0 0 8px;font-size:12px;color:#999;line-height:1.5">
<a href="https://signoz.io/terms-of-service/" style="color:#4E74F8;text-decoration:none">Terms of Service</a> - <a href="https://signoz.io/privacy/" style="color:#4E74F8;text-decoration:none">Privacy Policy</a>
</p>
<p style="margin:0;font-size:12px;color:#999;line-height:1.5">
&#169; 2026 SigNoz Inc.
</p>
</td>
</tr>
{{ end }}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,55 +0,0 @@
"""Fixtures and helpers for service account tests."""
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
SA_BASE = "/api/v1/service_accounts"
def create_sa(
signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer"
) -> str:
"""Create a service account and return its ID."""
resp = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": name, "roles": [{"name": role}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
return resp.json()["data"]["id"]
def create_sa_with_key(
signoz: types.SigNoz, token: str, name: str, role: str = "signoz-admin"
) -> tuple:
"""Create a service account with an API key and return (sa_id, api_key)."""
sa_id = create_sa(signoz, token, name, role)
key_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "auth-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert key_resp.status_code == HTTPStatus.CREATED, key_resp.text
api_key = key_resp.json()["data"]["key"]
return sa_id, api_key
def find_sa_by_name(signoz: types.SigNoz, token: str, name: str) -> dict:
"""Find a service account by name from the list endpoint."""
list_resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert list_resp.status_code == HTTPStatus.OK, list_resp.text
return next(sa for sa in list_resp.json()["data"] if sa["name"] == name)

View File

@@ -159,28 +159,3 @@ def test_generate_connection_params(
assert (
data["signoz_api_url"] == "https://test-deployment.test.signoz.cloud"
), "signoz_api_url should be https://test-deployment.test.signoz.cloud"
# Verify the integration service account was created with viewer role, not admin.
# This guards against a privilege-escalation regression where the SA was
# previously created with admin access.
sa_list = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/service_accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert sa_list.status_code == HTTPStatus.OK
integration_sa = next(
(sa for sa in sa_list.json()["data"] if sa["name"] == "integration"),
None,
)
assert integration_sa is not None, "Integration service account should exist"
role_names = [role["name"] for role in integration_sa["roles"]]
assert (
"signoz-viewer" in role_names
), f"Integration SA should have VIEWER role, got {role_names}"
assert (
"signoz-admin" not in role_names
), f"Integration SA must NOT have ADMIN role, got {role_names}"

View File

@@ -0,0 +1,117 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "admin",
"role": "ADMIN",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()
found_user = next(
(
user
for user in user_response["data"]
if user["email"] == "admin@integration.test"
),
None,
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert "data" in response.json()
found_pat = next(
(pat for pat in response.json()["data"] if pat["userId"] == found_user["id"]),
None,
)
assert found_pat is not None
assert found_pat["userId"] == found_user["id"]
assert found_pat["name"] == "admin"
assert found_pat["role"] == "ADMIN"
def test_api_key_role(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "viewer",
"role": "VIEWER",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "editor",
"role": "EDITOR",
"expiresInDays": 1,
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
pat_response = response.json()
assert "data" in pat_response
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN

View File

@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Callable
from typing import Callable, Tuple
import requests
@@ -9,6 +9,7 @@ from fixtures import types
def test_change_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
get_tokens: Callable[[str, str], Tuple[str, str]],
):
admin_token = get_token("admin@integration.test", "password123Z$")
@@ -34,7 +35,9 @@ def test_change_role(
assert response.status_code == HTTPStatus.NO_CONTENT
# Make some API calls as new user
new_user_token = get_token("admin+rolechange@integration.test", "password123Z$")
new_user_token, new_user_refresh_token = get_tokens(
"admin+rolechange@integration.test", "password123Z$"
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
@@ -75,8 +78,27 @@ def test_change_role(
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
# Rotate token for new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={
"refreshToken": new_user_refresh_token,
},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
# Make some API call again which is protected
rotate_response = response.json()["data"]
new_user_token, new_user_refresh_token = (
rotate_response["accessToken"],
rotate_response["refreshToken"],
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,

View File

@@ -1,242 +0,0 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa, find_sa_by_name
logger = setup_logger(__name__)
def test_create_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "test-sa", "roles": [{"name": "signoz-admin"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
data = response.json()["data"]
assert "id" in data
assert len(data["id"]) > 0
def test_create_service_account_invalid_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# name with spaces should be rejected
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "invalid name", "roles": [{"name": "signoz-admin"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_create_service_account_empty_roles(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": "no-roles-sa", "roles": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_list_service_accounts(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert isinstance(data, list)
# should contain the SA we created in the earlier test
names = [sa["name"] for sa in data]
assert "test-sa" in names
def test_get_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "test-sa")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["id"] == sa["id"]
assert data["name"] == "test-sa"
assert data["status"] == "active"
assert "email" in data
assert "roles" in data
def test_get_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"{SA_BASE}/00000000-0000-0000-0000-000000000000"
),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
def test_update_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "test-sa")
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
json={"name": "test-sa-updated", "roles": [{"name": "signoz-viewer"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify the update
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.json()["data"]["name"] == "test-sa-updated"
def test_delete_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-to-disable")
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/status"),
json={"status": "deleted"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify status changed
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.json()["data"]["status"] == "deleted"
def test_create_after_delete_reuses_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The partial unique index on (name, org_id) excludes deleted rows,
so create → delete → create with the same name must succeed."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_name = "sa-reuse-name"
# 1. create
first_id = create_sa(signoz, token, sa_name)
# 2. creating again with the same name should fail (conflict)
dup_resp = requests.post(
signoz.self.host_configs["8080"].get(SA_BASE),
json={"name": sa_name, "roles": [{"name": "signoz-viewer"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert dup_resp.status_code == HTTPStatus.CONFLICT, dup_resp.text
# 3. disable the first one
disable_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{first_id}/status"),
json={"status": "deleted"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert disable_resp.status_code == HTTPStatus.NO_CONTENT
# 4. now creating with the same name should succeed
second_id = create_sa(signoz, token, sa_name)
assert second_id != first_id, "New SA should have a different ID"
def test_force_delete_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-to-delete")
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# verify it's gone
get_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert get_resp.status_code == HTTPStatus.NOT_FOUND

View File

@@ -1,268 +0,0 @@
import time
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa, find_sa_by_name
logger = setup_logger(__name__)
def test_create_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-for-keys")
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "my-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
data = response.json()["data"]
assert "id" in data
assert "key" in data
assert len(data["key"]) > 0
def test_create_api_key_duplicate_name(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
# creating a key with the same name should fail
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
json={"name": "my-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
def test_list_api_keys(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert isinstance(data, list)
assert len(data) >= 1
key_entry = data[0]
assert "id" in key_entry
assert "name" in key_entry
assert key_entry["name"] == "my-key"
def test_update_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa = find_sa_by_name(signoz, token, "sa-for-keys")
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_id = keys_resp.json()["data"][0]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa['id']}/keys/{key_id}"),
json={"name": "renamed-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_revoke_api_key(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-revoke-key")
create_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "key-to-revoke", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_resp.status_code == HTTPStatus.CREATED
key_id = create_resp.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_create_api_key_with_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Key created with a future expiresAt should be usable."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-expiry")
future_ts = int(time.time()) + 3600 # 1 hour from now
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "future-key", "expiresAt": future_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
api_key = response.json()["data"]["key"]
# key should work since it hasn't expired
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert dash_resp.status_code == HTTPStatus.OK, dash_resp.text
# verify expiresAt is stored correctly
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_entry = next(k for k in keys_resp.json()["data"] if k["name"] == "future-key")
assert key_entry["expiresAt"] == future_ts
def test_create_api_key_with_past_expiry_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Creating a key with an already-past expiresAt should be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-past-expiry")
past_ts = int(time.time()) - 60 # 1 minute ago
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "expired-key", "expiresAt": past_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for past expiresAt, got {response.status_code}: {response.text}"
def test_create_api_key_no_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Key with expiresAt=0 should never expire."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-no-expiry")
response = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "forever-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
api_key = response.json()["data"]["key"]
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert dash_resp.status_code == HTTPStatus.OK, dash_resp.text
# verify expiresAt is 0
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_entry = next(k for k in keys_resp.json()["data"] if k["name"] == "forever-key")
assert key_entry["expiresAt"] == 0
def test_update_api_key_expiry(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Updating expiresAt to a past value should be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id = create_sa(signoz, token, "sa-key-update-expiry")
# create with no expiry
create_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
json={"name": "update-expiry-key", "expiresAt": 0},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_resp.status_code == HTTPStatus.CREATED
key_id = create_resp.json()["data"]["id"]
api_key = create_resp.json()["data"]["key"]
# updating to expire in the past should be rejected
past_ts = int(time.time()) - 60
update_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
json={"name": "update-expiry-key", "expiresAt": past_ts},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert (
update_resp.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for past expiresAt update, got {update_resp.status_code}: {update_resp.text}"
# key should still work since the update was rejected
dash_resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
dash_resp.status_code == HTTPStatus.OK
), f"Key should still work after rejected update, got {dash_resp.status_code}: {dash_resp.text}"

View File

@@ -1,319 +0,0 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.serviceaccount import SA_BASE, create_sa_with_key
logger = setup_logger(__name__)
def test_sa_key_auth_on_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account API key with admin role can access dashboards."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-dashboard-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
def test_sa_key_forbidden_on_user_me(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account key must not access /api/v1/user/me — it's user-only."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-user-me-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
## This shouldn't be allowed on api key identn, will be updated once we fix that.
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404 for SA on /user/me, got {response.status_code}: {response.text}"
def test_sa_key_forbidden_on_user_preferences(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Service account key must not access user preference endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-pref-test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/preferences"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
## This shouldn't be allowed on api key identn, will be updated once we fix that.
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for SA on /user/preferences, got {response.status_code}: {response.text}"
def test_sa_role_access_admin(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Admin SA can access admin, edit, and view endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(signoz, token, "sa-role-admin", role="signoz-admin")
# AdminAccess: list service accounts
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Admin SA should access admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: create a dashboard
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "admin-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.CREATED
), f"Admin SA should access edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Admin SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_role_access_editor(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Editor SA can access edit and view endpoints but not admin."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(
signoz, token, "sa-role-editor", role="signoz-editor"
)
# AdminAccess: should be forbidden
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Editor SA should be forbidden from admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: create a dashboard
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "editor-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.CREATED
), f"Editor SA should access edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Editor SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_role_access_viewer(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Viewer SA can access view endpoints but not edit or admin."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_sa_with_key(
signoz, token, "sa-role-viewer", role="signoz-viewer"
)
# AdminAccess: should be forbidden
resp = requests.get(
signoz.self.host_configs["8080"].get(SA_BASE),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Viewer SA should be forbidden from admin endpoint, got {resp.status_code}: {resp.text}"
# EditAccess: should be forbidden
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json={"title": "viewer-sa-dash", "uploadedGrafana": False, "version": "v4"},
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.FORBIDDEN
), f"Viewer SA should be forbidden from edit endpoint, got {resp.status_code}: {resp.text}"
# ViewAccess: list dashboards
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
resp.status_code == HTTPStatus.OK
), f"Viewer SA should access view endpoint, got {resp.status_code}: {resp.text}"
def test_sa_key_deleted_account_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A deleted service account's key must be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id, api_key = create_sa_with_key(signoz, token, "sa-disable-auth")
# verify the key works before deleting
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
# delete the SA
disable_resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/status"),
json={"status": "deleted"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert disable_resp.status_code == HTTPStatus.NO_CONTENT
# now the key should be rejected
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code in (
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
), f"Expected 401/403 for disabled SA, got {response.status_code}: {response.text}"
def test_sa_key_revoked_key_rejected(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A revoked API key must be rejected."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
sa_id, api_key = create_sa_with_key(signoz, token, "sa-revoke-auth")
# verify the key works first
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
# find the key id
keys_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
key_id = keys_resp.json()["data"][0]["id"]
# revoke it
revoke_resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SA_BASE}/{sa_id}/keys/{key_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert revoke_resp.status_code == HTTPStatus.NO_CONTENT
# now the key should be rejected
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert (
response.status_code == HTTPStatus.UNAUTHORIZED
), f"Expected 401 for revoked key, got {response.status_code}: {response.text}"
def test_user_token_still_works_on_user_me(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Verify that normal user JWT tokens still work on user-only endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["email"] == USER_ADMIN_EMAIL
def test_user_token_still_works_on_user_preferences(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Verify that normal user JWT tokens still work on preference endpoints."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/preferences"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"] is not None