Compare commits

...

17 Commits

Author SHA1 Message Date
vikrantgupta25
9af0368199 test(passwordauthn): restructure the suite by concern 2026-09-30 22:00:22 +05:30
vikrantgupta25
2a3aef7b48 Merge remote-tracking branch 'origin/main' into keystone-pod/issues/38
# Conflicts:
#	pkg/http/handler/resourcedef.go
2026-09-30 19:47:03 +05:30
vikrantgupta25
d08cd99107 refactor(user): drop self-mutation guards from admin routes 2026-09-30 18:43:18 +05:30
vikrantgupta25
507ea382cf refactor(authz): read user route body ids with the gjson extractors 2026-09-30 18:43:16 +05:30
vikrantgupta25
58acd6d337 Merge remote-tracking branch 'origin/revert/authz-body-decoding' into keystone-pod/issues/38 2026-09-30 18:41:22 +05:30
vikrantgupta25
e4afc8832b revert(authz): decode the request body once in the resource middleware
This reverts commit 8d80f98710 (#13014).
Authz should not own request body decoding.
2026-09-30 18:31:04 +05:30
vikrantgupta25
585a3b6140 revert(authz): read every body-derived resource id from the decoded request
This reverts commit 8dba9a13ea (#13015).
Authz should not own request body decoding.
2026-09-30 18:31:03 +05:30
vikrantgupta25
6a1fac8118 chore(openapi): regenerate spec for reset password token read 2026-09-30 18:21:05 +05:30
vikrantgupta25
b8869e7005 fix(authz): check only factor-password list when reading a reset password token 2026-09-30 18:20:29 +05:30
vikrantgupta25
2a5647f6b8 Merge remote-tracking branch 'origin/main' into keystone-pod/issues/38
# Conflicts:
#	pkg/signoz/provider.go
2026-09-30 18:20:01 +05:30
vikrantgupta25
ed45d5054d chore(openapi): regenerate spec for users by role scope 2026-09-30 18:17:41 +05:30
vikrantgupta25
057b61fe68 fix(authz): check only role read when listing users by role 2026-09-30 18:17:39 +05:30
vikrantgupta25
643de2055d chore(openapi): regenerate spec for reset password token scope 2026-09-30 17:23:54 +05:30
vikrantgupta25
86d9dae524 fix(authz): check factor-password list when reading a reset password token 2026-09-30 17:23:52 +05:30
vikrantgupta25
7f3ec73dfd fix(authz): guard nil request in user role extractors 2026-09-30 17:06:58 +05:30
vikrantgupta25
a39eec0f5c chore(openapi): regenerate spec for user routes 2026-09-30 16:43:17 +05:30
vikrantgupta25
fae6fc41e3 feat(authz): enable FGA for users and reset password tokens 2026-09-30 16:43:15 +05:30
25 changed files with 1476 additions and 934 deletions

View File

@@ -21186,9 +21186,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
@@ -25774,9 +25774,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -25827,9 +25829,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -25891,9 +25895,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -25940,9 +25944,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -26003,9 +26007,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -26050,9 +26058,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -26108,9 +26116,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -26165,9 +26173,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -26224,9 +26232,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:list
- tokenizer:
- ADMIN
- factor-password:list
summary: Get reset password token for a user
tags:
- users
@@ -26290,9 +26298,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- user:attach
- tokenizer:
- ADMIN
- factor-password:create
- user:attach
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -26351,9 +26361,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users
@@ -26667,10 +26677,7 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- tokenizer: []
summary: Updates my password
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -85,6 +85,7 @@ type provider struct {
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
userGetter user.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -129,6 +130,7 @@ func NewFactory(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -181,6 +183,7 @@ func NewFactory(
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
userGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -235,6 +238,7 @@ func newProvider(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -289,6 +293,7 @@ func newProvider(
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
userGetter: userGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -6,24 +6,35 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addUserRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -61,20 +72,46 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceUser.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: provider.roleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -95,88 +132,152 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
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 {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceUser,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -209,7 +310,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -248,90 +349,190 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userRoleUserIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.UserID.String(), nil
})
}
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.RoleID.String(), nil
})
}
func (provider *provider) userRoleFromRequest(req *http.Request) (*authtypes.UserRole, error) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
return nil, err
}
userRoleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
return provider.userGetter.GetUserRoleByOrgIDAndID(req.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
}

View File

@@ -56,20 +56,23 @@ type AttachDetachSiblingResourceDef struct {
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
resolved := coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
)
if resolved.HasNoLinks() {
return nil
}
return []coretypes.ResolvedResource{resolved}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child

View File

@@ -0,0 +1,71 @@
package handler
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAttachDetachSiblingResourceDefResolvesNothingWithoutLinks(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: coretypes.IDSelector,
}
testCases := []struct {
name string
body string
expectedResolved int
expectedTargetIDs []string
}{
{name: "NoRolesKey_ResolvesNothing", body: `{"email":"jane@example.com"}`, expectedResolved: 0},
{name: "EmptyRoles_ResolvesNothing", body: `{"userRoles":[]}`, expectedResolved: 0},
{name: "OneRole_ResolvesOne", body: `{"userRoles":[{"id":"signoz-viewer"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer"}},
{name: "TwoRoles_ResolvesOneWithBothTargets", body: `{"userRoles":[{"id":"signoz-viewer"},{"id":"signoz-editor"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer", "signoz-editor"}},
{name: "EmptyRoleID_KeepsFailingClosed", body: `{"userRoles":[{"id":""}]}`, expectedResolved: 1, expectedTargetIDs: []string{""}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.Len(t, resolved, testCase.expectedResolved)
if testCase.expectedResolved == 0 {
return
}
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.NoError(t, withTarget.Err())
assert.Equal(t, []string{""}, withTarget.SourceIDs())
assert.Equal(t, testCase.expectedTargetIDs, withTarget.TargetIDs())
})
}
}
func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: coretypes.IDSelector,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(`{"userId":"u1"}`)})
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}

