Compare commits

...

49 Commits

Author SHA1 Message Date
Cursor Agent
0f226f9cb5 Fix user listing and invite validation regressions 2026-03-09 14:41:03 +00:00
Karan Balani
d40cc4e1e7 chore: add a temp filter to show only active users in frontend until next frontend fix 2026-03-09 19:58:13 +05:30
Karan Balani
2ebd29b0cd chore: remove unused emails var 2026-03-09 19:58:13 +05:30
Karan Balani
095dd7eb9d fix: make 068 migratin idempotent 2026-03-09 19:58:13 +05:30
Karan Balani
3a34185122 chore: move duplicate email check to unmarshaljson and query user again in accept invite 2026-03-09 19:58:13 +05:30
Karan Balani
6460f0cb7a feat: use composite unique index on users table instead of partial one 2026-03-09 19:58:13 +05:30
Karan Balani
289273f61e chore: add tests for partial index too 2026-03-09 19:58:13 +05:30
Karan Balani
7eed7183b7 fix: edge cases 2026-03-09 19:58:13 +05:30
Karan Balani
739e7d51ab fix: user status persistence 2026-03-09 19:58:12 +05:30
Karan Balani
039d10fa08 fix: errifpending 2026-03-09 19:58:12 +05:30
Karan Balani
35f0539ca7 fix: openapi specs 2026-03-09 19:58:12 +05:30
Karan Balani
912ee4149f fix: fix duplicate email invite issue and add partial invite 2026-03-09 19:58:12 +05:30
Karan Balani
1fb7fcb7d2 fix: use db ctx in various places 2026-03-09 19:58:12 +05:30
Karan Balani
5ef76c0fd0 chore: address review comments and optimise bulk invite api 2026-03-09 19:58:12 +05:30
Karan Balani
dfca34b106 fix: callback authn tests oidc 2026-03-09 19:58:12 +05:30
Karan Balani
85720f0463 fix: remove extra oidc tests 2026-03-09 19:58:12 +05:30
Karan Balani
e3ee0b6738 fix: callbackauthn tests 2026-03-09 19:58:12 +05:30
Karan Balani
7523a780fd fix: password authn tests also add old invite flow test 2026-03-09 19:58:12 +05:30
Karan Balani
36d4c59222 fix: soft delete store method 2026-03-09 19:58:12 +05:30
Karan Balani
b270083bdd fix: error code for errifdeleted in user 2026-03-09 19:58:12 +05:30
Karan Balani
4c2849fc63 feat: helper method ErrIfDeleted for user 2026-03-09 19:58:12 +05:30
Karan Balani
ace520532d fix: transaction issues 2026-03-09 19:58:12 +05:30
Karan Balani
749a07bcfe fix: move reset link method to types, also move authz grants outside transation 2026-03-09 19:58:12 +05:30
Karan Balani
43954f1444 fix: wrap funcs to transactions, and fix openapi specs 2026-03-09 19:58:12 +05:30
Karan Balani
6d42be04cf chore: move to bulk inserts for migrating existing invites 2026-03-09 19:58:12 +05:30
Karan Balani
3e838e8718 chore: address some review comments, add back countbyorgid method 2026-03-09 19:58:12 +05:30
Karan Balani
5387f554fc fix: check deleted user in reset password flow 2026-03-09 19:58:12 +05:30
Karan Balani
ead9968438 fix: filter active users in role and org id check 2026-03-09 19:58:12 +05:30
Karan Balani
c917f18430 chore: change ordering of apis in server 2026-03-09 19:58:12 +05:30
Karan Balani
fa7b5418ab chore: change ordering of apis in server 2026-03-09 19:58:12 +05:30
Karan Balani
ad166fccc4 feat: add backward compatibility to existing apis using new invite flow 2026-03-09 19:58:12 +05:30
Karan Balani
a7b8fe582a fix: error from GetUsersByEmailAndOrgID 2026-03-09 19:58:12 +05:30
Karan Balani
2abbeff83d feat: migrate existing invites to user as pending invite status 2026-03-09 19:58:12 +05:30
Karan Balani
abf00f174b fix: a deleted user cannot recycled, creating a new one 2026-03-09 19:58:12 +05:30
Karan Balani
6611432e15 chore: rename countbyorgid to activecountbyorgid 2026-03-09 19:58:12 +05:30
Karan Balani
5488741170 fix: openapi frontend 2026-03-09 19:58:12 +05:30
Karan Balani
ceaf853973 fix: context api 2026-03-09 19:58:12 +05:30
Karan Balani
6c84319e79 fix: role tests 2026-03-09 19:58:12 +05:30
Karan Balani
d59cc81d29 feat: improve invitation email template 2026-03-09 19:58:12 +05:30
Karan Balani
a2433e2050 fix: integration tests fmtlint 2026-03-09 19:58:12 +05:30
Karan Balani
74ed40ea82 fix: integration tests and minor naming change 2026-03-09 19:58:11 +05:30
Karan Balani
501c94b37f fix: openapi specs 2026-03-09 19:58:11 +05:30
Karan Balani
c6ced93784 chore: remove all code related to old invite flow 2026-03-09 19:58:11 +05:30
Karan Balani
63849b601c fix: edge cases for reset token and context api 2026-03-09 19:58:11 +05:30
Karan Balani
cca30651a8 fix: array for grants 2026-03-09 19:58:11 +05:30
Karan Balani
eb49de65f9 feat: integration tests with fixes for new flow 2026-03-09 19:58:11 +05:30
Karan Balani
0004ad8934 fix: handle edge cases for authentication and reset password flow 2026-03-09 19:58:11 +05:30
Karan Balani
a79193aef1 fix: handle soft deleted users flow 2026-03-09 19:58:11 +05:30
Karan Balani
5a79efbe23 feat: deprecate user invite table 2026-03-09 19:58:11 +05:30
32 changed files with 1495 additions and 420 deletions

View File

@@ -1,4 +1,4 @@
FROM node:18-bullseye AS build
FROM node:22-bookworm AS build
WORKDIR /opt/
COPY ./frontend/ ./

View File

@@ -2108,6 +2108,16 @@ components:
token:
type: string
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
items:
$ref: '#/components/schemas/TypesPostableInvite'
nullable: true
type: array
required:
- invites
type: object
TypesPostableForgotPassword:
properties:
email:
@@ -2196,6 +2206,8 @@ components:
type: string
role:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
@@ -3538,9 +3550,7 @@ paths:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/TypesPostableInvite'
type: array
$ref: '#/components/schemas/TypesPostableBulkInviteRequest'
responses:
"201":
description: Created

View File

@@ -170,7 +170,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}

View File

