Compare commits

..

16 Commits

Author SHA1 Message Date
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
16 changed files with 1051 additions and 267 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

@@ -275,11 +275,51 @@ 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_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 +335,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 +345,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(

View File

@@ -112,8 +112,8 @@ def test_update_my_user(signoz: types.SigNoz, get_token: Callable[[str, str], st
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)."""
def test_root_cannot_be_updated_via_id(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify PUT /api/v2/users/{root_id} is rejected by the root user guard."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
@@ -130,7 +130,37 @@ def test_admin_cannot_update_self_via_id(signoz: types.SigNoz, get_token: Callab
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.status_code == HTTPStatus.NOT_IMPLEMENTED
def test_admin_can_update_self_via_id(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Verify a non-root admin can call PUT /api/v2/users/{own_id}."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
self_admin_id = create_active_user(
signoz,
admin_token,
email="admin+selfupdate@integration.test",
role="signoz-admin",
password="password123Z$",
name="self admin",
)
self_admin_token = get_token("admin+selfupdate@integration.test", "password123Z$")
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{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("/api/v2/users/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_editor_cannot_list_users(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:

View File

@@ -0,0 +1,295 @@
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,
USER_ROLES_BASE,
USERS_BASE,
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$"
_VIEWER_EMAIL = "viewer+userfga@integration.test"
_VIEWER_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_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=_VIEWER_EMAIL, role="signoz-viewer", password=_VIEWER_PASSWORD, name="user-fga-viewer")
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_managed_roles_matrix(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"]
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
for email, password in ((USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD), (_VIEWER_EMAIL, _VIEWER_PASSWORD)):
token = get_token(email, password)
headers = {"Authorization": f"Bearer {token}"}
resp = requests.get(signoz.self.host_configs["8080"].get(USERS_BASE), headers=headers, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"{email} list users: expected 403, got {resp.status_code}: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}"), headers=headers, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"{email} get user: expected 403, got {resp.status_code}: {resp.text}"
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers=headers, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"{email} reset token: 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": viewer_role_id},
headers=headers,
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"{email} assign role: expected 403, got {resp.status_code}: {resp.text}"
admin_headers = {"Authorization": f"Bearer {admin_token}"}
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}"), headers=admin_headers, timeout=5)
assert resp.status_code == HTTPStatus.OK, resp.text
resp = requests.get(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers=admin_headers, timeout=5)
assert resp.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND), resp.text
resp = requests.put(signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{target_id}/reset_password_tokens"), headers=admin_headers, timeout=5)
assert resp.status_code == HTTPStatus.CREATED, 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, _VIEWER_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}"