View File

@@ -184,11 +184,6 @@ func (handler *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
if userID == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
updatableUser := new(types.UpdatableUser)
if err := json.NewDecoder(r.Body).Decode(&updatableUser); err != nil {
render.Error(w, err)
@@ -431,11 +426,6 @@ func (handler *handler) CreateUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if req.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
userRole, err := handler.setter.AddUserRoleByRoleID(ctx, valuer.MustNewUUID(claims.OrgID), req.UserID, req.RoleID)
if err != nil {
render.Error(w, err)
@@ -492,11 +482,6 @@ func (handler *handler) DeleteUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if userRole.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), userRole.UserID, userRole.RoleID); err != nil {
render.Error(w, err)
return

View File

@@ -94,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},
struct{ user.Getter }{},
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},

View File

@@ -258,6 +258,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
sqlmigration.NewAddChannelSpecFactory(sqlschema),
sqlmigration.NewAddUserTuplesFactory(sqlstore),
)
}
@@ -353,6 +354,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,
modules.UserGetter,
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,

View File

@@ -0,0 +1,141 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addUserTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserTuples) 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
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// user and factor-password moved from the legacy AdminAccess gate to
// CheckResources. Existing organizations never had these tuples written;
// only new organizations receive them from the managed-role registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
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, tuple.objectType, objectID, tuple.relation, user, "userset", 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, tuple.objectType, objectID, tuple.relation, user, 0, 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, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", 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, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -52,7 +52,7 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
id, err := extractor.Fn(ec)
if err != nil || id == "" {
if err != nil {
return nil, err
}
return []string{id}, nil

View File

@@ -31,6 +31,8 @@ type ResolvedResourceWithTargetResource interface {
// IsParentChild true: the target is a child audited along but not authz-checked
// (only the source is); false: a sibling peer that is also authz-checked.
IsParentChild() bool
// HasNoLinks true: a request-phase ids extractor ran and found nothing to link.
HasNoLinks() bool
}
func NewContextWithResolvedResources(ctx context.Context, resolved []ResolvedResource) context.Context {

View File

@@ -12,6 +12,7 @@ type resolvedResourceWithTarget struct {
targetExtractor ResourceIDsExtractor
targetIDs []string
parentChild bool
noLinks bool
err error
}
@@ -44,29 +45,40 @@ func NewResolvedResourceWithTarget(
}
func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec ExtractorContext) {
if resolved.sourceExtractor.IsPhase(phase) {
ids, err := resolved.sourceExtractor.Fn(ec)
if err != nil && phase == PhaseRequest {
resolved.err = err
return
}
if len(ids) > 0 {
resolved.sourceIDs = ids
}
if err := resolved.fillSide(phase, ec, resolved.sourceExtractor, &resolved.sourceIDs); err != nil {
resolved.err = err
return
}
if resolved.targetExtractor.IsPhase(phase) {
ids, err := resolved.targetExtractor.Fn(ec)
if err != nil && phase == PhaseRequest {
resolved.err = err
return
if err := resolved.fillSide(phase, ec, resolved.targetExtractor, &resolved.targetIDs); err != nil {
resolved.err = err
}
}
func (resolved *resolvedResourceWithTarget) fillSide(phase ExtractPhase, ec ExtractorContext, extractor ResourceIDsExtractor, ids *[]string) error {
if !extractor.IsPhase(phase) {
return nil
}
extracted, err := extractor.Fn(ec)
if err != nil {
if phase == PhaseRequest {
return err
}
if len(ids) > 0 {
resolved.targetIDs = ids
}
return nil
}
if len(extracted) == 0 {
if phase == PhaseRequest {
resolved.noLinks = true
}
return nil
}
*ids = extracted
return nil
}
func (resolved *resolvedResourceWithTarget) Err() error {
@@ -117,6 +129,10 @@ func (resolved *resolvedResourceWithTarget) IsParentChild() bool {
return resolved.parentChild
}
func (resolved *resolvedResourceWithTarget) HasNoLinks() bool {
return resolved.noLinks
}
func (resolved *resolvedResourceWithTarget) ResolveResponse(ec ExtractorContext) {
resolved.fill(PhaseResponse, ec)
}

View File

@@ -7,251 +7,44 @@ from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_EDITOR_EMAIL,
USER_EDITOR_NAME,
USER_EDITOR_PASSWORD,
USER_VIEWER_EMAIL,
assert_user_has_role,
find_user_with_roles_by_email,
)
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
def test_register_with_invalid_input(signoz: types.SigNoz) -> None:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "admin",
"orgId": "",
"orgName": "integration.test",
"email": "admin@integration.test",
"password": "password", # invalid password
},
timeout=2,
json={"name": "admin", "orgId": "", "orgName": "integration.test", "email": USER_ADMIN_EMAIL, "password": "password"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "admin",
"orgId": "",
"orgName": "integration.test",
"email": "admin", # invalid email
"password": "password123Z$",
},
timeout=2,
json={"name": "admin", "orgId": "", "orgName": "integration.test", "email": "admin", "password": USER_ADMIN_PASSWORD},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/version"), timeout=2)
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/version"), timeout=5)
assert response.status_code == HTTPStatus.OK
assert response.json()["setupCompleted"] is False
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "admin",
"orgId": "",
"orgName": "integration.test",
"email": USER_ADMIN_EMAIL,
"password": USER_ADMIN_PASSWORD,
},
timeout=2,
json={"name": "admin", "orgId": "", "orgName": "integration.test", "email": USER_ADMIN_EMAIL, "password": USER_ADMIN_PASSWORD},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/version"), timeout=2)
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/version"), timeout=5)
assert response.status_code == HTTPStatus.OK
assert response.json()["setupCompleted"] is True
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Verify admin user exists via v2
found_user = find_user_with_roles_by_email(signoz, admin_token, USER_ADMIN_EMAIL)
assert found_user["isRoot"] is True
assert_user_has_role(found_user, "signoz-admin")
def test_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create the editor user as a pending invite
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_EDITOR_EMAIL,
"displayName": USER_EDITOR_NAME,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
# Verify the user appears in the users list but as pending_invite status
found_user = find_user_with_roles_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
assert found_user["status"] == "pending_invite"
assert_user_has_role(found_user, "signoz-editor")
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
# Reset the password to complete the invite flow (activates the user and also grants authz)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": USER_EDITOR_PASSWORD, "token": response.json()["data"]["token"]},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify the user can now log in
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
assert editor_token is not None
# Verify that the editor user status has been updated to ACTIVE
admin_token_fresh = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = find_user_with_roles_by_email(signoz, admin_token_fresh, USER_EDITOR_EMAIL)
assert_user_has_role(found_user, "signoz-editor")
assert found_user["displayName"] == USER_EDITOR_NAME
assert found_user["email"] == USER_EDITOR_EMAIL
assert found_user["status"] == "active"
def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Invite the viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_VIEWER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-viewer")}],
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
# Delete the pending invite user (revoke the invite)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
def test_provision_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Mirrors the zeus provisioning flow."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
provisioned_email = "zeus-provisioned@integration.test"
provisioned_name = "zeus provisioned user"
provisioned_password = "password123Z$"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
role_id = next(role["id"] for role in response.json()["data"] if role["name"] == "signoz-admin")
create_payload = {
"email": provisioned_email,
"displayName": provisioned_name,
"userRoles": [{"id": role_id}],
}
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_id = next(user["id"] for user in response.json()["data"] if user["email"] == provisioned_email.strip().lower())
assert existing_id == user_id
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
assert reset_token != ""
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": provisioned_password, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={"email": provisioned_email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": provisioned_email, "password": provisioned_password, "orgId": org_id},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["accessToken"] != ""
provisioned_user = find_user_with_roles_by_email(signoz, admin_token, provisioned_email)
assert provisioned_user["status"] == "active"
assert provisioned_user["displayName"] == provisioned_name
assert_user_has_role(provisioned_user, "signoz-admin")

View File

@@ -11,6 +11,7 @@ from wiremock.client import (
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
def test_apply_license(
@@ -52,7 +53,7 @@ def test_apply_license(
],
)
access_token = get_token("admin@integration.test", "password123Z$")
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v4/licenses"),
@@ -121,7 +122,7 @@ def test_refresh_license(
],
)
access_token = get_token("admin@integration.test", "password123Z$")
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
url=signoz.self.host_configs["8080"].get("/api/v4/licenses/0196360e-90cd-7a74-8313-1aa815ce2a67"),
@@ -182,7 +183,7 @@ def test_license_checkout(
],
)
access_token = get_token("admin@integration.test", "password123Z$")
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v1/subscriptions"),
@@ -229,7 +230,7 @@ def test_license_portal(
],
)
access_token = get_token("admin@integration.test", "password123Z$")
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
url=signoz.self.host_configs["8080"].get("/api/v1/subscriptions"),