@@ -2525,6 +2525,14 @@ export interface TypesPostableAcceptInviteDTO {
token?: string;
}
export interface TypesPostableBulkInviteRequestDTO {
/**
* @type array
* @nullable true
*/
invites: TypesPostableInviteDTO[] | null;
}
export interface TypesPostableForgotPasswordDTO {
/**
* @type string
@@ -2665,6 +2673,10 @@ export interface TypesUserDTO {
* @type string
*/
role?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time

View File

@@ -41,6 +41,7 @@ import type {
TypesChangePasswordRequestDTO,
TypesPostableAcceptInviteDTO,
TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
@@ -671,14 +672,14 @@ export const useAcceptInvite = <
* @summary Create bulk invite
*/
export const createBulkInvite = (
typesPostableInviteDTO: BodyType<TypesPostableInviteDTO[]>,
typesPostableBulkInviteRequestDTO: BodyType<TypesPostableBulkInviteRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/invite/bulk`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableInviteDTO,
data: typesPostableBulkInviteRequestDTO,
signal,
});
};
@@ -690,13 +691,13 @@ export const getCreateBulkInviteMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
const mutationKey = ['createBulkInvite'];
@@ -710,7 +711,7 @@ export const getCreateBulkInviteMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createBulkInvite>>,
{ data: BodyType<TypesPostableInviteDTO[]> }
{ data: BodyType<TypesPostableBulkInviteRequestDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -723,7 +724,7 @@ export const getCreateBulkInviteMutationOptions = <
export type CreateBulkInviteMutationResult = NonNullable<
Awaited<ReturnType<typeof createBulkInvite>>
>;
export type CreateBulkInviteMutationBody = BodyType<TypesPostableInviteDTO[]>;
export type CreateBulkInviteMutationBody = BodyType<TypesPostableBulkInviteRequestDTO>;
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -736,13 +737,13 @@ export const useCreateBulkInvite = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
const mutationOptions = getCreateBulkInviteMutationOptions(options);

View File

@@ -32,7 +32,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Tags: []string{"users"},
Summary: "Create bulk invite",
Description: "This endpoint creates a bulk invite for a user",
Request: make([]*types.PostableInvite, 0),
Request: new(types.PostableBulkInviteRequest),
RequestContentType: "application/json",
Response: nil,
SuccessStatusCode: http.StatusCreated,

View File

@@ -17,7 +17,7 @@ func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
return &store{sqlstore: sqlstore}
}
func (store *store) GetUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
user := new(types.User)
factorPassword := new(types.FactorPassword)
@@ -28,6 +28,7 @@ func (store *store) GetUserAndFactorPasswordByEmailAndOrgID(ctx context.Context,
Model(user).
Where("email = ?", email).
Where("org_id = ?", orgID).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s in org %s not found", email, orgID)

View File

@@ -21,7 +21,7 @@ func New(store authtypes.AuthNStore) *AuthN {
}
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
user, factorPassword, err := a.store.GetUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}

View File

@@ -65,6 +65,9 @@ func (module *module) GetSessionContext(ctx context.Context, email valuer.Email,
return nil, err
}
// filter out deleted users
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
// Since email is a valuer, we can be sure that it is a valid email and we can split it to get the domain name.
name := strings.Split(email.String(), "@")[1]
@@ -141,7 +144,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
roleMapping := authDomain.AuthDomainConfig().RoleMapping
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}

View File

@@ -86,6 +86,15 @@ func (module *getter) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int6
return count, nil
}
func (module *getter) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
counts, err := module.store.CountByOrgIDAndStatuses(ctx, orgID, statuses)
if err != nil {
return nil, err
}
return counts, nil
}
func (module *getter) GetFactorPasswordByUserID(ctx context.Context, userID valuer.UUID) (*types.FactorPassword, error) {
factorPassword, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {

View File

@@ -149,16 +149,11 @@ func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
return
}
uuid, err := valuer.NewUUID(id)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
if err := h.module.DeleteInvite(ctx, claims.OrgID, uuid); err != nil {
if err := h.module.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@@ -2,7 +2,6 @@ package impluser
import (
"context"
"fmt"
"slices"
"strings"
"time"
@@ -52,39 +51,50 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
}
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
invite, err := m.store.GetInviteByToken(ctx, token)
// get the user by reset password token
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID)
// update the password and delete the token
err = m.UpdatePasswordByResetPasswordToken(ctx, token, password)
if err != nil {
return nil, err
}
factorPassword, err := types.NewFactorPassword(password, user.ID.StringValue())
// query the user again
user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID)
if err != nil {
return nil, err
}
err = m.CreateUser(ctx, user, root.WithFactorPassword(factorPassword))
if err != nil {
return nil, err
}
if err := m.DeleteInvite(ctx, invite.OrgID.String(), invite.ID); err != nil {
return nil, err
}
return user, nil
}
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
invite, err := m.store.GetInviteByToken(ctx, token)
// get the user
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
// create a dummy invite obj for backward compatibility
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: user.ID,
},
Name: user.DisplayName,
Email: user.Email,
Token: token,
Role: user.Role,
OrgID: user.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
}
return invite, nil
}
@@ -95,80 +105,160 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
return nil, err
}
invites := make([]*types.Invite, 0, len(bulkInvites.Invites))
for _, invite := range bulkInvites.Invites {
// check if user exists
existingUser, err := m.store.GetUserByEmailAndOrgID(ctx, invite.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingUser != nil {
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot send invite to root user")
}
}
if existingUser != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email")
}
// Check if an invite already exists
existingInvite, err := m.store.GetInviteByEmailAndOrgID(ctx, invite.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingInvite != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email")
}
role, err := types.NewRole(invite.Role.String())
if err != nil {
return nil, err
}
newInvite, err := types.NewInvite(invite.Name, role, orgID, invite.Email)
if err != nil {
return nil, err
}
newInvite.InviteLink = fmt.Sprintf("%s/signup?token=%s", invite.FrontendBaseUrl, newInvite.Token)
invites = append(invites, newInvite)
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
emails[idx] = invite.Email.StringValue()
}
err = m.store.CreateBulkInvite(ctx, invites)
users, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err != nil {
return nil, err
}
for i := 0; i < len(invites); i++ {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{"invitee_email": invites[i].Email, "invitee_role": invites[i].Role})
if len(users) > 0 {
for _, existingUser := range users {
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "Cannot send invite to root user")
}
}
// if the frontend base url is not provided, we don't send the email
if bulkInvites.Invites[i].FrontendBaseUrl == "" {
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", invites[i].Email)
if users[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", users[0].Email.StringValue())
}
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", users[0].Email.StringValue())
}
type userWithResetToken struct {
User *types.User
ResetPasswordToken *types.ResetPasswordToken
}
newUsersWithResetToken := make([]*userWithResetToken, len(bulkInvites.Invites))
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
for idx, invite := range bulkInvites.Invites {
role, err := types.NewRole(invite.Role.String())
if err != nil {
return err
}
// create a new user with pending invite status
newUser, err := types.NewUser(invite.Name, invite.Email, role, orgID, types.UserStatusPendingInvite)
if err != nil {
return err
}
// store the user and password in db
err = m.createUserWithoutGrant(ctx, newUser)
if err != nil {
return err
}
// generate reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.ID)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to create reset password token for invited user", "error", err)
return err
}
newUsersWithResetToken[idx] = &userWithResetToken{
User: newUser,
ResetPasswordToken: resetPasswordToken,
}
}
return nil
}); err != nil {
return nil, err
}
invites := make([]*types.Invite, len(bulkInvites.Invites))
// send password reset emails to all the invited users
for idx, userWithToken := range newUsersWithResetToken {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.User.Role,
})
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: userWithToken.User.ID,
},
Name: userWithToken.User.DisplayName,
Email: userWithToken.User.Email,
Token: userWithToken.ResetPasswordToken.Token,
Role: userWithToken.User.Role,
OrgID: userWithToken.User.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: userWithToken.User.CreatedAt,
UpdatedAt: userWithToken.User.UpdatedAt,
},
}
invites[idx] = invite
frontendBaseUrl := bulkInvites.Invites[idx].FrontendBaseUrl
if frontendBaseUrl == "" {
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", userWithToken.User.Email)
continue
}
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": creator.Email,
"link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
}
resetLink := userWithToken.ResetPasswordToken.FactorPasswordResetLink(frontendBaseUrl)
tokenLifetime := m.config.Password.Reset.MaxTokenLifetime
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
if err := m.emailing.SendHTML(ctx, userWithToken.User.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": creator.Email,
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send invite email", "error", err)
}
}
return invites, nil
}
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
return m.store.ListInvite(ctx, orgID)
}
// find all the users with pending_invite status
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
if err != nil {
return nil, err
}
func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
return m.store.DeleteInvite(ctx, orgID, id)
pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite })
var invites []*types.Invite
for _, pUser := range pendingUsers {
// get the reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID)
if err != nil {
return nil, err
}
// create a dummy invite obj for backward compatibility
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: pUser.ID,
},
Name: pUser.DisplayName,
Email: pUser.Email,
Token: resetPasswordToken.Token,
Role: pUser.Role,
OrgID: pUser.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: pUser.CreatedAt,
UpdatedAt: pUser.UpdatedAt, // dummy
},
}
invites = append(invites, invite)
}
return invites, nil
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
@@ -213,6 +303,14 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
return nil, errors.WithAdditionalf(err, "cannot update root user")
}
if err := existingUser.ErrIfDeleted(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
}
if err := existingUser.ErrIfPending(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update pending user")
}
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
@@ -224,7 +322,7 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
// Make sure that the request is not demoting the last admin user.
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
adminUsers, err := m.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
adminUsers, err := m.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return nil, err
}
@@ -280,12 +378,16 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.WithAdditionalf(err, "cannot delete root user")
}
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot delete already deleted user")
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
// don't allow to delete the last admin user
adminUsers, err := module.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
adminUsers, err := module.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return err
}
@@ -300,7 +402,8 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := module.store.DeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
// for now we are only soft deleting users
if err := module.store.SoftDeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
return err
}
@@ -321,6 +424,10 @@ func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID
return nil, errors.WithAdditionalf(err, "cannot reset password for root user")
}
if err := user.ErrIfDeleted(); err != nil {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "user has been deleted")
}
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
@@ -375,7 +482,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "Users are not allowed to reset their password themselves, please contact an admin to reset your password.")
}
user, err := module.store.GetUserByEmailAndOrgID(ctx, email, orgID)
user, err := module.GetNonDeletedUserByEmailAndOrgID(ctx, email, orgID)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return nil // for security reasons
@@ -393,7 +500,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return err
}
resetLink := fmt.Sprintf("%s/password-reset?token=%s", frontendBaseURL, token.Token)
resetLink := token.FactorPasswordResetLink(frontendBaseURL)
tokenLifetime := module.config.Password.Reset.MaxTokenLifetime
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
@@ -435,6 +542,11 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
// handle deleted user
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "deleted users cannot reset their password")
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
@@ -443,7 +555,38 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
return module.store.UpdatePassword(ctx, password)
// since grant is idempotent, multiple calls won't cause issues in case of retries
if user.Status == types.UserStatusPendingInvite {
if err = module.authz.Grant(
ctx,
user.OrgID,
[]string{roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
); err != nil {
return err
}
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if user.Status == types.UserStatusPendingInvite {
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
return err
}
}
if err := module.store.UpdatePassword(ctx, password); err != nil {
return err
}
if err := module.store.DeleteResetPasswordTokenByPasswordID(ctx, password.ID); err != nil {
return err
}
return nil
})
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
@@ -452,6 +595,10 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
return err
}
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for deleted user")
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for root user")
}
@@ -469,7 +616,17 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
return err
}
if err := module.store.UpdatePassword(ctx, password); err != nil {
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.UpdatePassword(ctx, password); err != nil {
return err
}
if err := module.store.DeleteResetPasswordTokenByPasswordID(ctx, password.ID); err != nil {
return err
}
return nil
}); err != nil {
return err
}
@@ -477,7 +634,7 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
}
func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opts ...root.CreateUserOption) (*types.User, error) {
existingUser, err := module.store.GetUserByEmailAndOrgID(ctx, user.Email, user.OrgID)
existingUser, err := module.GetNonDeletedUserByEmailAndOrgID(ctx, user.Email, user.OrgID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
@@ -485,6 +642,16 @@ func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opt
}
if existingUser != nil {
// for users logging through SSO flow but are having status as pending_invite
if existingUser.Status == types.UserStatusPendingInvite {
// respect the role coming from the SSO
existingUser.Update("", user.Role)
// activate the user
if err = module.activatePendingUser(ctx, existingUser); err != nil {
return nil, err
}
}
return existingUser, nil
}
@@ -561,12 +728,15 @@ func (module *Module) CreateFirstUser(ctx context.Context, organization *types.O
func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
counts, err := module.store.CountByOrgIDAndStatuses(ctx, orgID, []string{types.UserStatusActive.StringValue(), types.UserStatusDeleted.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err == nil {
stats["user.count"] = count
stats["user.count"] = counts[types.UserStatusActive] + counts[types.UserStatusDeleted] + counts[types.UserStatusPendingInvite]
stats["user.count.active"] = counts[types.UserStatusActive]
stats["user.count.deleted"] = counts[types.UserStatusDeleted]
stats["user.count.pending_invite"] = counts[types.UserStatusPendingInvite]
}
count, err = module.store.CountAPIKeyByOrgID(ctx, orgID)
count, err := module.store.CountAPIKeyByOrgID(ctx, orgID)
if err == nil {
stats["factor.api_key.count"] = count
}
@@ -574,6 +744,28 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
// this function restricts that only one non-deleted user email can exist for an org ID, if found more, it throws an error
func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
existingUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
// filter out the deleted users
existingUsers = slices.DeleteFunc(existingUsers, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
if len(existingUsers) > 1 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "Multiple non-deleted users found for email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
if len(existingUsers) == 1 {
return existingUsers[0], nil
}
return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "No non-deleted user found with email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
@@ -598,3 +790,25 @@ func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.U
return nil
}
func (module *Module) activatePendingUser(ctx context.Context, user *types.User) error {
err := module.authz.Grant(
ctx,
user.OrgID,
[]string{roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
)
if err != nil {
return err
}
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
err = module.store.UpdateUser(ctx, user.OrgID, user)
if err != nil {
return err
}
return nil
}

View File

@@ -143,7 +143,7 @@ func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) erro
}
func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID) error {
existingUser, err := s.store.GetUserByEmailAndOrgID(ctx, s.config.Email, orgID)
existingUser, err := s.module.GetNonDeletedUserByEmailAndOrgID(ctx, s.config.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}

View File

@@ -25,77 +25,6 @@ func NewStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) typ
return &store{sqlstore: sqlstore, settings: settings}
}
// CreateBulkInvite implements types.InviteStore.
func (store *store) CreateBulkInvite(ctx context.Context, invites []*types.Invite) error {
_, err := store.sqlstore.BunDB().NewInsert().
Model(&invites).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrInviteAlreadyExists, "invite with email: %s already exists in org: %s", invites[0].Email, invites[0].OrgID)
}
return nil
}
// Delete implements types.InviteStore.
func (store *store) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
_, err := store.sqlstore.BunDB().NewDelete().
Model(&types.Invite{}).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with id: %s does not exist in org: %s", id.StringValue(), orgID)
}
return nil
}
func (store *store) GetInviteByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.Invite, error) {
invite := new(types.Invite)
err := store.
sqlstore.
BunDBCtx(ctx).NewSelect().
Model(invite).
Where("email = ?", email).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with email %s does not exist in org %s", email, orgID)
}
return invite, nil
}
func (store *store) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) {
invite := new(types.Invite)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(invite).
Where("token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite does not exist", token)
}
return invite, nil
}
func (store *store) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
invites := new([]*types.Invite)
err := store.sqlstore.BunDB().NewSelect().
Model(invites).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with org id: %s does not exist", orgID)
}
return *invites, nil
}
func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) error {
_, err := store.
sqlstore.
@@ -175,24 +104,25 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
return user, nil
}
func (store *store) GetUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
user := new(types.User)
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Model(&users).
Where("org_id = ?", orgID).
Where("email = ?", email).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s does not exist in org %s", email, orgID)
return nil, err
}
return user, nil
return users, nil
}
func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
func (store *store) GetActiveUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
@@ -202,6 +132,7 @@ func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role,
Model(&users).
Where("org_id = ?", orgID).
Where("role = ?", role).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
@@ -221,6 +152,7 @@ func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *typ
Column("role").
Column("is_root").
Column("updated_at").
Column("status").
Where("org_id = ?", orgID).
Where("id = ?", user.ID).
Exec(ctx)
@@ -247,20 +179,10 @@ func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]
return users, nil
}
func (store *store) DeleteUser(ctx context.Context, orgID string, id string) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
func (store *store) deleteUserAssociationsTx(ctx context.Context, tx bun.Tx, id string) error {
// get the password id
var password types.FactorPassword
err = tx.NewSelect().
err := tx.NewSelect().
Model(&password).
Where("user_id = ?", id).
Scan(ctx)
@@ -313,6 +235,23 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
}
return nil
}
func (store *store) DeleteUser(ctx context.Context, orgID string, id string) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err := store.deleteUserAssociationsTx(ctx, tx, id); err != nil {
return err
}
// delete user
_, err = tx.NewDelete().
Model(new(types.User)).
@@ -331,10 +270,46 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return nil
}
func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err := store.deleteUserAssociationsTx(ctx, tx, id); err != nil {
return err
}
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().
Model(new(types.User)).
Set("status = ?", types.UserStatusDeleted).
Set("deleted_at = ?", now).
Set("updated_at = ?", now).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user")
}
err = tx.Commit()
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
}
return nil
}
func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordToken *types.ResetPasswordToken) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(resetPasswordToken).
Exec(ctx)
@@ -367,7 +342,7 @@ func (store *store) GetPasswordByUserID(ctx context.Context, userID valuer.UUID)
err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(password).
Where("user_id = ?", userID).
@@ -383,7 +358,7 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw
err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(resetPasswordToken).
Where("password_id = ?", passwordID).
@@ -396,7 +371,7 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw
}
func (store *store) DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error {
_, err := store.sqlstore.BunDB().NewDelete().
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().
Model(&types.ResetPasswordToken{}).
Where("password_id = ?", passwordID).
Exec(ctx)
@@ -418,23 +393,14 @@ func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*t
Where("token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist", token)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist")
}
return resetPasswordRequest, nil
}
func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.FactorPassword) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
_, err = tx.
_, err := store.sqlstore.BunDBCtx(ctx).
NewUpdate().
Model(factorPassword).
Where("user_id = ?", factorPassword.UserID).
@@ -443,20 +409,6 @@ func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.Fa
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", factorPassword.UserID)
}
_, err = tx.
NewDelete().
Model(&types.ResetPasswordToken{}).
Where("password_id = ?", factorPassword.ID).
Exec(ctx)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
@@ -582,6 +534,36 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
return int64(count), nil
}
func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
user := new(types.User)
var results []struct {
Status valuer.String `bun:"status"`
Count int64 `bun:"count"`
}
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
ColumnExpr("status").
ColumnExpr("COUNT(*) AS count").
Where("org_id = ?", orgID.StringValue()).
Where("status IN (?)", bun.In(statuses)).
GroupExpr("status").
Scan(ctx, &results)
if err != nil {
return nil, err
}
counts := make(map[valuer.String]int64, len(results))
for _, r := range results {
counts[r.Status] = r.Count
}
return counts, nil
}
func (store *store) CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
apiKey := new(types.StorableAPIKey)
@@ -638,3 +620,41 @@ func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.
return users, nil
}
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Join(`JOIN factor_password ON factor_password.user_id = "user".id`).
Join("JOIN reset_password_token ON reset_password_token.password_id = factor_password.id").
Where("reset_password_token.token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user not found for reset password token")
}
return user, nil
}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.User, error) {
users := []*types.User{}
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&users).
Where("email IN (?)", bun.In(emails)).
Where("org_id = ?", orgID).
Where("status in (?)", bun.In(statuses)).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}

View File

@@ -42,7 +42,6 @@ type Module interface {
// invite
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
AcceptInvite(ctx context.Context, token string, password string) (*types.User, error)
GetInviteByToken(ctx context.Context, token string) (*types.Invite, error)
@@ -53,6 +52,8 @@ type Module interface {
RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error)
GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error)
statsreporter.StatsCollector
}
@@ -78,6 +79,9 @@ type Getter interface {
// Count users by org id.
CountByOrgID(context.Context, valuer.UUID) (int64, error)
// Count of users by org id and grouped by status.
CountByOrgIDAndStatuses(context.Context, valuer.UUID, []string) (map[valuer.String]int64, error)
// Get factor password by user id.
GetFactorPasswordByUserID(context.Context, valuer.UUID) (*types.FactorPassword, error)
}

View File

@@ -170,6 +170,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
sqlmigration.NewAddStatusUserFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,109 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addStatusUser struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddStatusUserFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_status_user"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addStatusUser{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addStatusUser) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addStatusUser) Up(ctx context.Context, db *bun.DB) error {
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("users"))
if err != nil {
return err
}
statusColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("status"),
DataType: sqlschema.DataTypeText,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, statusColumn, "active")
// add deleted_at (zero time = not deleted, non-zero = deletion timestamp) to enable the
// composite unique index that replaces the partial index approach
deletedAtColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("deleted_at"),
DataType: sqlschema.DataTypeTimestamp,
Nullable: false,
}
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, deletedAtColumn, time.Time{})...)
// we need to drop the unique index on (email, org_id)
sqls = append(sqls, migration.sqlschema.Operator().DropIndex(&sqlschema.UniqueIndex{TableName: "users", ColumnNames: []sqlschema.ColumnName{"email", "org_id"}})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// add a composite unique index on (org_id, email, deleted_at).
// active and pending users have deleted_at=time.Time{} (zero), forming a unique (org_id, email, zero) tuple.
// soft-deleted users have deleted_at set to the deletion timestamp, making each deleted row unique
// and allowing the same email to be re-invited after deletion without a constraint violation.
newIndexSqls := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "users", ColumnNames: []sqlschema.ColumnName{"org_id", "email", "deleted_at"}})
for _, sql := range newIndexSqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, true); err != nil {
return err
}
return nil
}
func (migration *addStatusUser) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,154 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type deprecateUserInvite struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
type userInviteRow struct {
bun.BaseModel `bun:"table:user_invite"`
ID string `bun:"id"`
Name string `bun:"name"`
Email string `bun:"email"`
Role string `bun:"role"`
OrgID string `bun:"org_id"`
Token string `bun:"token"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
}
type pendingInviteUser struct {
bun.BaseModel `bun:"table:users"`
ID string `bun:"id"`
DisplayName string `bun:"display_name"`
Email string `bun:"email"`
Role string `bun:"role"`
OrgID string `bun:"org_id"`
IsRoot bool `bun:"is_root"`
Status string `bun:"status"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
DeletedAt time.Time `bun:"deleted_at"`
}
func NewDeprecateUserInviteFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("deprecate_user_invite"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deprecateUserInvite{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *deprecateUserInvite) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *deprecateUserInvite) Up(ctx context.Context, db *bun.DB) error {
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("user_invite"))
if err != nil {
if err == sql.ErrNoRows || errors.Ast(err, errors.TypeNotFound) {
return nil
}
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// existing invites
var invites []*userInviteRow
err = tx.NewSelect().Model(&invites).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
// move all invitations to the users table as a pending_invite user
// skipping any invite whose email+org already has a user entry with non-deleted status
users := make([]*pendingInviteUser, 0, len(invites))
for _, invite := range invites {
existingCount, err := tx.NewSelect().
TableExpr("users").
Where("email = ?", invite.Email).
Where("org_id = ?", invite.OrgID).
Where("status != ?", "deleted").
Count(ctx)
if err != nil {
return err
}
if existingCount > 0 {
continue
}
user := &pendingInviteUser{
ID: invite.ID,
DisplayName: invite.Name,
Email: invite.Email,
Role: invite.Role,
OrgID: invite.OrgID,
IsRoot: false,
Status: "pending_invite",
CreatedAt: invite.CreatedAt,
UpdatedAt: time.Now(),
DeletedAt: time.Time{},
}
users = append(users, user)
}
if len(users) > 0 {
_, err = tx.NewInsert().Model(&users).Exec(ctx)
if err != nil {
return err
}
}
// finally drop the user_invite table
dropTableSqls := migration.sqlschema.Operator().DropTable(table)
for _, sql := range dropTableSqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *deprecateUserInvite) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -125,7 +125,7 @@ func (typ *Identity) ToClaims() Claims {
type AuthNStore interface {
// Get user and factor password by email and orgID.
GetUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error)
GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error)
// Get org domain from id.
GetAuthDomainFromID(ctx context.Context, domainID valuer.UUID) (*AuthDomain, error)

View File

@@ -2,6 +2,7 @@ package types
import (
"encoding/json"
"fmt"
"slices"
"time"
"unicode"
@@ -220,3 +221,7 @@ func comparePassword(hashedPassword string, password string) bool {
func (r *ResetPasswordToken) IsExpired() bool {
return r.ExpiresAt.Before(time.Now())
}
func (r *ResetPasswordToken) FactorPasswordResetLink(frontendBaseUrl string) string {
return fmt.Sprintf("%s/password-reset?token=%s", frontendBaseUrl, r.Token)
}

View File

@@ -54,7 +54,29 @@ type PostableInvite struct {
}
type PostableBulkInviteRequest struct {
Invites []PostableInvite `json:"invites"`
Invites []PostableInvite `json:"invites" required:"true"`
}
func (request *PostableBulkInviteRequest) UnmarshalJSON(data []byte) error {
type Alias PostableBulkInviteRequest
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// check for duplicate emails in the same request
seen := make(map[string]struct{}, len(temp.Invites))
for _, invite := range temp.Invites {
email := invite.Email.StringValue()
if _, exists := seen[email]; exists {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "Duplicate email in request: %s", email)
}
seen[email] = struct{}{}
}
*request = PostableBulkInviteRequest(temp)
return nil
}
type GettableCreateInviteResponse struct {

View File

@@ -3,6 +3,7 @@ package types
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -21,6 +22,15 @@ var (
ErrAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
ErrAPIKeyNotFound = errors.MustNewCode("api_key_not_found")
ErrCodeRootUserOperationUnsupported = errors.MustNewCode("root_user_operation_unsupported")
ErrCodeUserStatusDeleted = errors.MustNewCode("user_status_deleted")
ErrCodeUserStatusPendingInvite = errors.MustNewCode("user_status_pending_invite")
)
var (
UserStatusPendingInvite = valuer.NewString("pending_invite")
UserStatusActive = valuer.NewString("active")
UserStatusDeleted = valuer.NewString("deleted")
ValidUserStatus = []valuer.String{UserStatusPendingInvite, UserStatusActive, UserStatusDeleted}
)
type GettableUser = User
@@ -29,11 +39,13 @@ type User struct {
bun.BaseModel `bun:"table:users"`
Identifiable
DisplayName string `bun:"display_name" json:"displayName"`
Email valuer.Email `bun:"email" json:"email"`
Role Role `bun:"role" json:"role"`
OrgID valuer.UUID `bun:"org_id" json:"orgId"`
IsRoot bool `bun:"is_root" json:"isRoot"`
DisplayName string `bun:"display_name" json:"displayName"`
Email valuer.Email `bun:"email" json:"email"`
Role Role `bun:"role" json:"role"`
OrgID valuer.UUID `bun:"org_id" json:"orgId"`
IsRoot bool `bun:"is_root" json:"isRoot"`
Status valuer.String `bun:"status" json:"status"`
DeletedAt time.Time `bun:"deleted_at" json:"-"`
TimeAuditable
}
@@ -45,7 +57,7 @@ type PostableRegisterOrgAndAdmin struct {
OrgName string `json:"orgName"`
}
func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUID) (*User, error) {
func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUID, status valuer.String) (*User, error) {
if email.IsZero() {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
@@ -58,6 +70,10 @@ func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUI
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
}
if !slices.Contains(ValidUserStatus, status) {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid status: %s, allowed status are: %v", status, ValidUserStatus)
}
return &User{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
@@ -67,6 +83,7 @@ func NewUser(displayName string, email valuer.Email, role Role, orgID valuer.UUI
Role: role,
OrgID: orgID,
IsRoot: false,
Status: status,
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -92,6 +109,7 @@ func NewRootUser(displayName string, email valuer.Email, orgID valuer.UUID) (*Us
Role: RoleAdmin,
OrgID: orgID,
IsRoot: true,
Status: UserStatusActive,
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -111,6 +129,23 @@ func (u *User) Update(displayName string, role Role) {
u.UpdatedAt = time.Now()
}
func (u *User) UpdateStatus(status valuer.String) error {
// no updates allowed if user is in delete state
if err := u.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot update status of a deleted user")
}
// not udpates allowed from active to pending state
if status == UserStatusPendingInvite && u.Status == UserStatusActive {
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cannot move user to pending state from active state")
}
u.Status = status
u.UpdatedAt = time.Now()
return nil
}
// PromoteToRoot promotes the user to a root user with admin role.
func (u *User) PromoteToRoot() {
u.IsRoot = true
@@ -133,12 +168,31 @@ func (u *User) ErrIfRoot() error {
return nil
}
// ErrIfDeleted returns an error if the user is in deleted state.
// This error can be enriched with specific operation by the called using errors.WithAdditionalf
func (u *User) ErrIfDeleted() error {
if u.Status == UserStatusDeleted {
return errors.New(errors.TypeUnsupported, ErrCodeUserStatusDeleted, "unsupported operation for deleted user")
}
return nil
}
// ErrIfPending returns an error if the user is in pending invite state.
// This error can be enriched with specific operation by the called using errors.WithAdditionalf
func (u *User) ErrIfPending() error {
if u.Status == UserStatusPendingInvite {
return errors.New(errors.TypeUnsupported, ErrCodeUserStatusPendingInvite, "unsupported operation for pending user")
}
return nil
}
func NewTraitsFromUser(user *User) map[string]any {
return map[string]any{
"name": user.DisplayName,
"role": user.Role,
"email": user.Email.String(),
"display_name": user.DisplayName,
"status": user.Status,
"created_at": user.CreatedAt,
}
}
@@ -160,17 +214,6 @@ func (request *PostableRegisterOrgAndAdmin) UnmarshalJSON(data []byte) error {
}
type UserStore interface {
// invite
CreateBulkInvite(ctx context.Context, invites []*Invite) error
ListInvite(ctx context.Context, orgID string) ([]*Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
// Get invite by token.
GetInviteByToken(ctx context.Context, token string) (*Invite, error)
// Get invite by email and org.
GetInviteByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*Invite, error)
// Creates a user.
CreateUser(ctx context.Context, user *User) error
@@ -181,13 +224,13 @@ type UserStore interface {
GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*User, error)
// Get user by email and orgID.
GetUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*User, error)
GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*User, error)
// Get users by email.
GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*User, error)
// Get users by role and org.
GetUsersByRoleAndOrgID(ctx context.Context, role Role, orgID valuer.UUID) ([]*User, error)
GetActiveUsersByRoleAndOrgID(ctx context.Context, role Role, orgID valuer.UUID) ([]*User, error)
// List users by org.
ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*User, error)
@@ -195,8 +238,12 @@ type UserStore interface {
// List users by email and org ids.
ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*User, error)
// Get users for an org id using emails and statuses
GetUsersByEmailsOrgIDAndStatuses(context.Context, valuer.UUID, []string, []string) ([]*User, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, user *User) error
DeleteUser(ctx context.Context, orgID string, id string) error
SoftDeleteUser(ctx context.Context, orgID string, id string) error
// Creates a password.
CreatePassword(ctx context.Context, password *FactorPassword) error
@@ -217,10 +264,14 @@ type UserStore interface {
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error)
// Get root user by org.
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*User, error)
// Get user by reset password token
GetUserByResetPasswordToken(ctx context.Context, token string) (*User, error)
// Transaction
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>You're Invited to Join SigNoz</title>
<title>{{.subject}}</title>
</head>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#333;background:#fff">
@@ -41,13 +41,13 @@
</tr>
</table>
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
Accept the invitation to get started.
Click the button below to set your password and activate your account:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
<tr>
<td align="center">
<a href="{{.link}}" target="_blank" style="display:inline-block;padding:16px 48px;font-size:16px;font-weight:600;color:#fff;background:#4E74F8;text-decoration:none;border-radius:4px">
Accept Invitation
Set Password
</a>
</td>
</tr>
@@ -60,6 +60,18 @@
{{.link}}
</a>
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin:0 0 16px">
<tr>
<td style="padding:16px;background:#fff4e6;border-radius:6px;border-left:4px solid #ff9800">
<p style="margin:0;font-size:14px;color:#333;line-height:1.6">
<strong>&#9201; This link will expire in {{.Expiry}}.</strong>
</p>
</td>
</tr>
</table>
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
If you didn't expect this invitation, please ignore this email. No account will be activated.
</p>
{{ if .format.Help.Enabled }}
<p style="margin:0 0 16px;font-size:16px;color:#333;line-height:1.6">
Need help? Chat with our team in the SigNoz application or email us at <a href="mailto:{{.format.Help.Email}}" style="color:#4E74F8;text-decoration:none">{{.format.Help.Email}}</a>.

View File

@@ -4,6 +4,7 @@ from typing import Any, Callable, Dict, List
import requests
from selenium import webdriver
from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import (
@@ -570,3 +571,121 @@ def test_saml_empty_name_fallback(
assert found_user is not None
assert found_user["role"] == "VIEWER"
def test_saml_sso_login_activates_pending_invite_user(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
driver: webdriver.Chrome,
create_user_idp_with_groups: Callable[[str, str, bool, List[str]], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], str],
) -> None:
"""
Verify that an invited user (pending_invite) who logs in via SAML SSO is
auto-activated with the role from the invite, not the SSO default/group role.
1. Admin invites user as ADMIN
2. User exists in IDP with 'signoz-viewers' group (would normally get VIEWER)
3. SSO login activates the user with VIEWER role (SSO Wins)
"""
email = "sso-pending-invite@saml.integration.test"
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "SAML SSO Pending User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
# Create IDP user in viewer group — SSO would normally assign VIEWER
create_user_idp_with_groups(email, "password", True, ["signoz-viewers"])
perform_saml_login(
signoz, driver, get_session_context, idp_login, email, "password"
)
# User should be active with VIEWER role from SSO
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
assert found_user["status"] == "active"
assert found_user["role"] == "VIEWER"
def test_saml_sso_deleted_user_gets_new_user_on_login(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str, bool, str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], str],
) -> None:
"""
Verify the full deleted-user SAML SSO lifecycle:
1. Invite + activate a user (EDITOR)
2. Soft delete the user
3. SSO login attempt — user should remain deleted (blocked)
5. SSO login — new user should created
"""
email = "sso-deleted-lifecycle@saml.integration.test"
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# --- Step 1: Invite and activate via password reset ---
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "EDITOR", "name": "SAML SSO Lifecycle User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
user_id = response.json()["data"]["id"]
reset_token = response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# --- Step 2: Soft delete via DB using API
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# --- Step 3: SSO login should be blocked for deleted user ---
create_user_idp(email, "password", True, "SAML", "Lifecycle")
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
# Verify user is NOT reactivated — check via DB since API may filter deleted users
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text("SELECT status FROM users WHERE id = :user_id"),
{"user_id": user_id},
)
row = result.fetchone()
assert row is not None
assert row[0] == "deleted"
# Verify a NEW active user was auto-provisioned via SSO
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
found_user = next(
(user for user in response.json()["data"] if user["email"] == email and user["id"] != user_id),
None,
)
assert found_user is not None
assert found_user["status"] == "active"
assert found_user["role"] == "VIEWER" # default role from SSO domain config

View File

@@ -4,6 +4,7 @@ from urllib.parse import urlparse
import requests
from selenium import webdriver
from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import (
@@ -532,3 +533,54 @@ def test_oidc_empty_name_uses_fallback(
assert found_user is not None
assert found_user["role"] == "VIEWER"
# Note: displayName may be empty - this is a known limitation
def test_oidc_sso_login_activates_pending_invite_user(
signoz: SigNoz,
idp: TestContainerIDP,
driver: webdriver.Chrome,
create_user_idp_with_groups: Callable[[str, str, bool, List[str]], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], str],
) -> None:
"""
Verify that an invited user (pending_invite) who logs in via OIDC SSO is
auto-activated with the role from the invite, not the SSO default/group role.
1. Admin invites user as ADMIN
2. User exists in IDP with 'signoz-viewers' group (would normally get VIEWER)
3. SSO login activates the user with VIEWER role (SSO wins)
"""
email = "sso-pending-invite@oidc.integration.test"
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "OIDC SSO Pending User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
# Create IDP user in viewer group — SSO would normally assign VIEWER
create_user_idp_with_groups(email, "password123", True, ["signoz-viewers"])
perform_oidc_login(
signoz, idp, driver, get_session_context, idp_login, email, "password123"
)
# User should be active with ADMIN role from invite, not VIEWER from SSO
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
found_user = next(
(user for user in response.json()["data"] if user["email"] == email),
None,
)
assert found_user is not None
assert found_user["status"] == "active"
assert found_user["role"] == "VIEWER"

View File

@@ -104,68 +104,66 @@ def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
def test_invite_and_register(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
# Generate an invite token for the editor user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "editor@integration.test", "role": "EDITOR", "name": "editor"},
timeout=2,
headers={
"Authorization": f"Bearer {get_token("admin@integration.test", "password123Z$")}"
"Authorization": f"Bearer {admin_token}"
},
)
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("admin@integration.test", "password123Z$")}"
},
)
invited_user = response.json()["data"]
assert invited_user["email"] == "editor@integration.test"
assert invited_user["role"] == "EDITOR"
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "editor@integration.test"
),
# Verify the user user appears in the users list but as pending_invite status
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "editor@integration.test"),
None,
)
assert found_user is not None
assert found_user["status"] == "pending_invite"
assert found_user["role"] == "EDITOR"
# Register the editor user using the invite token
reset_token = invited_user["token"]
# 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/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "editor",
"token": f"{found_invite['token']}",
},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify that the invite token has been deleted
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['token']}"),
timeout=2,
)
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.BAD_REQUEST)
# Verify the user can now log in
editor_token = get_token("editor@integration.test", "password123Z$")
assert editor_token is not None
# Verify that an admin endpoint cannot be called by the editor user
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("editor@integration.test", "password123Z$")}"
"Authorization": f"Bearer {editor_token}"
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
# Verify that the editor has been created
# Verify that the editor user status has been updated to ACTIVE
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
@@ -186,59 +184,40 @@ def test_invite_and_register(
assert found_user["role"] == "EDITOR"
assert found_user["displayName"] == "editor"
assert found_user["email"] == "editor@integration.test"
assert found_user["status"] == "active"
def test_revoke_invite_and_register(
signoz: types.SigNoz, get_token: Callable[[str, str], str]
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
# Generate an invite token for the viewer user
# Invite the viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "viewer@integration.test", "role": "VIEWER"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
invited_user = response.json()["data"]
reset_token = invited_user["token"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("admin@integration.test", "password123Z$")}"
},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "viewer@integration.test"
),
None,
)
# Delete the pending invite user (revoke the invite)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v1/user/{invited_user['id']}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try registering the viewer user with the invite token
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "viewer",
"token": f"{found_invite["token"]}",
},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code in (HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND)
@@ -269,3 +248,85 @@ def test_self_access(
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["role"] == "EDITOR"
def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token("admin@integration.test", "password123Z$")
# invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "oldinviteflow@integration.test", "role": "VIEWER", "name": "old invite flow"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
# get the invite token using get api
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "oldinviteflow@integration.test"
),
None,
)
# accept the invite
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "old invite flow",
"token": f"{found_invite['token']}",
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
# verify the invite token has been deleted
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['token']}"),
timeout=2,
)
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.BAD_REQUEST)
# verify that admin endpoints cannot be called
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("oldinviteflow@integration.test", "password123Z$")}"
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
# verify the user has been created
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "oldinviteflow@integration.test"),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"
assert found_user["displayName"] == "old invite flow"
assert found_user["email"] == "oldinviteflow@integration.test"

View File

@@ -22,50 +22,17 @@ def test_change_password(
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
invited_user = response.json()["data"]
reset_token = invited_user["token"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "admin+password@integration.test"
),
None,
)
# Accept the invite with a bad password which should fail
# Reset password to activate user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password",
"displayName": "admin password",
"token": f"{found_invite['token']}",
},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# Accept the invite with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "admin password",
"token": f"{found_invite['token']}",
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.NO_CONTENT
# Get the user id
response = requests.get(
@@ -301,33 +268,16 @@ def test_forgot_password_creates_reset_token(
)
assert response.status_code == HTTPStatus.CREATED
# Get the invite token
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "forgot@integration.test"
),
None,
)
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Accept the invite to create the user
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "originalPassword123Z$",
"displayName": "forgotpassword user",
"token": f"{found_invite['token']}",
},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "originalPassword123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.NO_CONTENT
# Get org ID
response = requests.get(

View File

@@ -23,20 +23,16 @@ def test_change_role(
assert response.status_code == HTTPStatus.CREATED
invite_token = response.json()["data"]["token"]
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Accept the invite of the new user
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "role change user",
"token": f"{invite_token}",
},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.NO_CONTENT
# Make some API calls as new user
new_user_token, new_user_refresh_token = get_tokens(

View File

@@ -20,43 +20,39 @@ def test_duplicate_user_invite_rejected(
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Step 1: Invite a new user.
initial_invite_response = requests.post(
# Invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert initial_invite_response.status_code == HTTPStatus.CREATED
initial_invite_token = initial_invite_response.json()["data"]["token"]
assert response.status_code == HTTPStatus.CREATED
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Step 2: Accept the invite to create the user.
initial_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": initial_invite_token, "password": "password123Z$"},
timeout=2,
)
assert initial_accept_response.status_code == HTTPStatus.CREATED
# Step 3: Invite the same email again.
duplicate_invite_response = requests.post(
# Invite the same email again — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT
# The invite creation itself may be rejected if the app checks for existing users.
if duplicate_invite_response.status_code != HTTPStatus.CREATED:
assert duplicate_invite_response.status_code == HTTPStatus.CONFLICT
return
duplicate_invite_token = duplicate_invite_response.json()["data"]["token"]
# Step 4: Accept the duplicate invite — should fail due to unique constraint.
duplicate_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": duplicate_invite_token, "password": "password123Z$"},
# activate the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert duplicate_accept_response.status_code == HTTPStatus.CONFLICT
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to invite the same email again — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT

View File

@@ -0,0 +1,105 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
from sqlalchemy import sql
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"
reinvite_user_name = "reinvite user"
reinvite_user_role = "EDITOR"
reinvite_user_password = "password123Z$"
# invite the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": reinvite_user_email, "role": reinvite_user_role, "name": reinvite_user_name},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# reset the password to make it active
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": reinvite_user_password, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# call the delete api which now soft deletes the user
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{invited_user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Re-invite the same email — should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": reinvite_user_email, "role": "VIEWER", "name": "reinvite user v2"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
reinvited_user = response.json()["data"]
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
reinvited_user_reset_password_token = reinvited_user["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "newPassword123Z$", "token": reinvited_user_reset_password_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify user can log in with new password
user_token = get_token("reinvite@integration.test", "newPassword123Z$")
assert user_token is not None
def test_bulk_invite(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify the bulk invite endpoint creates multiple pending_invite users.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/bulk"),
json={
"invites": [
{"email": "bulk1@integration.test", "role": "EDITOR", "name": "bulk user 1"},
{"email": "bulk2@integration.test", "role": "VIEWER", "name": "bulk user 2"},
]
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED

View File

@@ -0,0 +1,176 @@
import uuid
from http import HTTPStatus
from typing import Callable
import pytest
import requests
from sqlalchemy import sql
from sqlalchemy.exc import IntegrityError
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
UNIQUE_INDEX_USER_EMAIL = "useruniqueindex@integration.test"
def test_unique_index_allows_multiple_deleted_rows(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that the composite unique index on (org_id, email, deleted_at) allows multiple
deleted rows for the same (org_id, email) while still enforcing uniqueness among
non-deleted rows.
Non-deleted users share deleted_at=zero-time, so the unique index prevents duplicates.
Soft-deleted users each have a distinct deleted_at timestamp, so the index allows
multiple deleted rows for the same (org_id, email).
Steps:
1. Invite and soft-delete a user via the API (first deleted row).
2. Re-invite and soft-delete the same email via the API (second deleted row).
3. Assert via SQL that exactly two deleted rows exist for the email.
4. Assert via SQL that inserting one active row succeeds (no conflict — only
deleted rows exist), then inserting a second active row for the same
(org_id, email) fails with a unique constraint error (both have deleted_at=zero-time).
5. Assert via SQL that inserting a third deleted row for the same (org_id, email)
with a unique deleted_at succeeds — confirming the index does not cover deleted rows.
6. Assert via SQL that the final count of deleted rows is 3.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Step 1: invite and delete the first user
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v1"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert resp.status_code == HTTPStatus.CREATED
first_user_id = resp.json()["data"]["id"]
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{first_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert resp.status_code == HTTPStatus.NO_CONTENT
# Step 2: re-invite and delete the same email (second deleted row)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": UNIQUE_INDEX_USER_EMAIL, "role": "EDITOR", "name": "unique index user v2"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert resp.status_code == HTTPStatus.CREATED
second_user_id = resp.json()["data"]["id"]
assert second_user_id != first_user_id
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{second_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert resp.status_code == HTTPStatus.NO_CONTENT
# Step 3: assert the DB has exactly two deleted rows for this email
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text(
"SELECT id, deleted_at FROM users"
" WHERE email = :email AND deleted_at != :zero_time"
),
{"email": UNIQUE_INDEX_USER_EMAIL, "zero_time": "0001-01-01 00:00:00"},
)
deleted_rows = result.fetchall()
assert len(deleted_rows) == 2, (
f"expected 2 deleted rows for {UNIQUE_INDEX_USER_EMAIL}, got {len(deleted_rows)}"
)
deleted_ids = {row[0] for row in deleted_rows}
assert first_user_id in deleted_ids
assert second_user_id in deleted_ids
# Retrieve org_id for the direct SQL inserts below
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text("SELECT org_id FROM users WHERE id = :id"),
{"id": first_user_id},
)
org_id = result.fetchone()[0]
# Step 4: the unique index must still block a duplicate non-deleted row.
# Both active rows have deleted_at=zero-time, so they share the same (org_id, email, zero-time)
# tuple. First insert must succeed (only deleted rows exist so far).
# Second insert for the same (org_id, email) with deleted_at=zero-time must fail.
active_id = str(uuid.uuid4())
with signoz.sqlstore.conn.connect() as conn:
conn.execute(
sql.text(
"INSERT INTO users"
" (id, display_name, email, role, org_id, is_root, status, created_at, updated_at, deleted_at)"
" VALUES (:id, :display_name, :email, :role, :org_id,"
" false, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, :zero_time)"
),
{
"id": active_id,
"display_name": "first active row",
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"org_id": org_id,
"zero_time": "0001-01-01 00:00:00",
},
)
conn.commit()
with signoz.sqlstore.conn.connect() as conn:
with pytest.raises(IntegrityError):
conn.execute(
sql.text(
"INSERT INTO users"
" (id, display_name, email, role, org_id, is_root, status, created_at, updated_at, deleted_at)"
" VALUES (:id, :display_name, :email, :role, :org_id,"
" false, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, :zero_time)"
),
{
"id": str(uuid.uuid4()),
"display_name": "should violate index",
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"org_id": org_id,
"zero_time": "0001-01-01 00:00:00",
},
)
# Step 5: a third deleted row with a unique deleted_at must be accepted
with signoz.sqlstore.conn.connect() as conn:
conn.execute(
sql.text(
"INSERT INTO users"
" (id, display_name, email, role, org_id, is_root, status, created_at, updated_at, deleted_at)"
" VALUES (:id, :display_name, :email, :role, :org_id,"
" false, 'deleted', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{
"id": str(uuid.uuid4()),
"display_name": "third deleted row",
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"org_id": org_id,
},
)
conn.commit()
# Step 6: confirm three deleted rows now exist
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text(
"SELECT COUNT(*) FROM users"
" WHERE email = :email AND deleted_at != :zero_time"
),
{"email": UNIQUE_INDEX_USER_EMAIL, "zero_time": "0001-01-01 00:00:00"},
)
count = result.fetchone()[0]
assert count == 3, f"expected 3 deleted rows after direct insert, got {count}"

View File

@@ -34,19 +34,15 @@ def test_user_invite_accept_role_grant(
timeout=2,
)
assert invite_response.status_code == HTTPStatus.CREATED
invite_token = invite_response.json()["data"]["token"]
invited_user = invite_response.json()["data"]
reset_token = invited_user["token"]
# accept the invite for editor
accept_payload = {
"token": invite_token,
"password": "password123Z$",
}
accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json=accept_payload,
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": USER_EDITOR_PASSWORD, "token": reset_token},
timeout=2,
)
assert accept_response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.NO_CONTENT
# Login with editor email and password
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)