mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-05 13:22:00 +00:00
Compare commits
26 Commits
main
...
cursor/use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fd25157e | ||
|
|
d4d068610a | ||
|
|
2064c28853 | ||
|
|
99fddb8592 | ||
|
|
2b88d22336 | ||
|
|
6453014081 | ||
|
|
61571516b3 | ||
|
|
a848533e8a | ||
|
|
8f1ff66d21 | ||
|
|
2e8ab8ad23 | ||
|
|
d308c8a36f | ||
|
|
770dcf6326 | ||
|
|
1cf30945ec | ||
|
|
e807913ef7 | ||
|
|
6b3d2acf96 | ||
|
|
76f9e978a3 | ||
|
|
3d51da74f0 | ||
|
|
3414bdebbe | ||
|
|
2d441a3307 | ||
|
|
2e47d05539 | ||
|
|
d42b250b40 | ||
|
|
d4fef79d64 | ||
|
|
6d70e9e7ef | ||
|
|
aaed262f0f | ||
|
|
104d5c158e | ||
|
|
e4fbc028f8 |
@@ -1,4 +1,4 @@
|
||||
FROM node:18-bullseye AS build
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
|
||||
@@ -2040,31 +2040,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesInvite:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
inviteLink:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
TypesOrganization:
|
||||
properties:
|
||||
alias:
|
||||
@@ -2097,17 +2072,6 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableAcceptInvite:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
sourceUrl:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableForgotPassword:
|
||||
properties:
|
||||
email:
|
||||
@@ -2196,6 +2160,8 @@ components:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -3275,53 +3241,6 @@ paths:
|
||||
tags:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all invites
|
||||
operationId: ListInvite
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List invites
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an invite for a user
|
||||
@@ -3338,7 +3257,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -3384,151 +3303,6 @@ paths:
|
||||
summary: Create invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an invite by id
|
||||
operationId: DeleteInvite
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/{token}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an invite by token
|
||||
operationId: GetInvite
|
||||
parameters:
|
||||
- in: path
|
||||
name: token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Get invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/accept:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint accepts an invite by token
|
||||
operationId: AcceptInvite
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableAcceptInvite'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Accept invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -2415,47 +2415,6 @@ export interface TypesIdentifiableDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TypesInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
inviteLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesOrganizationDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2506,25 +2465,6 @@ export interface TypesPostableAPIKeyDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableAcceptInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
sourceUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableForgotPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2665,6 +2605,10 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -3017,40 +2961,7 @@ export type GetGlobalConfig200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListInvite200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesInviteDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateInvite201 = {
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteInvitePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetInvitePathParameters = {
|
||||
token: string;
|
||||
};
|
||||
export type GetInvite200 = {
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AcceptInvite201 = {
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
|
||||
@@ -20,26 +20,20 @@ import { useMutation, useQuery } from 'react-query';
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
AcceptInvite201,
|
||||
ChangePasswordPathParameters,
|
||||
CreateAPIKey201,
|
||||
CreateInvite201,
|
||||
DeleteInvitePathParameters,
|
||||
DeleteUserPathParameters,
|
||||
GetInvite200,
|
||||
GetInvitePathParameters,
|
||||
GetMyUser200,
|
||||
GetResetPasswordToken200,
|
||||
GetResetPasswordTokenPathParameters,
|
||||
GetUser200,
|
||||
GetUserPathParameters,
|
||||
ListAPIKeys200,
|
||||
ListInvite200,
|
||||
ListUsers200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeAPIKeyPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableAcceptInviteDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
@@ -258,84 +252,6 @@ export const invalidateGetResetPasswordToken = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all invites
|
||||
* @summary List invites
|
||||
*/
|
||||
export const listInvite = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListInvite200>({
|
||||
url: `/api/v1/invite`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListInviteQueryKey = () => {
|
||||
return [`/api/v1/invite`] as const;
|
||||
};
|
||||
|
||||
export const getListInviteQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listInvite>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListInviteQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listInvite>>> = ({
|
||||
signal,
|
||||
}) => listInvite(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listInvite>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListInviteQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listInvite>>
|
||||
>;
|
||||
export type ListInviteQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List invites
|
||||
*/
|
||||
|
||||
export function useListInvite<
|
||||
TData = Awaited<ReturnType<typeof listInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listInvite>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListInviteQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List invites
|
||||
*/
|
||||
export const invalidateListInvite = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListInviteQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @summary Create invite
|
||||
@@ -419,257 +335,6 @@ export const useCreateInvite = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes an invite by id
|
||||
* @summary Delete invite
|
||||
*/
|
||||
export const deleteInvite = ({ id }: DeleteInvitePathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/invite/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteInviteMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteInvite'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
{ pathParams: DeleteInvitePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteInvite(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteInvite>>
|
||||
>;
|
||||
|
||||
export type DeleteInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete invite
|
||||
*/
|
||||
export const useDeleteInvite = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteInviteMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an invite by token
|
||||
* @summary Get invite
|
||||
*/
|
||||
export const getInvite = (
|
||||
{ token }: GetInvitePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetInvite200>({
|
||||
url: `/api/v1/invite/${token}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetInviteQueryKey = ({ token }: GetInvitePathParameters) => {
|
||||
return [`/api/v1/invite/${token}`] as const;
|
||||
};
|
||||
|
||||
export const getGetInviteQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetInviteQueryKey({ token });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getInvite>>> = ({
|
||||
signal,
|
||||
}) => getInvite({ token }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!token,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetInviteQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getInvite>>
|
||||
>;
|
||||
export type GetInviteQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get invite
|
||||
*/
|
||||
|
||||
export function useGetInvite<
|
||||
TData = Awaited<ReturnType<typeof getInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetInviteQueryOptions({ token }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get invite
|
||||
*/
|
||||
export const invalidateGetInvite = async (
|
||||
queryClient: QueryClient,
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetInviteQueryKey({ token }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint accepts an invite by token
|
||||
* @summary Accept invite
|
||||
*/
|
||||
export const acceptInvite = (
|
||||
typesPostableAcceptInviteDTO: BodyType<TypesPostableAcceptInviteDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<AcceptInvite201>({
|
||||
url: `/api/v1/invite/accept`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableAcceptInviteDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getAcceptInviteMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['acceptInvite'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return acceptInvite(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AcceptInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof acceptInvite>>
|
||||
>;
|
||||
export type AcceptInviteMutationBody = BodyType<TypesPostableAcceptInviteDTO>;
|
||||
export type AcceptInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Accept invite
|
||||
*/
|
||||
export const useAcceptInvite = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getAcceptInviteMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @summary Create bulk invite
|
||||
|
||||
@@ -27,22 +27,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite/bulk", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateBulkInvite), handler.OpenAPIDef{
|
||||
ID: "CreateBulkInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create bulk invite",
|
||||
Description: "This endpoint creates a bulk invite for a user",
|
||||
Request: make([]*types.PostableInvite, 0),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
if err := router.Handle("/api/v1/invite/{token}", handler.New(provider.authZ.OpenAccess(provider.userHandler.GetInvite), handler.OpenAPIDef{
|
||||
ID: "GetInvite",
|
||||
Tags: []string{"users"},
|
||||
@@ -60,6 +45,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
if err := router.Handle("/api/v1/invite/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.DeleteInvite), handler.OpenAPIDef{
|
||||
ID: "DeleteInvite",
|
||||
Tags: []string{"users"},
|
||||
@@ -77,6 +63,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
if err := router.Handle("/api/v1/invite", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListInvite), handler.OpenAPIDef{
|
||||
ID: "ListInvite",
|
||||
Tags: []string{"users"},
|
||||
@@ -94,6 +81,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
if err := router.Handle("/api/v1/invite/accept", handler.New(provider.authZ.OpenAccess(provider.userHandler.AcceptInvite), handler.OpenAPIDef{
|
||||
ID: "AcceptInvite",
|
||||
Tags: []string{"users"},
|
||||
@@ -111,6 +99,22 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite/bulk", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateBulkInvite), handler.OpenAPIDef{
|
||||
ID: "CreateBulkInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create bulk invite",
|
||||
Description: "This endpoint creates a bulk invite for a user",
|
||||
Request: make([]*types.PostableInvite, 0),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
|
||||
ID: "CreateAPIKey",
|
||||
Tags: []string{"users"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.Status == types.UserStatusDeleted })
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ func (module *getter) ListUsersByEmailAndOrgIDs(ctx context.Context, email value
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (module *getter) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
count, err := module.store.CountByOrgID(ctx, orgID)
|
||||
func (module *getter) ActiveCountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
count, err := module.store.ActiveCountByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewHandler(module root.Module, getter root.Getter) root.Handler {
|
||||
return &handler{module: module, getter: getter}
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -70,6 +71,11 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(invites) == 0 {
|
||||
render.Error(rw, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to create invite"))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, invites[0])
|
||||
}
|
||||
|
||||
@@ -104,6 +110,7 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -118,6 +125,7 @@ func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) {
|
||||
render.Success(w, http.StatusOK, invite)
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (h *handler) ListInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -137,6 +145,7 @@ func (h *handler) ListInvite(w http.ResponseWriter, r *http.Request) {
|
||||
render.Success(w, http.StatusOK, invites)
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -51,40 +51,72 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
|
||||
invite, err := m.store.GetInviteByToken(ctx, token)
|
||||
// token in this case is the reset password token
|
||||
resetPasswordToken, err := m.store.GetResetPasswordToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID)
|
||||
// get the factor password
|
||||
factorPassword, err := m.store.GetPassword(ctx, resetPasswordToken.PasswordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
factorPassword, err := types.NewFactorPassword(password, user.ID.StringValue())
|
||||
userID := valuer.MustNewUUID(factorPassword.UserID)
|
||||
|
||||
err = m.UpdatePasswordByResetPasswordToken(ctx, token, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = m.CreateUser(ctx, user, root.WithFactorPassword(factorPassword))
|
||||
// get the user
|
||||
user, err := m.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := m.DeleteInvite(ctx, invite.OrgID.String(), invite.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
|
||||
invite, err := m.store.GetInviteByToken(ctx, token)
|
||||
// token in this case is the reset password token
|
||||
resetPasswordToken, err := m.store.GetResetPasswordToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get the factor password
|
||||
factorPassword, err := m.store.GetPassword(ctx, resetPasswordToken.PasswordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get the user
|
||||
user, err := m.store.GetUser(ctx, valuer.MustNewUUID(factorPassword.UserID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// create a dummy invite obj for backward compatibility
|
||||
invite := &types.Invite{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: resetPasswordToken.PasswordID,
|
||||
},
|
||||
Name: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Token: resetPasswordToken.Token,
|
||||
Role: user.Role,
|
||||
OrgID: user.OrgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt, // dummy
|
||||
},
|
||||
}
|
||||
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
@@ -95,80 +127,174 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invites := make([]*types.Invite, 0, len(bulkInvites.Invites))
|
||||
invitedUsers := make([]*types.User, 0, len(bulkInvites.Invites))
|
||||
var invites []*types.Invite // TODO(balanikaran) remove this and more to types.User as return
|
||||
|
||||
for _, invite := range bulkInvites.Invites {
|
||||
// check if user exists
|
||||
existingUser, err := m.store.GetUserByEmailAndOrgID(ctx, invite.Email, orgID)
|
||||
// check and active user already exists with this email
|
||||
existingUser, err := m.GetNonDeletedUserByEmailAndOrgID(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")
|
||||
return nil, errors.WithAdditionalf(err, "Cannot send invite to root user")
|
||||
}
|
||||
|
||||
// check if a pending invite already exists
|
||||
if existingUser.Status == types.UserStatusPendingInvite {
|
||||
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// create a new user with pending invite status
|
||||
newUser, err := types.NewUser(invite.Name, invite.Email, role, orgID, types.UserStatusPendingInvite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newInvite.InviteLink = fmt.Sprintf("%s/signup?token=%s", invite.FrontendBaseUrl, newInvite.Token)
|
||||
invites = append(invites, newInvite)
|
||||
// store the user and password in db
|
||||
err = m.createUserWithoutGrant(ctx, newUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invitedUsers = append(invitedUsers, newUser)
|
||||
}
|
||||
|
||||
err = m.store.CreateBulkInvite(ctx, invites)
|
||||
// send password reset emails to all the invited users
|
||||
for i, invitedUser := range invitedUsers {
|
||||
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
|
||||
"invitee_email": invitedUser.Email,
|
||||
"invitee_role": invitedUser.Role,
|
||||
})
|
||||
|
||||
// generate reset password token
|
||||
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, invitedUser.ID)
|
||||
if err != nil {
|
||||
m.settings.Logger().ErrorContext(ctx, "failed to create reset password token for invited user", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this
|
||||
invite := &types.Invite{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: resetPasswordToken.PasswordID,
|
||||
},
|
||||
Name: invitedUser.DisplayName,
|
||||
Email: invitedUser.Email,
|
||||
Token: resetPasswordToken.Token,
|
||||
Role: invitedUser.Role,
|
||||
OrgID: invitedUser.OrgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: invitedUser.CreatedAt,
|
||||
UpdatedAt: invitedUser.UpdatedAt, // dummy
|
||||
},
|
||||
}
|
||||
|
||||
invites = append(invites, invite)
|
||||
|
||||
frontendBaseUrl := bulkInvites.Invites[i].FrontendBaseUrl
|
||||
if frontendBaseUrl == "" {
|
||||
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", invitedUser.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
resetLink := m.resetLink(frontendBaseUrl, resetPasswordToken.Token)
|
||||
|
||||
tokenLifetime := m.config.Password.Reset.MaxTokenLifetime
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
|
||||
if err := m.emailing.SendHTML(ctx, invitedUser.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 // TODO(balanikaran) move to types.User
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
|
||||
// find all the users with pending_invite status
|
||||
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
|
||||
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})
|
||||
pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite })
|
||||
|
||||
// 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)
|
||||
continue
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
// create a dummy invite obj for backward compatibility
|
||||
invite := &types.Invite{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: resetPasswordToken.PasswordID,
|
||||
},
|
||||
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 (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
|
||||
return m.store.ListInvite(ctx, orgID)
|
||||
}
|
||||
|
||||
// TODO(balanikaran): deprecate this one frontend changes are live with new invitation flow
|
||||
func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
return m.store.DeleteInvite(ctx, orgID, id)
|
||||
// the id in this case is the password id
|
||||
// get the factor password
|
||||
factorPassword, err := m.store.GetPassword(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get the user
|
||||
user, err := m.store.GetUser(ctx, valuer.MustNewUUID(factorPassword.UserID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// revoke grants
|
||||
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
|
||||
err = m.authz.Revoke(ctx, user.OrgID, []string{roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}, authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// hard delete the user row
|
||||
err = m.store.DeleteUser(ctx, user.OrgID.StringValue(), user.ID.StringValue())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
|
||||
@@ -300,7 +426,9 @@ 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
|
||||
user.UpdateStatus(types.UserStatusDeleted)
|
||||
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -321,6 +449,10 @@ func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID
|
||||
return nil, errors.WithAdditionalf(err, "cannot reset password for root user")
|
||||
}
|
||||
|
||||
if user.Status == types.UserStatusDeleted {
|
||||
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 +507,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 +525,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 := module.resetLink(frontendBaseURL, token.Token)
|
||||
|
||||
tokenLifetime := module.config.Password.Reset.MaxTokenLifetime
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
@@ -439,10 +571,21 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
return errors.WithAdditionalf(err, "cannot reset password for root user")
|
||||
}
|
||||
|
||||
if user.Status == types.UserStatusDeleted {
|
||||
return errors.Newf(errors.TypeNotFound, types.ErrCodeUserNotFound, "user with id %s does not exist", user.ID)
|
||||
}
|
||||
|
||||
if err := password.Update(passwd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the status of user if this a newly invited user and also grant authz
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
if err = module.activatePendingUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return module.store.UpdatePassword(ctx, password)
|
||||
}
|
||||
|
||||
@@ -477,7 +620,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 +628,14 @@ 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 {
|
||||
// activate the user
|
||||
if err = module.activatePendingUser(ctx, existingUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return existingUser, nil
|
||||
}
|
||||
|
||||
@@ -561,7 +712,7 @@ 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)
|
||||
count, err := module.store.ActiveCountByOrgID(ctx, orgID)
|
||||
if err == nil {
|
||||
stats["user.count"] = count
|
||||
}
|
||||
@@ -598,3 +749,49 @@ func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.U
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *Module) resetLink(frontendBaseUrl string, token string) string {
|
||||
return fmt.Sprintf("%s/password-reset?token=%s", frontendBaseUrl, token)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
user.UpdateStatus(types.UserStatusActive)
|
||||
err = module.store.UpdateUser(ctx, user.OrgID, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return 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.Status == types.UserStatusDeleted })
|
||||
|
||||
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())
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,21 +104,22 @@ 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) {
|
||||
@@ -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.UserStatusDeleted.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)
|
||||
@@ -565,7 +497,7 @@ func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*type
|
||||
return flattenedAPIKeys[0], nil
|
||||
}
|
||||
|
||||
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
func (store *store) ActiveCountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
|
||||
user := new(types.User)
|
||||
|
||||
count, err := store.
|
||||
@@ -574,6 +506,7 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("status = ?", types.UserStatusActive.StringValue()).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -41,10 +41,10 @@ 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)
|
||||
ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) // TODO(balanikaran) - deprecate this
|
||||
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error // TODO(balanikaran) - deprecate this
|
||||
AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) // TODO(balanikaran) - deprecate this
|
||||
GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) // TODO(balanikaran) - deprecate this
|
||||
|
||||
// API KEY
|
||||
CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error
|
||||
@@ -53,6 +53,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
|
||||
}
|
||||
|
||||
@@ -75,8 +77,8 @@ type Getter interface {
|
||||
// List users by email and org ids.
|
||||
ListUsersByEmailAndOrgIDs(context.Context, valuer.Email, []valuer.UUID) ([]*types.User, error)
|
||||
|
||||
// Count users by org id.
|
||||
CountByOrgID(context.Context, valuer.UUID) (int64, error)
|
||||
// Count active users by org id.
|
||||
ActiveCountByOrgID(context.Context, valuer.UUID) (int64, error)
|
||||
|
||||
// Get factor password by user id.
|
||||
GetFactorPasswordByUserID(context.Context, valuer.UUID) (*types.FactorPassword, error)
|
||||
@@ -85,10 +87,10 @@ type Getter interface {
|
||||
type Handler interface {
|
||||
// invite
|
||||
CreateInvite(http.ResponseWriter, *http.Request)
|
||||
AcceptInvite(http.ResponseWriter, *http.Request)
|
||||
GetInvite(http.ResponseWriter, *http.Request) // public function
|
||||
ListInvite(http.ResponseWriter, *http.Request)
|
||||
DeleteInvite(http.ResponseWriter, *http.Request)
|
||||
AcceptInvite(http.ResponseWriter, *http.Request) // TODO(balanikaran) - deprecate this
|
||||
GetInvite(http.ResponseWriter, *http.Request) // public function // TODO(balanikaran) - deprecate this
|
||||
ListInvite(http.ResponseWriter, *http.Request) // TODO(balanikaran) - deprecate this
|
||||
DeleteInvite(http.ResponseWriter, *http.Request) // TODO(balanikaran) - deprecate this
|
||||
CreateBulkInvite(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListUsers(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -253,7 +253,7 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
|
||||
}
|
||||
// if the first org with the first user is created then the setup is complete.
|
||||
if len(orgs) == 1 {
|
||||
count, err := opts.Signoz.Modules.UserGetter.CountByOrgID(context.Background(), orgs[0].ID)
|
||||
count, err := opts.Signoz.Modules.UserGetter.ActiveCountByOrgID(context.Background(), orgs[0].ID)
|
||||
if err != nil {
|
||||
zap.L().Warn("unexpected error while fetch user count while initializing base api handler", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
89
pkg/sqlmigration/067_add_status_user.go
Normal file
89
pkg/sqlmigration/067_add_status_user.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type 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
|
||||
}
|
||||
|
||||
column := &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("status"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: false,
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, column, "active")
|
||||
|
||||
// we need to drop the unique index on (email, org_id)
|
||||
indexSqls := migration.sqlschema.Operator().DropIndex(&sqlschema.UniqueIndex{TableName: "users", ColumnNames: []sqlschema.ColumnName{"email", "org_id"}})
|
||||
|
||||
sqls = append(sqls, indexSqls...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
141
pkg/sqlmigration/068_deprecate_user_invite.go
Normal file
141
pkg/sqlmigration/068_deprecate_user_invite.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"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 deprecateUserInvite struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (migration *deprecateUserInvite) Up(ctx context.Context, db *bun.DB) error {
|
||||
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
|
||||
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(),
|
||||
}
|
||||
|
||||
if _, err = tx.NewInsert().Model(user).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// finally drop the user_invite table
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("user_invite"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -14,6 +14,17 @@ var (
|
||||
ErrInviteNotFound = errors.MustNewCode("invite_not_found")
|
||||
)
|
||||
|
||||
type PostableInvite struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Role Role `json:"role"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
}
|
||||
|
||||
type PostableBulkInviteRequest struct {
|
||||
Invites []PostableInvite `json:"invites"`
|
||||
}
|
||||
|
||||
type GettableInvite = Invite
|
||||
|
||||
type Invite struct {
|
||||
@@ -46,17 +57,6 @@ type PostableAcceptInvite struct {
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
}
|
||||
|
||||
type PostableInvite struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
Role Role `json:"role"`
|
||||
FrontendBaseUrl string `json:"frontendBaseUrl"`
|
||||
}
|
||||
|
||||
type PostableBulkInviteRequest struct {
|
||||
Invites []PostableInvite `json:"invites"`
|
||||
}
|
||||
|
||||
type GettableCreateInviteResponse struct {
|
||||
InviteToken string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -23,17 +23,25 @@ var (
|
||||
ErrCodeRootUserOperationUnsupported = errors.MustNewCode("root_user_operation_unsupported")
|
||||
)
|
||||
|
||||
var (
|
||||
UserStatusPendingInvite = valuer.NewString("pending_invite")
|
||||
UserStatusActive = valuer.NewString("active")
|
||||
UserStatusDeleted = valuer.NewString("deleted")
|
||||
ValidUserStatus = []valuer.String{UserStatusPendingInvite, UserStatusActive, UserStatusDeleted}
|
||||
)
|
||||
|
||||
type GettableUser = User
|
||||
|
||||
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"`
|
||||
TimeAuditable
|
||||
}
|
||||
|
||||
@@ -45,7 +53,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")
|
||||
}
|
||||
@@ -67,6 +75,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 +101,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 +121,11 @@ func (u *User) Update(displayName string, role Role) {
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (u *User) UpdateStatus(status valuer.String) {
|
||||
u.Status = status
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// PromoteToRoot promotes the user to a root user with admin role.
|
||||
func (u *User) PromoteToRoot() {
|
||||
u.IsRoot = true
|
||||
@@ -139,6 +154,7 @@ func NewTraitsFromUser(user *User) map[string]any {
|
||||
"role": user.Role,
|
||||
"email": user.Email.String(),
|
||||
"display_name": user.DisplayName,
|
||||
"status": user.Status,
|
||||
"created_at": user.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -160,17 +176,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,7 +186,7 @@ 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)
|
||||
@@ -216,7 +221,7 @@ type UserStore interface {
|
||||
GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error)
|
||||
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
|
||||
|
||||
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
|
||||
ActiveCountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
|
||||
|
||||
// Get root user by org.
|
||||
GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*User, error)
|
||||
|
||||
@@ -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>⏱ 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>.
|
||||
|
||||
@@ -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,123 @@ 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 ADMIN role (invite role 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
|
||||
assert response.json()["data"]["status"] == "pending_invite"
|
||||
|
||||
# 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 ADMIN role from invite, not VIEWER 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"] == "ADMIN"
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{user_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# --- Step 2: Soft delete via DB (feature flag may not be enabled) ---
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE users SET status = 'deleted' WHERE id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# --- 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
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
assert found_user is not None
|
||||
assert found_user["status"] == "active"
|
||||
assert found_user["id"] != user_id # new user, different ID
|
||||
assert found_user["role"] == "VIEWER" # default role from SSO domain config
|
||||
|
||||
@@ -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,142 @@ 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 ADMIN role (invite role 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
|
||||
assert response.json()["data"]["status"] == "pending_invite"
|
||||
|
||||
# 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
|
||||
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"] == "ADMIN"
|
||||
|
||||
|
||||
def test_oidc_sso_deleted_user_blocked_and_reinvite_activates(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP,
|
||||
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 OIDC SSO lifecycle:
|
||||
1. Invite + activate a user (EDITOR)
|
||||
2. Soft delete the user
|
||||
3. SSO login attempt — user should remain deleted (blocked)
|
||||
4. Re-invite the same email as VIEWER
|
||||
5. SSO login — user should become active with VIEWER role
|
||||
"""
|
||||
email = "sso-deleted-lifecycle@oidc.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": "OIDC SSO Lifecycle User"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
user_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{user_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# --- Step 2: Soft delete via DB (feature flag may not be enabled) ---
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE users SET status = 'deleted' WHERE id = :user_id"),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# --- Step 3: SSO login should be blocked for deleted user ---
|
||||
create_user_idp(email, "password123", True, "OIDC", "Lifecycle")
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
|
||||
# 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"
|
||||
|
||||
# --- Step 4: Re-invite as VIEWER ---
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": email, "role": "VIEWER", "name": "OIDC SSO Lifecycle User v2"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert response.json()["data"]["status"] == "pending_invite"
|
||||
assert response.json()["data"]["role"] == "VIEWER"
|
||||
|
||||
# --- Step 5: SSO login should activate with new role ---
|
||||
driver.delete_all_cookies()
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -104,68 +104,76 @@ 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["status"] == "pending_invite"
|
||||
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 invited_user["role"] == "EDITOR"
|
||||
|
||||
# Register the editor user using the invite token
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
|
||||
json={
|
||||
"password": "password123Z$",
|
||||
"displayName": "editor",
|
||||
"token": f"{found_invite['token']}",
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
# Verify that the invite token has been deleted
|
||||
# Get the reset password token through admin token
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['token']}"),
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.BAD_REQUEST)
|
||||
# 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/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# 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 +194,50 @@ 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"]
|
||||
|
||||
# Get reset password token before revoking
|
||||
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"
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
None,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
# Delete the pending invite user (revoke the invite)
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/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)
|
||||
|
||||
|
||||
|
||||
@@ -22,50 +22,27 @@ def test_change_password(
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
invited_user = response.json()["data"]
|
||||
|
||||
# Get reset password 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"
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Accept the invite with a bad password which should fail
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
|
||||
json={
|
||||
"password": "password",
|
||||
"displayName": "admin password",
|
||||
"token": f"{found_invite['token']}",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Accept the invite with a good password
|
||||
# Reset password to activate user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
|
||||
json={
|
||||
"password": "password123Z$",
|
||||
"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.CREATED
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Get the user id
|
||||
response = requests.get(
|
||||
@@ -301,33 +278,25 @@ 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"]
|
||||
|
||||
# Accept the invite to create the user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
|
||||
json={
|
||||
"password": "originalPassword123Z$",
|
||||
"displayName": "forgotpassword user",
|
||||
"token": f"{found_invite['token']}",
|
||||
},
|
||||
# Activate user via reset password
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "originalPassword123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Get org ID
|
||||
response = requests.get(
|
||||
|
||||
@@ -23,20 +23,25 @@ def test_change_role(
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
invite_token = response.json()["data"]["token"]
|
||||
invited_user = response.json()["data"]
|
||||
|
||||
# Accept the invite of the new user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
|
||||
json={
|
||||
"password": "password123Z$",
|
||||
"displayName": "role change user",
|
||||
"token": f"{invite_token}",
|
||||
},
|
||||
# Activate user via reset password
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
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
|
||||
|
||||
# Make some API calls as new user
|
||||
new_user_token, new_user_refresh_token = get_tokens(
|
||||
|
||||
@@ -20,43 +20,48 @@ 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"]
|
||||
|
||||
# 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 via reset password
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert duplicate_accept_response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
116
tests/integration/src/passwordauthn/07_invite_status.py
Normal file
116
tests/integration/src/passwordauthn/07_invite_status.py
Normal file
@@ -0,0 +1,116 @@
|
||||
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 can be re-invited:
|
||||
1. Invite and activate a user
|
||||
2. Soft delete the user
|
||||
3. Re-invite the same email — should succeed and reactivate as pending_invite
|
||||
4. Reset password — user becomes active again
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create and activate a user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "reinvite@integration.test", "role": "EDITOR", "name": "reinvite user"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
invited_user = response.json()["data"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
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
|
||||
|
||||
# Soft delete the user (set status to deleted via DB since feature flag may not be enabled)
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE users SET status = 'deleted' WHERE id = :user_id"),
|
||||
{"user_id": invited_user["id"]},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Re-invite the same email — should succeed
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "reinvite@integration.test", "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["status"] == "pending_invite"
|
||||
assert reinvited_user["role"] == "VIEWER"
|
||||
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
|
||||
|
||||
# Activate via reset password
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{reinvited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "newPassword123Z$", "token": reset_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
|
||||
@@ -34,19 +34,25 @@ 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"]
|
||||
|
||||
# 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,
|
||||
# Activate user via reset password
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/getResetPasswordToken/{invited_user['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert accept_response.status_code == HTTPStatus.CREATED
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": USER_EDITOR_PASSWORD, "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Login with editor email and password
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
|
||||
Reference in New Issue
Block a user