Compare commits

...

12 Commits

Author SHA1 Message Date
Vikrant Gupta
f16f37b4eb Merge branch 'main' into platform-pod/issues/1830-sa 2026-03-24 02:23:06 +05:30
vikrantgupta25
044ed1470d feat(serviceaccount): migration fixes 2026-03-24 02:22:43 +05:30
vikrantgupta25
ea83b622ce feat(serviceaccount): fix some issues 2026-03-24 02:06:52 +05:30
vikrantgupta25
5494d97bb1 feat(serviceaccount): fix migration numbers 2026-03-23 23:50:40 +05:30
vikrantgupta25
4500080af3 feat(serviceaccount): rebase issues 2026-03-23 23:02:40 +05:30
vikrantgupta25
675e23ee9f feat(serviceaccount): handle migration duplicates 2026-03-23 22:53:06 +05:30
vikrantgupta25
12a5984164 feat(serviceaccount): handle author access for user/dashboard 2026-03-23 22:53:02 +05:30
vikrantgupta25
6ab2dbd2c1 feat(serviceaccount): claims.UserID changes 2026-03-23 22:40:30 +05:30
vikrantgupta25
9e458f4954 feat(serviceaccount): handle self access better 2026-03-23 22:38:09 +05:30
vikrantgupta25
85c396880e feat(serviceaccount): deprecate the api keys 2026-03-23 22:38:04 +05:30
vikrantgupta25
5da4e382a0 feat(serviceaccount): add analytics and cloudintegration migration 2026-03-23 22:35:28 +05:30
vikrantgupta25
5402ceef80 feat(serviceaccount): sqlmigration and identn provider 2026-03-23 22:34:40 +05:30
52 changed files with 1286 additions and 1406 deletions

View File

@@ -1860,8 +1860,6 @@ components:
type: object
ServiceaccounttypesPostableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
@@ -1870,7 +1868,6 @@ components:
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesServiceAccount:
@@ -1878,9 +1875,6 @@ components:
createdAt:
format: date-time
type: string
deletedAt:
format: date-time
type: string
email:
type: string
id:
@@ -1905,7 +1899,6 @@ components:
- roles
- status
- orgId
- deletedAt
type: object
ServiceaccounttypesUpdatableFactorAPIKey:
properties:
@@ -1920,8 +1913,6 @@ components:
type: object
ServiceaccounttypesUpdatableServiceAccount:
properties:
email:
type: string
name:
type: string
roles:
@@ -1930,7 +1921,6 @@ components:
type: array
required:
- name
- email
- roles
type: object
ServiceaccounttypesUpdatableServiceAccountStatus:
@@ -2063,43 +2053,6 @@ 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:
@@ -2154,16 +2107,6 @@ components:
required:
- id
type: object
TypesPostableAPIKey:
properties:
expiresInDays:
format: int64
type: integer
name:
type: string
role:
type: string
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
@@ -2217,56 +2160,6 @@ 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:
@@ -3706,222 +3599,6 @@ 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

View File

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

View File