View File

@@ -0,0 +1,221 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USERS_BASE,
assert_user_has_role,
create_active_user,
find_user_with_roles_by_email,
)
from fixtures.role import find_role_by_name
_INVITED_EMAIL = "editor+invite@integration.test"
_INVITED_NAME = "invited editor"
_INVITED_PASSWORD = "password123Z$"
_REVOKED_EMAIL = "viewer+invite-revoked@integration.test"
_DUPLICATE_EMAIL = "duplicate+invite@integration.test"
_REINVITED_EMAIL = "reinvite+invite@integration.test"
_PROVISIONED_EMAIL = "admin+invite-provisioned@integration.test"
_PROVISIONED_NAME = "provisioned admin"
_PROVISIONED_PASSWORD = "password123Z$"
def test_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _INVITED_EMAIL, "displayName": _INVITED_NAME, "userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
found_user = find_user_with_roles_by_email(signoz, admin_token, _INVITED_EMAIL)
assert found_user["status"] == "pending_invite"
assert_user_has_role(found_user, "signoz-editor")
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": _INVITED_PASSWORD, "token": response.json()["data"]["token"]},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert get_token(_INVITED_EMAIL, _INVITED_PASSWORD) is not None
found_user = find_user_with_roles_by_email(signoz, admin_token, _INVITED_EMAIL)
assert found_user["status"] == "active"
assert found_user["displayName"] == _INVITED_NAME
assert found_user["email"] == _INVITED_EMAIL
assert_user_has_role(found_user, "signoz-editor")
def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _REVOKED_EMAIL, "userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-viewer")}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": reset_token},
timeout=5,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
def test_duplicate_invite_rejected(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _DUPLICATE_EMAIL, "userRoles": [{"id": editor_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _DUPLICATE_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _DUPLICATE_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT
def test_reinvite_deleted_user_creates_new_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user_id = create_active_user(signoz, admin_token, email=_REINVITED_EMAIL, role="signoz-editor", password="password123Z$", name="reinvite user")
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
reinvited_user_id = create_active_user(signoz, admin_token, email=_REINVITED_EMAIL, role="signoz-viewer", password="newPassword123Z$", name="reinvite user v2")
assert reinvited_user_id != user_id
response = requests.get(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{reinvited_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert_user_has_role(response.json()["data"], "signoz-viewer")
assert get_token(_REINVITED_EMAIL, "newPassword123Z$") is not None
def test_provisioned_admin_can_login(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Mirrors the zeus provisioning flow: invite an admin, activate, and log in with an explicit org id."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _PROVISIONED_EMAIL, "displayName": _PROVISIONED_NAME, "userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-admin")}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
assert reset_token != ""
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": _PROVISIONED_PASSWORD, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={"email": _PROVISIONED_EMAIL, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": _PROVISIONED_EMAIL, "password": _PROVISIONED_PASSWORD, "orgId": org_id},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["accessToken"] != ""
provisioned_user = find_user_with_roles_by_email(signoz, admin_token, _PROVISIONED_EMAIL)
assert provisioned_user["status"] == "active"
assert provisioned_user["displayName"] == _PROVISIONED_NAME
assert_user_has_role(provisioned_user, "signoz-admin")

View File

@@ -6,9 +6,6 @@ from sqlalchemy import sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user, find_user_by_email
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
PASSWORD_USER_EMAIL = "admin+password@integration.test"
PASSWORD_USER_PASSWORD = "password123Z$"
@@ -36,7 +33,7 @@ def test_change_password(signoz: types.SigNoz, get_token: Callable[[str, str], s
"oldPassword": "password",
"newPassword": PASSWORD_USER_PASSWORD,
},
timeout=2,
timeout=5,
headers={"Authorization": f"Bearer {token}"},
)
@@ -49,7 +46,7 @@ def test_change_password(signoz: types.SigNoz, get_token: Callable[[str, str], s
"oldPassword": PASSWORD_USER_PASSWORD,
"newPassword": "password123Znew$",
},
timeout=2,
timeout=5,
headers={"Authorization": f"Bearer {token}"},
)
@@ -70,7 +67,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
@@ -83,7 +80,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
assert response.json()["data"]["token"] == token
@@ -92,7 +89,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["token"] == token
@@ -101,7 +98,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password", "token": token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@@ -110,7 +107,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$NEWNEW#!", "token": token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -121,7 +118,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$REPLAY#!", "token": token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
@@ -143,7 +140,7 @@ def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callab
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
@@ -151,7 +148,7 @@ def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callab
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
@@ -163,7 +160,7 @@ def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callab
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "FINALPASSword123!#[", "token": token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -258,7 +255,7 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
token_data = response.json()["data"]
@@ -271,7 +268,7 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "newSecurePassword123Z$!", "token": reset_token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -280,11 +277,12 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
assert user_token is not None
# Verify old password no longer works
try:
get_token(forgot_email, "originalPassword123Z$")
assert False, "Old password should not work after reset"
except AssertionError:
pass # Expected - old password should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": forgot_email, "password": "originalPassword123Z$", "orgId": org_id},
timeout=5,
)
assert response.status_code == HTTPStatus.UNAUTHORIZED, response.text
def test_reset_password_with_expired_token(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
@@ -361,6 +359,6 @@ def test_reset_password_with_expired_token(signoz: types.SigNoz, get_token: Call
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "expiredTokenPassword123Z$!", "token": reset_token},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.UNAUTHORIZED

View File

@@ -1,70 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.role import find_role_by_name
from fixtures.types import SigNoz
DUPLICATE_USER_EMAIL = "duplicate@integration.test"
def test_duplicate_user_invite_rejected(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that the unique index on (email, org_id) in the users table prevents
creating duplicate users. This invites a new user, accepts the invite, then
tries to invite and accept the same email again expecting a failure.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
# Invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": DUPLICATE_USER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
# Invite the same email again while still pending — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT
# activate the user
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to invite the same email again once active — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT

View File

@@ -0,0 +1,161 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USERS_BASE,
assert_user_has_role,
create_active_user,
find_user_by_email,
find_user_with_roles_by_email,
)
_EDITOR_EMAIL = "editor+user@integration.test"
_EDITOR_PASSWORD = "password123Z$"
_SELF_ADMIN_EMAIL = "admin+user-self@integration.test"
_SELF_ADMIN_PASSWORD = "password123Z$"
_DELETED_EMAIL = "deleted+user@integration.test"
_DELETED_PASSWORD = "password123Z$"
def test_setup_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_active_user(signoz, admin_token, email=_EDITOR_EMAIL, role="signoz-editor", password=_EDITOR_PASSWORD, name="user editor")
create_active_user(signoz, admin_token, email=_SELF_ADMIN_EMAIL, role="signoz-admin", password=_SELF_ADMIN_PASSWORD, name="self admin")
def test_list_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(USERS_BASE), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
users = {user["email"]: user for user in response.json()["data"]}
assert users[USER_ADMIN_EMAIL]["isRoot"] is True
assert users[USER_ADMIN_EMAIL]["status"] == "active"
assert users[_EDITOR_EMAIL]["isRoot"] is False
assert users[_EDITOR_EMAIL]["status"] == "active"
def test_get_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor = find_user_with_roles_by_email(signoz, admin_token, _EDITOR_EMAIL)
assert editor["email"] == _EDITOR_EMAIL
assert editor["status"] == "active"
assert_user_has_role(editor, "signoz-editor")
def test_get_my_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"), headers={"Authorization": f"Bearer {editor_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["email"] == _EDITOR_EMAIL
assert data["isRoot"] is False
assert_user_has_role(data, "signoz-editor")
def test_update_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_id = find_user_by_email(signoz, admin_token, _EDITOR_EMAIL)["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{editor_id}"),
json={"displayName": "updated editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert find_user_by_email(signoz, admin_token, _EDITOR_EMAIL)["displayName"] == "updated editor"
def test_update_my_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"),
json={"displayName": "self updated editor"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"), headers={"Authorization": f"Bearer {editor_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["displayName"] == "self updated editor"
def test_admin_can_update_self_via_id(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
self_admin_id = find_user_by_email(signoz, admin_token, _SELF_ADMIN_EMAIL)["id"]
self_admin_token = get_token(_SELF_ADMIN_EMAIL, _SELF_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{self_admin_id}"),
json={"displayName": "self admin updated"},
headers={"Authorization": f"Bearer {self_admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"), headers={"Authorization": f"Bearer {self_admin_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["displayName"] == "self admin updated"
def test_root_user_is_immutable(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
root_id = find_user_by_email(signoz, admin_token, USER_ADMIN_EMAIL)["id"]
headers = {"Authorization": f"Bearer {admin_token}"}
response = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}"), json={"displayName": "should fail"}, headers=headers, timeout=5)
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED, response.text
response = requests.delete(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}"), headers=headers, timeout=5)
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED, response.text
response = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}/reset_password_tokens"), headers=headers, timeout=5)
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED, response.text
def test_delete_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user_id = create_active_user(signoz, admin_token, email=_DELETED_EMAIL, role="signoz-viewer", password=_DELETED_PASSWORD, name="deleted user")
user_token = get_token(_DELETED_EMAIL, _DELETED_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"), headers={"Authorization": f"Bearer {user_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
response = requests.delete(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["status"] == "deleted"
assert response.json()["data"]["userRoles"] == []
assert find_user_by_email(signoz, admin_token, _DELETED_EMAIL)["status"] == "deleted"
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/me"), headers={"Authorization": f"Bearer {user_token}"}, timeout=5)
assert response.status_code == HTTPStatus.UNAUTHORIZED
def test_editor_cannot_manage_other_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
root_id = find_user_by_email(signoz, admin_token, USER_ADMIN_EMAIL)["id"]
headers = {"Authorization": f"Bearer {get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)}"}
response = requests.get(signoz.self.host_configs["8080"].get(USERS_BASE), headers=headers, timeout=5)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}"), headers=headers, timeout=5)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}"), json={"displayName": "hacked"}, headers=headers, timeout=5)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.delete(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{root_id}"), headers=headers, timeout=5)
assert response.status_code == HTTPStatus.FORBIDDEN

View File

@@ -1,132 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
assert_user_has_role,
create_active_user,
)
from fixtures.types import SigNoz
def test_reinvite_deleted_user(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that a deleted user if re-inivited creates a new user altogether:
1. Invite and activate a user
2. Call the delete user api
3. Re-invite the same email — should succeed and create a new user with pending_invite status
4. Reset password for the new user
5. Get User API returns two users now, one deleted and one active
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
reinvite_user_email = "reinvite@integration.test"
user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-editor",
password="password123Z$",
name="reinvite user",
)
# call the delete api which now soft deletes the user
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Re-invite the same email — should succeed and create a different user
reinvited_user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-viewer",
password="newPassword123Z$",
name="reinvite user v2",
)
assert reinvited_user_id != user_id
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{reinvited_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert_user_has_role(response.json()["data"], "signoz-viewer")
# Verify user can log in with new password
user_token = get_token(reinvite_user_email, "newPassword123Z$")
assert user_token is not None
def test_delete_user(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that after soft-deleting a user:
1. GET /api/v2/users shows the user with status == "deleted"
2. GET /api/v2/users/{id} returns the user with empty userRoles (roles revoked)
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user_id = create_active_user(
signoz,
admin_token,
email="delete-verify-v2@integration.test",
role="signoz-editor",
password="password123Z$",
name="delete verify v2",
)
# verify user is active via v2
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["status"] == "active"
assert len(data["userRoles"]) == 1
# delete the user
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# verify status is deleted in the users list
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
deleted_user = next((u for u in users if u["id"] == user_id), None)
assert deleted_user is not None
assert deleted_user["status"] == "deleted"
# verify roles are revoked
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["status"] == "deleted"
assert len(data["userRoles"]) == 0

View File

@@ -7,8 +7,6 @@ from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_EDITOR_EMAIL,
USER_EDITOR_PASSWORD,
change_user_role,
create_active_user,
)
@@ -47,7 +45,7 @@ def test_change_role(
# Make some API call which is protected
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
timeout=5,
headers={"Authorization": f"Bearer {new_user_token}"},
)
@@ -61,7 +59,7 @@ def test_change_role(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{new_user_id}"),
json={"displayName": "role change user"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -77,7 +75,7 @@ def test_change_role(
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
timeout=5,
headers={"Authorization": f"Bearer {new_user_token}"},
)
@@ -275,11 +273,97 @@ def test_user_with_roles_reflects_change(
assert "signoz-admin" in role_names
def test_admin_cannot_assign_role_to_self(
def test_admin_can_change_own_roles(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/user_roles for the caller's own user is rejected (self-mutation guard)."""
"""Verify a non-root admin can assign a role to and remove a role from their own user."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
self_token = get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
self_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": self_id, "roleId": find_role_by_name(signoz, admin_token, "signoz-editor")},
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
editor_entry_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{editor_entry_id}"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert {ur["role"]["name"] for ur in response.json()["data"]["userRoles"]} == {"signoz-admin"}
def test_self_demotion_is_immediate_and_recoverable(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify a non-root admin who removes their own admin role loses access on the next call and root can restore it."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
self_token = get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
me = response.json()["data"]
admin_entry_id = next(ur["id"] for ur in me["userRoles"] if ur["role"]["name"] == "signoz-admin")
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{admin_entry_id}"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": me["id"], "roleId": find_role_by_name(signoz, admin_token, "signoz-admin")},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {self_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
def test_root_roles_cannot_be_changed(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify role assignment and removal on the root user are rejected by the root user guard."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -295,22 +379,7 @@ def test_admin_cannot_assign_role_to_self(
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_admin_cannot_remove_own_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify DELETE /api/v2/user_roles/{id} for the caller's own assignment is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
admin_data = response.json()["data"]
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED
admin_entry_id = next((ur["id"] for ur in admin_data["userRoles"] if ur["role"]["name"] == "signoz-admin"), None)
assert admin_entry_id is not None
@@ -320,7 +389,7 @@ def test_admin_cannot_remove_own_role(
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED
def test_editor_cannot_manage_roles(
@@ -340,7 +409,15 @@ def test_editor_cannot_manage_roles(
name="viewer roleauth",
)
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
create_active_user(
signoz,
admin_token,
email="editor+roleauth@integration.test",
role="signoz-editor",
password=ROLECHANGE_USER_PASSWORD,
name="editor roleauth",
)
editor_token = get_token("editor+roleauth@integration.test", ROLECHANGE_USER_PASSWORD)
# GET roles — forbidden
response = requests.get(

View File

@@ -0,0 +1,275 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_ROLES_BASE,
USERS_BASE,
add_license,
change_user_role,
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, transaction_group
_ACTOR_ROLE_NAME = "user-fga-actor"
_ACTOR_EMAIL = "customrole+userfga@integration.test"
_ACTOR_PASSWORD = "password123Z$"
_EDITOR_EMAIL = "editor+userfga@integration.test"
_EDITOR_PASSWORD = "password123Z$"
# Instance verbs are granted on _TARGET_EMAIL's id only; _OTHER_EMAIL must stay forbidden.
_TARGET_EMAIL = "target+userfga@integration.test"
_OTHER_EMAIL = "other+userfga@integration.test"
_TARGET_PASSWORD = "password123Z$"
_INVITED_VIEWER_EMAIL = "invited-viewer+userfga@integration.test"
_INVITED_EDITOR_EMAIL = "invited-editor+userfga@integration.test"
_INVITED_NO_ROLE_EMAIL = "invited-norole+userfga@integration.test"
def _set_actor_role(signoz: types.SigNoz, admin_token: str, transaction_groups: list[dict]) -> None:
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": transaction_groups},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
def test_apply_license(
signoz: types.SigNoz,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
def test_setup_actor_and_targets(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_active_user(signoz, admin_token, email=_EDITOR_EMAIL, role="signoz-editor", password=_EDITOR_PASSWORD, name="user-fga-editor")
create_active_user(signoz, admin_token, email=_TARGET_EMAIL, role="signoz-viewer", password=_TARGET_PASSWORD, name="user-fga-target")
create_active_user(signoz, admin_token, email=_OTHER_EMAIL, role="signoz-viewer", password=_TARGET_PASSWORD, name="user-fga-other")
target_id = find_user_by_email(signoz, admin_token, _TARGET_EMAIL)["id"]
create_role(
admin_token,
_ACTOR_ROLE_NAME,
[
transaction_group("read", "user", "user", [target_id]),
transaction_group("list", "user", "user", ["*"]),
],
)
actor_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="user-fga-actor")
change_user_role(signoz, admin_token, actor_id, "signoz-viewer", _ACTOR_ROLE_NAME)
def test_editor_cannot_issue_reset_token(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
target_id = find_user_by_email(signoz, admin_token, _TARGET_EMAIL)["id"]
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers={"Authorization": f"Bearer {editor_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"editor reset token: expected 403, got {resp.status_code}: {resp.text}"
def test_read_scoped_to_granted_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
target_id = find_user_by_email(signoz, admin_token, _TARGET_EMAIL)["id"]
other_id = find_user_by_email(signoz, admin_token, _OTHER_EMAIL)["id"]
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"get granted user: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/roles"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"get granted user roles: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{other_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"get other user: expected 403, got {resp.status_code}: {resp.text}"
# list is collection-scoped: list on "*" returns every user, including the one
# the actor cannot read individually.
resp = requests.get(signoz.self.host_configs["8080"].get(USERS_BASE), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, resp.text
ids = {user["id"] for user in resp.json()["data"]}
assert {target_id, other_id} <= ids
resp = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}"),
json={"displayName": "user-fga-target-renamed"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"update user without grant: expected 403, got {resp.status_code}: {resp.text}"
def test_reset_password_token_scoped_to_granted_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
target_id = find_user_by_email(signoz, admin_token, _TARGET_EMAIL)["id"]
other_id = find_user_by_email(signoz, admin_token, _OTHER_EMAIL)["id"]
_set_actor_role(
signoz,
admin_token,
[
transaction_group("read", "user", "user", [target_id]),
transaction_group("attach", "user", "user", [target_id]),
transaction_group("create", "metaresource", "factor-password", ["*"]),
transaction_group("list", "metaresource", "factor-password", ["*"]),
],
)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.CREATED, f"create reset token for granted user: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"get reset token for granted user: {resp.text}"
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{other_id}/reset_password_tokens"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create reset token for other user: expected 403, got {resp.status_code}: {resp.text}"
# user:attach alone is not enough: factor-password:create is checked too.
_set_actor_role(signoz, admin_token, [transaction_group("attach", "user", "user", [target_id])])
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create reset token without factor-password:create: expected 403, got {resp.status_code}: {resp.text}"
def test_user_role_attach_detach_dual_scoped(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
target_id = find_user_by_email(signoz, admin_token, _TARGET_EMAIL)["id"]
other_id = find_user_by_email(signoz, admin_token, _OTHER_EMAIL)["id"]
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
admin_role_id = find_role_by_name(signoz, admin_token, "signoz-admin")
# attach/detach granted on the target user id AND the signoz-editor role name only.
_set_actor_role(
signoz,
admin_token,
[
transaction_group("read", "user", "user", [target_id]),
transaction_group("attach", "user", "user", [target_id]),
transaction_group("detach", "user", "user", [target_id]),
transaction_group("attach", "role", "role", ["signoz-editor"]),
transaction_group("detach", "role", "role", ["signoz-editor"]),
],
)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.post(
signoz.self.host_configs["8080"].get(USER_ROLES_BASE),
json={"userId": target_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, f"assign editor to target: {resp.text}"
editor_entry_id = resp.json()["data"]["id"]
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USER_ROLES_BASE}/{editor_entry_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"get user role of granted user: {resp.text}"
resp = requests.post(
signoz.self.host_configs["8080"].get(USER_ROLES_BASE),
json={"userId": other_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign editor to other user: expected 403, got {resp.status_code}: {resp.text}"
resp = requests.post(
signoz.self.host_configs["8080"].get(USER_ROLES_BASE),
json={"userId": target_id, "roleId": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign admin to target: expected 403, got {resp.status_code}: {resp.text}"
resp = requests.delete(signoz.self.host_configs["8080"].get(f"{USER_ROLES_BASE}/{editor_entry_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove editor from target: {resp.text}"
def test_invite_checks_role_attach_per_role(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
_set_actor_role(
signoz,
admin_token,
[
transaction_group("create", "user", "user", ["*"]),
transaction_group("attach", "user", "user", ["*"]),
transaction_group("attach", "role", "role", ["signoz-viewer"]),
],
)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _INVITED_VIEWER_EMAIL, "displayName": "invited-viewer", "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, f"invite with viewer: {resp.text}"
resp = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _INVITED_EDITOR_EMAIL, "displayName": "invited-editor", "userRoles": [{"id": editor_role_id}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"invite with editor: expected 403, got {resp.status_code}: {resp.text}"
# No roles in the body: nothing to attach, so neither user:attach nor role:attach is checked.
_set_actor_role(signoz, admin_token, [transaction_group("create", "user", "user", ["*"])])
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": _INVITED_NO_ROLE_EMAIL, "displayName": "invited-norole", "userRoles": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, f"invite without roles: {resp.text}"
resp = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={"email": "invited-viewer-denied+userfga@integration.test", "displayName": "invited-viewer-denied", "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"invite with viewer without role:attach: expected 403, got {resp.status_code}: {resp.text}"
def test_cleanup(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {admin_token}"}
actor_id = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)["id"]
change_user_role(signoz, admin_token, actor_id, _ACTOR_ROLE_NAME, "signoz-viewer")
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers=headers, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
for email in (_ACTOR_EMAIL, _EDITOR_EMAIL, _TARGET_EMAIL, _OTHER_EMAIL, _INVITED_VIEWER_EMAIL, _INVITED_NO_ROLE_EMAIL):
user_id = find_user_by_email(signoz, admin_token, email)["id"]
resp = requests.delete(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"), headers=headers, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete {email}: {resp.text}"

View File

@@ -1,216 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USER_EDITOR_EMAIL,
USER_EDITOR_PASSWORD,
assert_user_has_role,
create_active_user,
find_user_by_email,
find_user_with_roles_by_email,
)
def test_list_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify GET /api/v2/users returns all users with correct fields."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
admin_user = next((u for u in users if u["email"] == USER_ADMIN_EMAIL), None)
assert admin_user is not None
assert admin_user["isRoot"] is True
assert admin_user["status"] == "active"
editor_user = next((u for u in users if u["email"] == USER_EDITOR_EMAIL), None)
assert editor_user is not None
assert editor_user["status"] == "active"
def test_get_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify GET /api/v2/users/{id} returns user with roles."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_user = find_user_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["email"] == USER_EDITOR_EMAIL
assert data["status"] == "active"
assert len(data["userRoles"]) >= 1
assert_user_has_role(data, "signoz-editor")
def test_get_my_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify GET /api/v2/users/me returns authenticated user with roles."""
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["email"] == USER_EDITOR_EMAIL
assert data["status"] == "active"
assert data["isRoot"] is False
assert_user_has_role(data, "signoz-editor")
def test_update_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify PUT /api/v2/users/{id} updates displayName."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_user = find_user_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_user['id']}"),
json={"displayName": "updated editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
updated = find_user_with_roles_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
assert updated["displayName"] == "updated editor"
def test_update_my_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify PUT /api/v2/users/me updates own displayName."""
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
json={"displayName": "self updated editor"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["displayName"] == "self updated editor"
def test_admin_cannot_update_self_via_id(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify PUT /api/v2/users/{own_id} is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
admin_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
json={"displayName": "should fail"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_editor_cannot_list_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify non-admin cannot call GET /api/v2/users."""
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_editor_cannot_get_other_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify non-admin cannot call GET /api/v2/users/{other_id}."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
admin_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_editor_cannot_update_other_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify non-admin cannot call PUT /api/v2/users/{other_id}."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
admin_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
json={"displayName": "hacked"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_deleted_user_session_is_revoked(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify a deleted user's existing session stops authenticating immediately."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user_id = create_active_user(signoz, admin_token, "revoked@integration.test", "signoz-viewer", "password123Z$", "revoked")
user_token = get_token("revoked@integration.test", "password123Z$")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {user_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {user_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.UNAUTHORIZED

View File

@@ -49,7 +49,7 @@ def test_unique_index_allows_multiple_deleted_rows(
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
first_user_id = resp.json()["data"]["id"]
@@ -57,7 +57,7 @@ def test_unique_index_allows_multiple_deleted_rows(
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{first_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert resp.status_code == HTTPStatus.NO_CONTENT
@@ -70,7 +70,7 @@ def test_unique_index_allows_multiple_deleted_rows(
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
second_user_id = resp.json()["data"]["id"]
@@ -79,7 +79,7 @@ def test_unique_index_allows_multiple_deleted_rows(
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{second_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
timeout=5,
)
assert resp.status_code == HTTPStatus.NO_CONTENT