@@ -213,8 +213,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {

View File

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

View File

@@ -2209,10 +2209,6 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesPostableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -2229,11 +2225,6 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @format date-time
*/
createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/**
* @type string
*/
@@ -2278,10 +2269,6 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/

View File

@@ -43,74 +43,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
ID: "CreateAPIKey",
Tags: []string{"users"},
Summary: "Create api key",
Description: "This endpoint creates an api key",
Request: new(types.PostableAPIKey),
RequestContentType: "application/json",
Response: new(types.GettableAPIKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
ID: "ListAPIKeys",
Tags: []string{"users"},
Summary: "List api keys",
Description: "This endpoint lists all api keys",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
ID: "UpdateAPIKey",
Tags: []string{"users"},
Summary: "Update api key",
Description: "This endpoint updates an api key",
Request: new(types.StorableAPIKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
ID: "RevokeAPIKey",
Tags: []string{"users"},
Summary: "Revoke api key",
Description: "This endpoint revokes an api key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.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, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,11 +30,5 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(user.ID, orgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, authtypes.IdentNProviderTokenizer), nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, role types.Role, lock bool) error
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error

View File

@@ -7,6 +7,7 @@ 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"
@@ -22,11 +23,12 @@ import (
type handler struct {
module dashboard.Module
authz authz.AuthZ
providerSettings factory.ProviderSettings
}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, authz authz.AuthZ) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings, authz: authz}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
@@ -57,7 +59,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.UserID), req)
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
@@ -155,7 +157,24 @@ func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
return
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, claims.Role, *req.Locked)
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)
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, role types.Role, lock bool) error {
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
err = dashboard.LockUnlock(lock, role, updatedBy)
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
if err != nil {
return err
}

View File

@@ -0,0 +1,29 @@
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 {
Prefix string `mapstructure:"prefix"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("serviceaccount"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Prefix: "signoz",
}
}
func (c Config) Validate() error {
if c.Prefix == "" {
return errors.New(errors.TypeInvalidInput, serviceaccounttypes.ErrCodeServiceAccountInvalidConfig, "prefix cannot be empty")
}
return nil
}

View File

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

View File

@@ -2,28 +2,36 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/cache"
"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/emailtypes"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"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
emailing emailing.Emailing
settings factory.ScopedProviderSettings
store serviceaccounttypes.Store
authz authz.AuthZ
cache cache.Cache
analytics analytics.Analytics
settings factory.ScopedProviderSettings
config serviceaccount.Config
}
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, cache cache.Cache, analytics analytics.Analytics, providerSettings factory.ProviderSettings, config serviceaccount.Config) serviceaccount.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
return &module{store: store, authz: authz, cache: cache, analytics: analytics, settings: settings, config: config}
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
@@ -58,6 +66,8 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return nil
}
@@ -68,7 +78,7 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
}
if existingServiceAccount != nil {
return serviceAccount, nil
return serviceaccounttypes.NewServiceAccountFromStorables(existingServiceAccount, serviceAccount.Roles), nil
}
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
@@ -76,6 +86,8 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
return nil, err
}
module.analytics.IdentifyUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return serviceAccount, nil
}
@@ -186,6 +198,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Updated", input.Traits())
return nil
}
@@ -214,6 +228,11 @@ 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.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -251,6 +270,11 @@ 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.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), id.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -263,22 +287,36 @@ func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serv
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
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))
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
}
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
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
}
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 {
@@ -297,12 +335,15 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
}
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
func (module *module) UpdateFactorAPIKey(ctx context.Context, orgID 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.analytics.TrackUser(ctx, orgID.String(), serviceAccountID.String(), "API Key updated", factorAPIKey.Traits())
return nil
}
@@ -322,14 +363,108 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
return err
}
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
// delete the cache when revoking the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccountID.String(), "API Key revoked", factorAPIKey.Traits())
return nil
}
func (module *module) Config() serviceaccount.Config {
return module.config
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.count"] = count
}
count, err = module.store.CountFactorAPIKeysByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.keys.count"] = count
}
return stats, nil
}
func (module *module) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
apiKey, err := module.getOrGetSetAPIKey(ctx, key)
if err != nil {
return nil, err
}
if err := apiKey.IsExpired(); err != nil {
return nil, err
}
identity, err := module.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
if err != nil {
return nil, err
}
return identity, nil
}
func (module *module) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
return module.store.UpdateLastObservedAt(ctx, key, lastObservedAt)
}
func (module *module) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
factorAPIkey := new(serviceaccounttypes.FactorAPIKey)
err := module.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return factorAPIkey, nil
}
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 apiKeyCacheKey(apiKey string) string {
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
}
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -2,6 +2,7 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
@@ -84,6 +85,23 @@ 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)
@@ -231,6 +249,60 @@ 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)
@@ -254,6 +326,7 @@ 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 {
@@ -263,6 +336,22 @@ 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,7 +3,9 @@ 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"
)
@@ -39,14 +41,24 @@ 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,18 +160,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
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{})
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewPrincipalUserIdentity(newUser.ID, newUser.OrgID, newUser.Email, 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), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), 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, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
func (m *module) GetSpanPercentile(ctx context.Context, orgID 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, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,7 +12,6 @@ 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"
)
@@ -43,7 +41,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.UserID), &types.PostableBulkInviteRequest{
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &types.PostableBulkInviteRequest{
Invites: []types.PostableInvite{req},
})
if err != nil {
@@ -76,7 +74,7 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
return
}
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &req)
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
if err != nil {
render.Error(rw, err)
return
@@ -162,7 +160,7 @@ func (h *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user, claims.UserID)
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
if err != nil {
render.Error(w, err)
return
@@ -183,7 +181,7 @@ func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.IdentityID()); err != nil {
render.Error(w, err)
return
}
@@ -274,172 +272,3 @@ 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,12 +56,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
}
// CreateBulk implements invite.Module.
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
}
func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
@@ -128,7 +123,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, u
// send password reset emails to all the invited users
for idx, userWithToken := range newUsersWithResetToken {
module.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
module.analytics.TrackUser(ctx, orgID.String(), identityID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.Role,
})
@@ -162,7 +157,7 @@ func (module *setter) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, u
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": creator.Email,
"inviter_email": identityEmail.StringValue(),
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
@@ -220,7 +215,12 @@ 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, updatedBy string) (*types.DeprecatedUser, error) {
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
}
existingUser, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return nil, err
@@ -234,19 +234,29 @@ 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 && requestor.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
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")
}
}
// make sure the user is not demoting self from admin
if roleChange && existingUser.ID == requestor.ID && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
if roleChange && existingUser.ID == valuer.MustNewUUID(claims.IdentityID()) && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot change self role")
}
@@ -628,26 +638,6 @@ 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 {
@@ -703,11 +693,6 @@ 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,7 +3,6 @@ package impluser
import (
"context"
"database/sql"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -182,15 +181,6 @@ 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)).
@@ -266,15 +256,6 @@ 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)).
@@ -421,111 +402,6 @@ 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)
@@ -573,24 +449,6 @@ 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,21 +34,14 @@ 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, updatedBy string) (*types.DeprecatedUser, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*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, 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)
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
// Roles
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
@@ -104,10 +97,4 @@ 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

@@ -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.UserID, "Telemetry Query Returned Empty", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
return
}
handler.analytics.TrackUser(ctx, claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
handler.analytics.TrackUser(ctx, claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
}

View File

@@ -1567,7 +1567,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.UserID, request.EventName, request.Attributes)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), request.EventName, request.Attributes)
}
aH.WriteJSON(w, r, map[string]string{"data": "Event Processed Successfully"})
} else {
@@ -4670,7 +4670,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.UserID, "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
return
}
@@ -4680,18 +4680,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.UserID, "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
return
}
if len(result[0].Table.Rows) == 0 {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Empty", properties)
return
}
}
}
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.IdentityID(), "Telemetry Query Returned Results", properties)
}

View File

@@ -22,6 +22,7 @@ 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"
@@ -119,6 +120,9 @@ 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) {
@@ -148,6 +152,7 @@ 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

@@ -75,7 +75,7 @@ func NewHandlers(
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, authz),
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, emailing, providerSettings),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
}
}

View File

@@ -31,6 +31,7 @@ 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"
@@ -190,6 +191,9 @@ 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),
)
}
@@ -290,11 +294,11 @@ func NewTokenizerProviderFactories(cache cache.Cache, sqlstore sqlstore.SQLStore
)
}
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]] {
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]] {
return factory.MustNewNamedMap(
impersonationidentn.NewFactory(orgGetter, userGetter, userConfig),
tokenizeridentn.NewFactory(tokenizer),
apikeyidentn.NewFactory(sqlstore),
apikeyidentn.NewFactory(serviceAccount),
)
}

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(sqlstore, tokenizer, orgGetter, userGetter, config.User)
identNFactories := NewIdentNProviderFactories(tokenizer, modules.ServiceAccount, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err

View File

@@ -0,0 +1,133 @@
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 != 'disabled'",
})
sqls = append(sqls, indexSQLs...)
indexSQLs = migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "service_account",
ColumnNames: []sqlschema.ColumnName{"email", "org_id"},
Where: "status != 'disabled'",
})
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

@@ -0,0 +1,359 @@
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@signoz.serviceaccount.io", 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

@@ -0,0 +1,148 @@
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,7 +2,6 @@ package jwttokenizer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/golang-jwt/jwt/v5"
)
@@ -10,21 +9,15 @@ var _ jwt.ClaimsValidator = (*Claims)(nil)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"id"`
Email string `json:"email"`
Role types.Role `json:"role"`
OrgID string `json:"orgId"`
UserID string `json:"id"`
Email string `json:"email"`
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,7 +78,6 @@ 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{
@@ -94,7 +93,6 @@ 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{
@@ -117,17 +115,7 @@ func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (
return nil, err
}
// 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
return authtypes.NewPrincipalUserIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), authtypes.IdentNProviderTokenizer), nil
}
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {

View File

@@ -14,7 +14,6 @@ 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"
@@ -62,7 +61,6 @@ 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{},
@@ -74,7 +72,6 @@ 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,7 +3,6 @@ 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"
@@ -47,25 +46,7 @@ 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)
}
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
return authtypes.NewPrincipalUserIdentity(userID, user.OrgID, user.Email, authtypes.IdentNProviderTokenizer), nil
}
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {

View File

@@ -22,14 +22,22 @@ 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"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
Role types.Role `json:"role"`
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"`
}
type CallbackIdentity struct {
@@ -79,16 +87,37 @@ func NewStateFromString(state string) (State, error) {
}, nil
}
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role, identNProvider IdentNProvider) *Identity {
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 {
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,
@@ -118,11 +147,12 @@ func (typ *Identity) UnmarshalBinary(data []byte) error {
func (typ *Identity) ToClaims() Claims {
return Claims{
UserID: typ.UserID.String(),
Email: typ.Email.String(),
Role: typ.Role,
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider.StringValue(),
UserID: typ.UserID.String(),
ServiceAccountID: typ.ServiceAccountID.String(),
Principal: typ.Principal.StringValue(),
Email: typ.Email.String(),
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
Email string
Role types.Role
OrgID string
IdentNProvider string
UserID string
ServiceAccountID string
Principal string
Email string
OrgID string
IdentNProvider string
}
// NewContextWithClaims attaches individual claims to the context.
@@ -48,48 +48,42 @@ 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),
slog.String("email", c.Email),
slog.String("role", c.Role.String()),
slog.String("org_id", c.OrgID),
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
}
if c.Role == types.RoleAdmin {
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.StringValue() {
return c.UserID
}
return c.ServiceAccountID
}

View File

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

View File

@@ -1,144 +0,0 @@
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,9 +11,9 @@ import (
)
var (
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")
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")
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
)
@@ -124,10 +124,15 @@ func NewGettableFactorAPIKeyWithKey(id valuer.UUID, key string) *GettableFactorA
}
}
func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) {
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")
}
apiKey.Name = name
apiKey.ExpiresAt = expiresAt
apiKey.UpdatedAt = time.Now()
return nil
}
func (apiKey *FactorAPIKey) IsExpired() error {
@@ -184,3 +189,18 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
*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,6 +4,7 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"time"
@@ -15,6 +16,7 @@ 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")
@@ -28,6 +30,10 @@ var (
ValidStatus = []valuer.String{StatusActive, StatusDisabled}
)
var (
serviceAccountEmailDomain = valuer.NewString("signozserviceaccount.io")
)
var (
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
)
@@ -37,41 +43,37 @@ type StorableServiceAccount struct {
types.Identifiable
types.TimeAuditable
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
DeletedAt time.Time `bun:"deleted_at"`
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
}
type ServiceAccount struct {
types.Identifiable
types.TimeAuditable
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"`
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"`
}
type PostableServiceAccount struct {
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccount struct {
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Name string `json:"name" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
}
type UpdatableServiceAccountStatus struct {
Status valuer.String `json:"status" required:"true"`
}
func NewServiceAccount(name string, email valuer.Email, roles []string, status valuer.String, orgID valuer.UUID) *ServiceAccount {
func NewServiceAccount(name string, prefix string, roles []string, status valuer.String, orgID valuer.UUID) *ServiceAccount {
return &ServiceAccount{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -80,12 +82,11 @@ func NewServiceAccount(name string, email valuer.Email, roles []string, status v
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Email: email,
Roles: roles,
Status: status,
OrgID: orgID,
DeletedAt: time.Time{},
Name: name,
Email: valuer.MustNewEmail(fmt.Sprintf("%s@%s.%s", name, prefix, serviceAccountEmailDomain)),
Roles: roles,
Status: status,
OrgID: orgID,
}
}
@@ -98,7 +99,6 @@ func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccou
Roles: roles,
Status: storableServiceAccount.Status,
OrgID: valuer.MustNewUUID(storableServiceAccount.OrgID),
DeletedAt: storableServiceAccount.DeletedAt,
}
}
@@ -135,17 +135,15 @@ func NewStorableServiceAccount(serviceAccount *ServiceAccount) *StorableServiceA
Email: serviceAccount.Email.String(),
Status: serviceAccount.Status,
OrgID: serviceAccount.OrgID.String(),
DeletedAt: serviceAccount.DeletedAt,
}
}
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) error {
func (sa *ServiceAccount) Update(name string, 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
@@ -158,7 +156,6 @@ func (sa *ServiceAccount) UpdateStatus(status valuer.String) error {
sa.Status = status
sa.UpdatedAt = time.Now()
sa.DeletedAt = time.Now()
return nil
}
@@ -175,6 +172,10 @@ func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*Facto
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 {
@@ -284,3 +285,22 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
*sa = UpdatableServiceAccountStatus(temp)
return nil
}
func (sa *StorableServiceAccount) ToIdentity() *authtypes.Identity {
return &authtypes.Identity{
ServiceAccountID: sa.ID,
Principal: authtypes.PrincipalServiceAccount,
OrgID: valuer.MustNewUUID(sa.OrgID),
IdenNProvider: authtypes.IdentNProviderAPIKey,
Email: valuer.MustNewEmail(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(),
}
}

View File

@@ -2,6 +2,7 @@ package serviceaccounttypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -12,6 +13,7 @@ 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
@@ -25,8 +27,12 @@ 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,14 +281,6 @@ 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

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