mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-01 12:20:38 +01:00
Compare commits
3 Commits
platform-p
...
nv/layout-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d104e150e8 | ||
|
|
c16c4ab863 | ||
|
|
3b73bbc46d |
@@ -618,6 +618,13 @@ components:
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- description
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
@@ -2529,6 +2536,22 @@ components:
|
||||
- resource
|
||||
- selectors
|
||||
type: object
|
||||
CoretypesPatchableObjects:
|
||||
properties:
|
||||
additions:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
deletions:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- additions
|
||||
- deletions
|
||||
type: object
|
||||
CoretypesResourceRef:
|
||||
properties:
|
||||
kind:
|
||||
@@ -11802,6 +11825,68 @@ paths:
|
||||
summary: Get role
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: true
|
||||
description: This endpoint patches a role
|
||||
operationId: PatchRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a role
|
||||
@@ -11864,6 +11949,158 @@ paths:
|
||||
summary: Update role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Gets all objects connected to the specified role via a given relation
|
||||
type
|
||||
operationId: GetObjects
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: relation
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:read
|
||||
- tokenizer:
|
||||
- role:read
|
||||
summary: Get objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: true
|
||||
description: Patches the objects connected to the specified role via a given
|
||||
relation type
|
||||
operationId: PatchObjects
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: relation
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
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
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Patch objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
/api/v1/route_policies:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -260,6 +260,40 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableRole, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects := make([]*coretypes.Object, 0)
|
||||
for _, objectType := range provider.registry.Types() {
|
||||
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resourceObjects, err := provider.
|
||||
ListObjects(
|
||||
ctx,
|
||||
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
|
||||
relation,
|
||||
objectType,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objects = append(objects, resourceObjects...)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -290,6 +324,39 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, role)
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,13 +18,19 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPatchableRoleDTO,
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
CoretypesPatchableObjectsDTO,
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
GetObjects200,
|
||||
GetObjectsPathParameters,
|
||||
GetRole200,
|
||||
GetRolePathParameters,
|
||||
ListRoles200,
|
||||
PatchObjectsPathParameters,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateRolePathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -359,6 +365,107 @@ export const invalidateGetRole = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint patches a role
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const patchRole = (
|
||||
{ id }: PatchRolePathParameters,
|
||||
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPatchableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchRole'];
|
||||
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 patchRole>>,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchRole>>
|
||||
>;
|
||||
export type PatchRoleMutationBody =
|
||||
| BodyType<AuthtypesPatchableRoleDTO>
|
||||
| undefined;
|
||||
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const usePatchRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRolePathParameters;
|
||||
data?: BodyType<AuthtypesPatchableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a role
|
||||
* @summary Update role
|
||||
@@ -458,3 +565,205 @@ export const useUpdateRole = <
|
||||
> => {
|
||||
return useMutation(getUpdateRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Gets all objects connected to the specified role via a given relation type
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
export const getObjects = (
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetObjects200>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetObjectsQueryKey = ({
|
||||
id,
|
||||
relation,
|
||||
}: GetObjectsPathParameters) => {
|
||||
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
|
||||
};
|
||||
|
||||
export const getGetObjectsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getObjects>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getObjects>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetObjectsQueryKey({ id, relation });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getObjects>>> = ({
|
||||
signal,
|
||||
}) => getObjects({ id, relation }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && relation),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getObjects>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetObjectsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getObjects>>
|
||||
>;
|
||||
export type GetObjectsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
|
||||
export function useGetObjects<
|
||||
TData = Awaited<ReturnType<typeof getObjects>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getObjects>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetObjectsQueryOptions({ id, relation }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get objects for a role by relation
|
||||
*/
|
||||
export const invalidateGetObjects = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, relation }: GetObjectsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetObjectsQueryKey({ id, relation }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Patches the objects connected to the specified role via a given relation type
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const patchObjects = (
|
||||
{ id, relation }: PatchObjectsPathParameters,
|
||||
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: coretypesPatchableObjectsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchObjectsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchObjects'];
|
||||
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 patchObjects>>,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchObjects(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchObjectsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchObjects>>
|
||||
>;
|
||||
export type PatchObjectsMutationBody =
|
||||
| BodyType<CoretypesPatchableObjectsDTO>
|
||||
| undefined;
|
||||
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const usePatchObjects = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchObjects>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchObjectsPathParameters;
|
||||
data?: BodyType<CoretypesPatchableObjectsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchObjectsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -2230,6 +2230,13 @@ export interface AuthtypesOrgSessionContextDTO {
|
||||
warning?: ErrorsJSONDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPatchableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
@@ -3242,6 +3249,17 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesPatchableObjectsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
additions: CoretypesObjectGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
deletions: CoretypesObjectGroupDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
@@ -10230,9 +10248,31 @@ export type GetRole200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type GetObjects200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: CoretypesObjectGroupDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
};
|
||||
export type GetAllRoutePolicies200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { selectResolvedVariables } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useResolvedVariables } from '../useResolvedVariables';
|
||||
|
||||
// A text variable is the simplest envelope (no list plugin); the builder's full
|
||||
// type/value matrix is covered in buildVariablesPayload.test.ts. The envelope is
|
||||
// cast at the boundary — its kind discriminant is the literal 'TextVariable'.
|
||||
function textVariable(name: string, value: string): DashboardtypesVariableDTO {
|
||||
return {
|
||||
kind: 'TextVariable',
|
||||
spec: { name, value, display: { name } },
|
||||
} as unknown as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
id: string,
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id,
|
||||
spec: { variables },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
describe('useResolvedVariables', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore.setState({ variableValues: {}, resolvedVariables: {} });
|
||||
});
|
||||
|
||||
it('publishes the resolved V5 payload for the dashboard to the store', () => {
|
||||
renderHook(() =>
|
||||
useResolvedVariables(dashboard('d1', [textVariable('env', 'prod')])),
|
||||
);
|
||||
|
||||
expect(
|
||||
selectResolvedVariables('d1')(useDashboardStore.getState()),
|
||||
).toStrictEqual({ env: { type: 'text', value: 'prod' } });
|
||||
});
|
||||
|
||||
it('reflects the runtime selection over the configured default', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d2', { env: { value: 'staging', allSelected: false } });
|
||||
|
||||
renderHook(() =>
|
||||
useResolvedVariables(dashboard('d2', [textVariable('env', 'prod')])),
|
||||
);
|
||||
|
||||
expect(
|
||||
selectResolvedVariables('d2')(useDashboardStore.getState()),
|
||||
).toStrictEqual({ env: { type: 'text', value: 'staging' } });
|
||||
});
|
||||
|
||||
it('publishes an empty payload when the dashboard has no variables', () => {
|
||||
renderHook(() => useResolvedVariables(dashboard('d3', [])));
|
||||
|
||||
expect(
|
||||
selectResolvedVariables('d3')(useDashboardStore.getState()),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,6 @@ import type { PanelPagination, PanelQueryData } from '../queryV5/types';
|
||||
import { getRawResults } from '../queryV5/v5ResponseData';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
|
||||
@@ -67,9 +65,8 @@ export interface UsePanelQueryResult {
|
||||
/**
|
||||
* Fetches query-range data for a V2 panel over the pure-V5 contract: builds the request DTO
|
||||
* from the panel's perses queries (no V1 `Query` intermediary), reads global time from Redux,
|
||||
* substitutes the dashboard's resolved variable values (published to the store by
|
||||
* `useResolvedVariables`), and posts via `useGetQueryRangeV5`. Renderers consume the raw
|
||||
* response through the `queryV5` prep utils.
|
||||
* and posts via `useGetQueryRangeV5`. Variable substitution is deferred until V2 has its own
|
||||
* variable plumbing. Renderers consume the raw response through the `queryV5` prep utils.
|
||||
*/
|
||||
export function usePanelQuery({
|
||||
panel,
|
||||
@@ -108,11 +105,6 @@ export function usePanelQuery({
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Resolved variable values for this dashboard, published by useResolvedVariables.
|
||||
// Substituted into the request and keyed into the cache so a selection change refetches.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
|
||||
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
|
||||
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
|
||||
const pluginSpec = panel.spec.plugin.spec;
|
||||
@@ -149,19 +141,8 @@ export function usePanelQuery({
|
||||
endMs,
|
||||
fillGaps,
|
||||
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
|
||||
variables,
|
||||
}),
|
||||
[
|
||||
queries,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
fillGaps,
|
||||
isPaginated,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
],
|
||||
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
|
||||
@@ -186,8 +167,6 @@ export function usePanelQuery({
|
||||
// Each page is its own cache entry (0/default for non-paged kinds).
|
||||
offset,
|
||||
pageSize,
|
||||
// Variable selection changes the request, so it must re-key the cache (refetch).
|
||||
variables,
|
||||
],
|
||||
[
|
||||
panelId,
|
||||
@@ -203,7 +182,6 @@ export function usePanelQuery({
|
||||
queries,
|
||||
offset,
|
||||
pageSize,
|
||||
variables,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import { buildVariablesPayload } from '../queryV5/buildVariablesPayload';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Resolves the dashboard's variable selection into the V5 query payload and
|
||||
* publishes it to the store, so `usePanelQuery` reads it by dashboardId without
|
||||
* the spec being threaded through the panel tree (the `setEditContext` pattern).
|
||||
*
|
||||
* Definitions come from the spec; values come from the runtime selection (seeded
|
||||
* by the variable bar). Re-publishes whenever either changes, which re-keys the
|
||||
* panel queries and triggers a refetch with the new values.
|
||||
*/
|
||||
export function useResolvedVariables(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): void {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const definitions = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setResolvedVariables = useDashboardStore((s) => s.setResolvedVariables);
|
||||
|
||||
const resolved = useMemo(
|
||||
() => buildVariablesPayload(definitions, selection),
|
||||
[definitions, selection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
setResolvedVariables(dashboardId, resolved);
|
||||
}, [dashboardId, resolved, setResolvedVariables]);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
@@ -51,10 +50,6 @@ function DashboardContainer({
|
||||
setEditContext,
|
||||
]);
|
||||
|
||||
// Resolve the variable selection into the V5 query payload and publish it to
|
||||
// the store, so each panel's query substitutes the bar's selected values.
|
||||
useResolvedVariables(dashboard);
|
||||
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
|
||||
import { buildVariablesPayload } from '../buildVariablesPayload';
|
||||
|
||||
function variable(
|
||||
name: string,
|
||||
type: VariableType,
|
||||
overrides: Partial<VariableFormModel> = {},
|
||||
): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name, type, ...overrides };
|
||||
}
|
||||
|
||||
describe('buildVariablesPayload', () => {
|
||||
it('returns an empty map when there are no definitions', () => {
|
||||
expect(buildVariablesPayload([], {})).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('maps each UI variable type to its V5 wire type', () => {
|
||||
const definitions = [
|
||||
variable('q', 'QUERY'),
|
||||
variable('c', 'CUSTOM'),
|
||||
variable('t', 'TEXT'),
|
||||
variable('d', 'DYNAMIC'),
|
||||
];
|
||||
const selection: VariableSelectionMap = {
|
||||
q: { value: 'a', allSelected: false },
|
||||
c: { value: 'b', allSelected: false },
|
||||
t: { value: 'c', allSelected: false },
|
||||
d: { value: 'e', allSelected: false },
|
||||
};
|
||||
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
|
||||
q: { type: 'query', value: 'a' },
|
||||
c: { type: 'custom', value: 'b' },
|
||||
t: { type: 'text', value: 'c' },
|
||||
d: { type: 'dynamic', value: 'e' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a multi-select array value through verbatim', () => {
|
||||
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
|
||||
const selection: VariableSelectionMap = {
|
||||
svc: { value: ['a', 'b'], allSelected: false },
|
||||
};
|
||||
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
|
||||
svc: { type: 'query', value: ['a', 'b'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a multi-select dynamic ALL selection to the __all__ sentinel', () => {
|
||||
const definitions = [variable('pod', 'DYNAMIC', { multiSelect: true })];
|
||||
const selection: VariableSelectionMap = {
|
||||
pod: { value: null, allSelected: true },
|
||||
};
|
||||
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
|
||||
pod: { type: 'dynamic', value: '__all__' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT collapse a query ALL selection — it sends the full value array', () => {
|
||||
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
|
||||
const selection: VariableSelectionMap = {
|
||||
svc: { value: ['a', 'b'], allSelected: true },
|
||||
};
|
||||
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
|
||||
svc: { type: 'query', value: ['a', 'b'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a text variable configured value when unselected', () => {
|
||||
const definitions = [variable('env', 'TEXT', { textValue: 'prod' })];
|
||||
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
|
||||
env: { type: 'text', value: 'prod' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a list variable configured default when unselected', () => {
|
||||
const definitions = [
|
||||
variable('region', 'QUERY', {
|
||||
defaultValue: { value: 'us-east' },
|
||||
} as unknown as Partial<VariableFormModel>),
|
||||
];
|
||||
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
|
||||
region: { type: 'query', value: 'us-east' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits a variable with no selection and no default', () => {
|
||||
const definitions = [variable('q', 'QUERY')];
|
||||
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('omits an unnamed variable', () => {
|
||||
const definitions = [variable('', 'QUERY')];
|
||||
const selection: VariableSelectionMap = {
|
||||
'': { value: 'x', allSelected: false },
|
||||
};
|
||||
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
@@ -203,13 +202,11 @@ export interface BuildQueryRangeRequestArgs {
|
||||
fillGaps?: boolean;
|
||||
/** Server-side paging for raw/list panels, written onto the builder queries' `offset`/`limit`. */
|
||||
pagination?: { offset: number; limit: number };
|
||||
/** Runtime variable values (name → {type,value}) substituted server-side; built by `buildVariablesPayload`. */
|
||||
variables?: Querybuildertypesv5QueryRangeRequestDTOVariables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the V5 query-range request DTO directly from the panel's perses queries (no V1 `Query`
|
||||
* intermediary). `variables` carries the runtime selection (empty when the dashboard has none).
|
||||
* intermediary). Variables are absent (`variables: {}`) until V2 grows its own variable plumbing.
|
||||
*/
|
||||
export function buildQueryRangeRequest({
|
||||
queries,
|
||||
@@ -218,7 +215,6 @@ export function buildQueryRangeRequest({
|
||||
endMs,
|
||||
fillGaps = false,
|
||||
pagination,
|
||||
variables = {},
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (panelType === PANEL_TYPES.BAR) {
|
||||
@@ -238,7 +234,7 @@ export function buildQueryRangeRequest({
|
||||
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
|
||||
fillGaps,
|
||||
},
|
||||
variables,
|
||||
variables: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
Querybuildertypesv5VariableItemDTOValue,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../VariablesBar/selectionTypes';
|
||||
|
||||
/**
|
||||
* Backend sentinel for "every value selected" on a multi-select dynamic variable.
|
||||
* V1 parity (`getDashboardVariables`): only dynamic vars collapse to `__all__`;
|
||||
* query/custom multi-selects send the full value array instead. Lowercase — the
|
||||
* URL/store `__ALL__` sentinel is a separate serialization concern.
|
||||
*/
|
||||
const ALL_VALUES_SENTINEL = '__all__';
|
||||
|
||||
/** UI variable grouping → the V5 wire `variables[].type`. */
|
||||
const VARIABLE_TYPE_TO_DTO: Record<
|
||||
VariableType,
|
||||
Querybuildertypesv5VariableTypeDTO
|
||||
> = {
|
||||
QUERY: Querybuildertypesv5VariableTypeDTO.query,
|
||||
CUSTOM: Querybuildertypesv5VariableTypeDTO.custom,
|
||||
TEXT: Querybuildertypesv5VariableTypeDTO.text,
|
||||
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
|
||||
};
|
||||
|
||||
/** The variable's configured default, used when nothing is selected yet. */
|
||||
function configuredDefault(
|
||||
definition: VariableFormModel,
|
||||
): SelectedVariableValue | undefined {
|
||||
if (definition.type === 'TEXT') {
|
||||
return definition.textValue || undefined;
|
||||
}
|
||||
return (
|
||||
definition.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
|
||||
* user's selection, else the configured default. Returns `undefined` when there
|
||||
* is nothing meaningful to send (the variable is then omitted from the payload).
|
||||
*/
|
||||
function resolveValue(
|
||||
definition: VariableFormModel,
|
||||
selection: VariableSelection | undefined,
|
||||
): Querybuildertypesv5VariableItemDTOValue | undefined {
|
||||
if (
|
||||
definition.type === 'DYNAMIC' &&
|
||||
definition.multiSelect &&
|
||||
selection?.allSelected
|
||||
) {
|
||||
return ALL_VALUES_SENTINEL;
|
||||
}
|
||||
|
||||
const selected = selection?.value;
|
||||
const hasSelection =
|
||||
selected !== null &&
|
||||
selected !== undefined &&
|
||||
!(typeof selected === 'string' && selected === '');
|
||||
if (hasSelection) {
|
||||
return selected as Querybuildertypesv5VariableItemDTOValue;
|
||||
}
|
||||
|
||||
const fallback = configuredDefault(definition);
|
||||
return fallback == null
|
||||
? undefined
|
||||
: (fallback as Querybuildertypesv5VariableItemDTOValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the V5 `variables` map from the dashboard's variable definitions and the
|
||||
* runtime selection, so a panel query substitutes the values the user picked in
|
||||
* the variable bar (V1 parity with `getDashboardVariables` + the V5 prep). The
|
||||
* definition list supplies the wire `type` (the selection map carries only values).
|
||||
*/
|
||||
export function buildVariablesPayload(
|
||||
definitions: VariableFormModel[],
|
||||
selection: VariableSelectionMap,
|
||||
): Querybuildertypesv5QueryRangeRequestDTOVariables {
|
||||
const payload: Querybuildertypesv5QueryRangeRequestDTOVariables = {};
|
||||
definitions.forEach((definition) => {
|
||||
if (!definition.name) {
|
||||
return;
|
||||
}
|
||||
const value = resolveValue(definition, selection[definition.name]);
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
payload[definition.name] = {
|
||||
type: VARIABLE_TYPE_TO_DTO[definition.type],
|
||||
value,
|
||||
};
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
@@ -13,19 +12,9 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*
|
||||
* `resolvedVariables` is the same selection resolved into the V5 query payload
|
||||
* shape (`{ name: { type, value } }`), published by `useResolvedVariables` so
|
||||
* `usePanelQuery` reads it without threading the dashboard spec down the tree
|
||||
* (the edit-context publish pattern). Transient — not persisted (it is derived
|
||||
* from `variableValues` + the spec on every load).
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
resolvedVariables: Record<
|
||||
string,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables
|
||||
>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
@@ -33,11 +22,6 @@ export interface VariableSelectionSlice {
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
|
||||
/** Publish the resolved V5 variables payload for a dashboard. */
|
||||
setResolvedVariables: (
|
||||
dashboardId: string,
|
||||
variables: Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
@@ -47,7 +31,6 @@ export const createVariableSelectionSlice: StateCreator<
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
resolvedVariables: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
@@ -63,12 +46,6 @@ export const createVariableSelectionSlice: StateCreator<
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
setResolvedVariables: (dashboardId, variables): void => {
|
||||
const { resolvedVariables } = get();
|
||||
set({
|
||||
resolvedVariables: { ...resolvedVariables, [dashboardId]: variables },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -83,13 +60,3 @@ export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
|
||||
|
||||
/** Stable empty payload — same rationale as {@link EMPTY_SELECTION_MAP}. */
|
||||
const EMPTY_RESOLVED_VARIABLES: Querybuildertypesv5QueryRangeRequestDTOVariables =
|
||||
{};
|
||||
|
||||
/** Selector: the resolved V5 variables payload for a dashboard (empty if none). */
|
||||
export const selectResolvedVariables =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): Querybuildertypesv5QueryRangeRequestDTOVariables =>
|
||||
state.resolvedVariables[dashboardId] ?? EMPTY_RESOLVED_VARIABLES;
|
||||
|
||||
@@ -145,5 +145,86 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get objects for a role by relation",
|
||||
Description: "Gets all objects connected to the specified role via a given relation type",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*coretypes.ObjectGroup, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch role",
|
||||
Description: "This endpoint patches a role",
|
||||
Request: new(authtypes.PatchableRole),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch objects for a role by relation",
|
||||
Description: "Patches the objects connected to the specified role via a given relation type",
|
||||
Request: new(coretypes.PatchableObjects),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ type AuthZ interface {
|
||||
// Gets the role if it exists or creates one.
|
||||
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
|
||||
|
||||
// Gets the objects associated with the given role and relation.
|
||||
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*coretypes.Object, error)
|
||||
|
||||
// Patches the role.
|
||||
Patch(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Patches the objects in authorization server associated with the given role and relation
|
||||
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
|
||||
|
||||
// Updates the role's metadata and reconciles its transaction groups.
|
||||
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
|
||||
@@ -93,8 +102,14 @@ type Handler interface {
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetObjects(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Patch(http.ResponseWriter, *http.Request)
|
||||
|
||||
PatchObjects(http.ResponseWriter, *http.Request)
|
||||
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
Check(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -189,10 +189,22 @@ func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *autht
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) PatchObjects(_ context.Context, _ valuer.UUID, _ string, _ authtypes.Relation, _, _ []*coretypes.Object) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(_ context.Context, _ valuer.UUID, _ valuer.UUID) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,46 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
|
||||
}
|
||||
|
||||
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
|
||||
return
|
||||
}
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relationStr, ok := mux.Vars(r)["relation"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
|
||||
return
|
||||
}
|
||||
|
||||
relation, err := coretypes.NewVerb(relationStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
objects, err := handler.authz.GetObjects(ctx, valuer.MustNewUUID(claims.OrgID), roleID, authtypes.Relation{Verb: relation})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, coretypes.NewObjectGroupsFromObjects(objects))
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -91,6 +131,99 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, roles)
|
||||
}
|
||||
|
||||
func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(authtypes.PatchableRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = role.PatchMetadata(req.Description)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.Patch(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relation, err := coretypes.NewVerb(mux.Vars(r)["relation"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := role.ErrIfManaged(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(coretypes.PatchableObjects)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
additions, deletions, err := coretypes.NewPatchableObjects(req.Additions, req.Deletions, relation)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, authtypes.Relation{Verb: relation}, additions, deletions)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
|
||||
@@ -217,7 +217,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
|
||||
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type removeOrganizationTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewRemoveOrganizationTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("remove_organization_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &removeOrganizationTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *removeOrganizationTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *removeOrganizationTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM tuple WHERE store = ? AND object_type = ?`, storeID, "organization"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM changelog WHERE store = ? AND object_type = ?`, storeID, "organization"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *removeOrganizationTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeRoleInvalidInput = errors.MustNewCode("role_invalid_input")
|
||||
ErrCodeRoleEmptyPatch = errors.MustNewCode("role_empty_patch")
|
||||
ErrCodeInvalidTypeRelation = errors.MustNewCode("role_invalid_type_relation")
|
||||
ErrCodeRoleNotFound = errors.MustNewCode("role_not_found")
|
||||
ErrCodeRoleAlreadyExists = errors.MustNewCode("role_already_exists")
|
||||
@@ -88,6 +90,10 @@ type UpdatableRole struct {
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type PatchableRole struct {
|
||||
Description string `json:"description" required:"true"`
|
||||
}
|
||||
|
||||
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID) *Role {
|
||||
return &Role{
|
||||
Identifiable: types.Identifiable{
|
||||
@@ -144,6 +150,17 @@ func NewStatsFromRoles(roles []*Role) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (role *Role) PatchMetadata(description string) error {
|
||||
err := role.ErrIfManaged()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role.Description = description
|
||||
role.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
|
||||
err := role.ErrIfManaged()
|
||||
if err != nil {
|
||||
@@ -230,6 +247,73 @@ func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (role *PatchableRole) UnmarshalJSON(data []byte) error {
|
||||
type shadowPatchableRole struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var shadowRole shadowPatchableRole
|
||||
if err := json.Unmarshal(data, &shadowRole); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shadowRole.Description == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, description must be present")
|
||||
}
|
||||
|
||||
role.Description = shadowRole.Description
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAdditionTuples(name string, orgID valuer.UUID, relation Relation, additions []*coretypes.Object) ([]*openfgav1.TupleKey, error) {
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
|
||||
for _, object := range additions {
|
||||
resource := coretypes.MustNewResourceFromTypeAndKind(object.Resource.Type, object.Resource.Kind)
|
||||
transactionTuples := NewTuples(
|
||||
resource,
|
||||
MustNewSubject(
|
||||
coretypes.NewResourceRole(),
|
||||
name,
|
||||
orgID,
|
||||
&coretypes.VerbAssignee,
|
||||
),
|
||||
relation,
|
||||
[]coretypes.Selector{object.Selector},
|
||||
orgID,
|
||||
)
|
||||
|
||||
tuples = append(tuples, transactionTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func GetDeletionTuples(name string, orgID valuer.UUID, relation Relation, deletions []*coretypes.Object) ([]*openfgav1.TupleKey, error) {
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
|
||||
for _, object := range deletions {
|
||||
resource := coretypes.MustNewResourceFromTypeAndKind(object.Resource.Type, object.Resource.Kind)
|
||||
transactionTuples := NewTuples(
|
||||
resource,
|
||||
MustNewSubject(
|
||||
coretypes.NewResourceRole(),
|
||||
name,
|
||||
orgID,
|
||||
&coretypes.VerbAssignee,
|
||||
),
|
||||
relation,
|
||||
[]coretypes.Selector{object.Selector},
|
||||
orgID,
|
||||
)
|
||||
|
||||
tuples = append(tuples, transactionTuples...)
|
||||
}
|
||||
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func MustGetSigNozManagedRoleFromExistingRole(role types.Role) string {
|
||||
managedRole, ok := ExistingRoleToSigNozManagedRoleMap[role]
|
||||
if !ok {
|
||||
|
||||
@@ -46,33 +46,18 @@ func MustNewObject(resource ResourceRef, inputSelector string) *Object {
|
||||
}
|
||||
|
||||
func MustNewObjectFromString(input string) *Object {
|
||||
typeParts := strings.SplitN(input, ":", 2)
|
||||
if len(typeParts) != 2 {
|
||||
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid type format: %s", input))
|
||||
}
|
||||
|
||||
typed := MustNewType(typeParts[0])
|
||||
|
||||
// The organization resource is the root entity and encodes its object as
|
||||
// "organization:organization/<selector>" — without the orgID and kind
|
||||
// segments used by every other resource ("<type>:organization/<orgID>/<kind>/<selector>").
|
||||
if typed.Equals(TypeOrganization) {
|
||||
orgParts := strings.Split(typeParts[1], "/")
|
||||
if len(orgParts) != 2 {
|
||||
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid input format: %s", input))
|
||||
}
|
||||
|
||||
resource := ResourceRef{Type: typed, Kind: MustNewKind(orgParts[0])}
|
||||
return &Object{Resource: resource, Selector: typed.MustSelector(orgParts[1])}
|
||||
}
|
||||
|
||||
parts := strings.Split(input, "/")
|
||||
if len(parts) != 4 {
|
||||
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid input format: %s", input))
|
||||
}
|
||||
|
||||
typeParts := strings.Split(parts[0], ":")
|
||||
if len(typeParts) != 2 {
|
||||
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid type format: %s", parts[0]))
|
||||
}
|
||||
|
||||
resource := ResourceRef{
|
||||
Type: typed,
|
||||
Type: MustNewType(typeParts[0]),
|
||||
Kind: MustNewKind(parts[2]),
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSubscription}, WildCardSelectorString)},
|
||||
// organization — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeOrganization, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeOrganization, Kind: KindOrganization}, WildCardSelectorString)},
|
||||
// org-preference — admin only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindOrgPreference}, WildCardSelectorString)},
|
||||
|
||||
@@ -138,14 +138,33 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
const maxLayoutsPerDashboard = 500
|
||||
|
||||
// validateLayouts validates the dashboard's layouts: bounded section count,
|
||||
// per-item geometry, resolvable panel references, and no panel placed twice.
|
||||
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
|
||||
// runs here so its errors can name the layout by index.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
if len(d.Layouts) > maxLayoutsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
|
||||
}
|
||||
|
||||
// Could enforce this but skipping for now: panels in no grid item (orphans)
|
||||
// are allowed.
|
||||
|
||||
// The frontend keys each grid item by its panel id, so placing one panel in
|
||||
// two grid items collides; reject duplicate references dashboard-wide. Maps
|
||||
// each referenced panel key to the path of the item that first placed it.
|
||||
referencedPanels := make(map[string]string, len(d.Panels))
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
return err
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
@@ -158,6 +177,10 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
if firstPath, dup := referencedPanels[key]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
|
||||
}
|
||||
referencedPanels[key] = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -299,19 +299,22 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
|
||||
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
|
||||
// p2 fills the right half of row 0, so p1 can only move to a fresh row
|
||||
// without tripping overlap validation.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// The first item used to live at x=0, now lives at x=6.
|
||||
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
// The first item used to live at y=0, now lives at y=6.
|
||||
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
assert.Contains(t, raw, `"width":3`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
@@ -321,11 +324,12 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
}]`).Apply(base)
|
||||
// Appending needs a not-yet-placed panel, so add one in the same patch;
|
||||
// re-placing p1 or p2 would be a duplicate reference.
|
||||
out, err := decode(t, `[
|
||||
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
|
||||
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
@@ -1442,3 +1443,123 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridGeometry(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
items []dashboard.GridItem
|
||||
expectErrContain string
|
||||
}{
|
||||
{
|
||||
scenario: "valid side-by-side items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "valid full-width item",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "stacked items do not overlap",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "zero width",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
|
||||
expectErrContain: "width must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "zero height",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
|
||||
expectErrContain: "height must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "negative x",
|
||||
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "negative y",
|
||||
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
|
||||
expectErrContain: "y must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "width wider than grid",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
|
||||
expectErrContain: "width (13) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x at grid width",
|
||||
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
|
||||
expectErrContain: "x (12) must be less than grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x plus width overflows grid",
|
||||
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x (8) + width (6) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "overlapping items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
|
||||
expectErrContain: "items[0] and items[1] overlap",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
|
||||
if test.expectErrContain == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), test.expectErrContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridItemLimit(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "maximum is")
|
||||
}
|
||||
|
||||
// Both panel refs are valid, so this errors only if geometry validation runs on
|
||||
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
|
||||
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
|
||||
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
|
||||
]}}]
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "overlap")
|
||||
}
|
||||
|
||||
// The frontend keys each grid item by its panel id, so the same panel placed by
|
||||
// two grid items crashes the section; the backend rejects it dashboard-wide. The
|
||||
// two items are side by side so they clear the overlap check first.
|
||||
func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
]}}]
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "already placed")
|
||||
// Both offending grid items are named.
|
||||
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
|
||||
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
@@ -322,6 +322,55 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
gridColumnCount = 12
|
||||
maxItemsPerGridLayout = 100
|
||||
)
|
||||
|
||||
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
|
||||
// position, and intra-section overlap), which Perses does not. It reads only the
|
||||
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
|
||||
// solely to name the layout in error paths.
|
||||
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
|
||||
if len(spec.Items) > maxItemsPerGridLayout {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
|
||||
}
|
||||
for i, item := range spec.Items {
|
||||
// The width/x bounds keep x+width small enough not to overflow.
|
||||
switch {
|
||||
case item.Width < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
|
||||
case item.Height < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
|
||||
case item.X < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
|
||||
case item.Y < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
|
||||
case item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
|
||||
case item.X >= gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
|
||||
case item.X+item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
|
||||
}
|
||||
// Could cap y/height but skipping for now: the grid grows vertically
|
||||
// without limit (frontend autoSize), so "too big" has no natural bound.
|
||||
}
|
||||
// Two items overlap iff their rectangles intersect on both axes.
|
||||
overlap := func(a, b dashboard.GridItem) bool {
|
||||
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
|
||||
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
|
||||
}
|
||||
for i := 0; i < len(spec.Items); i++ {
|
||||
for j := i + 1; j < len(spec.Items); j++ {
|
||||
if overlap(spec.Items[i], spec.Items[j]) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Layout) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
|
||||
|
||||
100
tests/fixtures/role.py
vendored
100
tests/fixtures/role.py
vendored
@@ -1,56 +1,76 @@
|
||||
"""Fixtures and data helpers for role tests: role lookup, request-body builder, grant comparison, and the golden managed-role matrix."""
|
||||
"""Fixtures and helpers for role tests."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ROLES_BASE = "/api/v1/roles"
|
||||
|
||||
|
||||
@pytest.fixture(name="find_role_id", scope="function")
|
||||
def find_role_id(signoz: types.SigNoz) -> Callable[[str, str], str]:
|
||||
def _find(token: str, name: str) -> str:
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
|
||||
|
||||
return _find
|
||||
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
"""Find a role by name from the roles endpoint and return its UUID."""
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
roles = resp.json()["data"]
|
||||
role = next(r for r in roles if r["name"] == name)
|
||||
return role["id"]
|
||||
|
||||
|
||||
def transaction_group(relation: str, type_name: str, kind_name: str, selectors: list[str]) -> dict:
|
||||
return {"relation": relation, "objectGroup": {"resource": {"type": type_name, "kind": kind_name}, "selectors": selectors}}
|
||||
def create_custom_role(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
"""Create a custom role and return its ID. transactionGroups is required (send [] for none)."""
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": name, "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
return resp.json()["data"]["id"]
|
||||
|
||||
|
||||
def flatten_transaction_groups(groups: list[dict]) -> set[tuple[str, str, str, str]]:
|
||||
flat: set[tuple[str, str, str, str]] = set()
|
||||
for group in groups or []:
|
||||
resource = group["objectGroup"]["resource"]
|
||||
for selector in group["objectGroup"]["selectors"]:
|
||||
flat.add((group["relation"], resource["type"], resource["kind"], selector))
|
||||
return flat
|
||||
def delete_custom_role(signoz: types.SigNoz, token: str, role_id: str) -> None:
|
||||
"""Delete a custom role."""
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def load_managed_role_grants() -> dict[str, list[dict]]:
|
||||
with open(get_testdata_file_path("role/managed_role_grants.json"), encoding="utf-8") as file:
|
||||
raw = json.load(file)
|
||||
return {name: grants for name, grants in raw.items() if not name.startswith("_")}
|
||||
def patch_role_objects(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
role_id: str,
|
||||
relation: str,
|
||||
additions=None,
|
||||
deletions=None,
|
||||
) -> None:
|
||||
"""PATCH /api/v1/roles/{id}/relations/{relation}/objects."""
|
||||
body = {}
|
||||
if additions is not None:
|
||||
body["additions"] = additions
|
||||
if deletions is not None:
|
||||
body["deletions"] = deletions
|
||||
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{role_id}/relations/{relation}/objects"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"PatchObjects {relation} failed: {resp.text}"
|
||||
|
||||
|
||||
def managed_role_names() -> set[str]:
|
||||
return set(load_managed_role_grants().keys())
|
||||
|
||||
|
||||
def expected_managed_grant_keys(role_name: str) -> set[tuple[str, str, str, str]]:
|
||||
keys: set[tuple[str, str, str, str]] = set()
|
||||
for grant in load_managed_role_grants()[role_name]:
|
||||
for verb in grant["verbs"]:
|
||||
keys.add((verb, grant["type"], grant["kind"], "*"))
|
||||
return keys
|
||||
def object_group(type_name: str, kind_name: str, selectors: list[str]) -> dict:
|
||||
"""Build an ObjectGroup dict for PatchObjects."""
|
||||
return {"resource": {"type": type_name, "kind": kind_name}, "selectors": selectors}
|
||||
|
||||
11
tests/fixtures/serviceaccount.py
vendored
11
tests/fixtures/serviceaccount.py
vendored
@@ -6,22 +6,13 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.role import ROLES_BASE, find_role_by_name # noqa: F401 — re-export for existing callers
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
SERVICE_ACCOUNT_BASE = "/api/v1/service_accounts"
|
||||
|
||||
|
||||
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
|
||||
|
||||
|
||||
def create_service_account(signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer") -> str:
|
||||
"""Create a service account, assign a role, and return its ID."""
|
||||
resp = requests.post(
|
||||
|
||||
@@ -1,704 +0,0 @@
|
||||
{
|
||||
"signoz-admin": [
|
||||
{
|
||||
"type": "role",
|
||||
"kind": "role",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"kind": "user",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "serviceaccount",
|
||||
"kind": "serviceaccount",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "auth-domain",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "cloud-integration",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "cloud-integration-service",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "integration",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "factor-api-key",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "factor-password",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "license",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "subscription",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "org-preference",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "public-dashboard",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "session",
|
||||
"verbs": [
|
||||
"read",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "dashboard",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "pipeline",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "planned-maintenance",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "rule",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "saved-view",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "trace-funnel",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ingestion-key",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ingestion-limit",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "notification-channel",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "route-policy",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "apdex-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "quick-filter",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ttl-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "logs",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "traces",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "metrics",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "audit-logs",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "meter-metrics",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "logs-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "traces-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"signoz-editor": [
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "dashboard",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "pipeline",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "planned-maintenance",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "rule",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "saved-view",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "trace-funnel",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "integration",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ingestion-key",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ingestion-limit",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "notification-channel",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "route-policy",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "apdex-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "quick-filter",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ttl-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "logs",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "traces",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "metrics",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "logs-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "traces-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"signoz-viewer": [
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "dashboard",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "pipeline",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "planned-maintenance",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "rule",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "saved-view",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "trace-funnel",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "integration",
|
||||
"verbs": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "notification-channel",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "route-policy",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "apdex-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "quick-filter",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "ttl-setting",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
"verbs": [
|
||||
"read",
|
||||
"update",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "logs",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "traces",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "telemetryresource",
|
||||
"kind": "metrics",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "logs-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "traces-field",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"signoz-anonymous": [
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "public-dashboard",
|
||||
"verbs": [
|
||||
"read"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -173,6 +173,125 @@ def test_create_rejects_too_many_tags(
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_invalid_grid_layout(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def panel(name: str) -> dict:
|
||||
return {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": name},
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Two grid items reference valid, distinct panels but share cells, so the
|
||||
# overlap is the only violation.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-overlap",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Overlap"},
|
||||
"panels": {"p1": panel("P1"), "p2": panel("P2")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "overlap" in response.json()["error"]["message"]
|
||||
|
||||
# One panel placed by two grid items (side by side, so they clear the overlap
|
||||
# check first). The frontend keys grid items by panel id, so this is rejected.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-multiref",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Multiref"},
|
||||
"panels": {"p1": panel("P1")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "already placed" in response.json()["error"]["message"]
|
||||
|
||||
# More grid items than allowed. The item-count check runs before the
|
||||
# panel-ref check, so content-less items suffice here.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-too-many-items",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Too Many"},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "maximum" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
|
||||
@@ -3,15 +3,13 @@ from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.role import (
|
||||
expected_managed_grant_keys,
|
||||
flatten_transaction_groups,
|
||||
managed_role_names,
|
||||
)
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
ANONYMOUS_USER_ID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
def test_managed_roles_create_on_register(
|
||||
signoz: SigNoz,
|
||||
@@ -20,41 +18,135 @@ def test_managed_roles_create_on_register(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# get the list of all roles.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
data = response.json()["data"]
|
||||
|
||||
# since this check happens immediately post registeration, all the managed roles should be present.
|
||||
assert len(data) == 4
|
||||
assert {role["name"] for role in data} == managed_role_names()
|
||||
for role in data:
|
||||
assert role["type"] == "managed"
|
||||
role_names = {role["name"] for role in data}
|
||||
expected_names = {
|
||||
"signoz-admin",
|
||||
"signoz-viewer",
|
||||
"signoz-editor",
|
||||
"signoz-anonymous",
|
||||
}
|
||||
# do the set mapping as this is order insensitive, direct list match is order-sensitive.
|
||||
assert set(role_names) == expected_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role_name", managed_role_names())
|
||||
def test_managed_role_grants_match_expected(
|
||||
def test_root_user_signoz_admin_assignment(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
role_name: str,
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, role_name)
|
||||
|
||||
# Get the user from the v2 /users/me endpoint and extract the id
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
role = response.json()["data"]
|
||||
assert role["type"] == "managed"
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
user_data = response.json()["data"]
|
||||
user_id = user_data["id"]
|
||||
|
||||
actual = flatten_transaction_groups(role.get("transactionGroups") or [])
|
||||
expected = expected_managed_grant_keys(role_name)
|
||||
assert actual == expected, f"{role_name} grants mismatch:\n missing={expected - actual}\n unexpected={actual - expected}"
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
# this validates to some extent that the role assignment is complete under the assumption that middleware is functioning as expected.
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
# Loop over the roles and get the org_id and id for signoz-admin role
|
||||
roles = response.json()["data"]
|
||||
admin_role_entry = next((role for role in roles if role["name"] == "signoz-admin"), None)
|
||||
assert admin_role_entry is not None
|
||||
org_id = admin_role_entry["orgId"]
|
||||
|
||||
# to be super sure of authorization server, let's validate the tuples in DB as well.
|
||||
# todo[@vikrantgupta25]: replace this with role memebers handler once built.
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
# verify the entry present for role assignment
|
||||
tuple_object_id = f"organization/{org_id}/role/signoz-admin"
|
||||
tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
|
||||
{"object_id": tuple_object_id},
|
||||
)
|
||||
|
||||
tuple_row = tuple_result.mappings().fetchone()
|
||||
assert tuple_row is not None
|
||||
# check that the tuple if for role assignment
|
||||
assert tuple_row["object_type"] == "role"
|
||||
assert tuple_row["relation"] == "assignee"
|
||||
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/user/{user_id}"
|
||||
assert tuple_row["user_object_type"] == "user"
|
||||
assert tuple_row["user_object_id"] == user_object_id
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{user_id}"
|
||||
assert tuple_row["user_type"] == "user"
|
||||
assert tuple_row["_user"] == _user
|
||||
|
||||
|
||||
def test_anonymous_user_signoz_anonymous_assignment(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
# this validates to some extent that the role assignment is complete under the assumption that middleware is functioning as expected.
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
# Loop over the roles and get the org_id and id for signoz-admin role
|
||||
roles = response.json()["data"]
|
||||
admin_role_entry = next((role for role in roles if role["name"] == "signoz-anonymous"), None)
|
||||
assert admin_role_entry is not None
|
||||
org_id = admin_role_entry["orgId"]
|
||||
|
||||
# to be super sure of authorization server, let's validate the tuples in DB as well.
|
||||
# todo[@vikrantgupta25]: replace this with role memebers handler once built.
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
# verify the entry present for role assignment
|
||||
tuple_object_id = f"organization/{org_id}/role/signoz-anonymous"
|
||||
tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
|
||||
{"object_id": tuple_object_id},
|
||||
)
|
||||
|
||||
tuple_row = tuple_result.mappings().fetchone()
|
||||
assert tuple_row is not None
|
||||
# check that the tuple if for role assignment
|
||||
assert tuple_row["object_type"] == "role"
|
||||
assert tuple_row["relation"] == "assignee"
|
||||
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/anonymous/{ANONYMOUS_USER_ID}"
|
||||
assert tuple_row["user_object_type"] == "anonymous"
|
||||
assert tuple_row["user_object_id"] == user_object_id
|
||||
else:
|
||||
_user = f"anonymous:organization/{org_id}/anonymous/{ANONYMOUS_USER_ID}"
|
||||
assert tuple_row["user_type"] == "user"
|
||||
assert tuple_row["_user"] == _user
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import flatten_transaction_groups, transaction_group
|
||||
|
||||
CRUD_ROLE_NAME = "crud-test-role"
|
||||
CRUD_ASSIGNEE_ROLE_NAME = "crud-assignee-role"
|
||||
CRUD_ASSIGNEE_USER_EMAIL = "crud+assignee@integration.test"
|
||||
CRUD_ASSIGNEE_USER_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
def test_custom_role_create_requires_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": "crud-no-license", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS, f"expected 451 without license, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_create_get_list_roundtrip(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
groups = [
|
||||
transaction_group("read", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
]
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": CRUD_ROLE_NAME, "description": "crud role", "transactionGroups": groups},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
role = resp.json()["data"]
|
||||
assert role["name"] == CRUD_ROLE_NAME
|
||||
assert role["type"] == "custom"
|
||||
assert role["description"] == "crud role"
|
||||
assert flatten_transaction_groups(role["transactionGroups"]) == flatten_transaction_groups(groups)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get("/api/v1/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert CRUD_ROLE_NAME in {r["name"] for r in resp.json()["data"]}
|
||||
|
||||
|
||||
def test_declarative_update_adds_and_removes_grants(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, CRUD_ROLE_NAME)
|
||||
|
||||
def put_grants(groups: list[dict]) -> None:
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "crud role", "transactionGroups": groups},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
def current_grants() -> set:
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
return flatten_transaction_groups(resp.json()["data"]["transactionGroups"])
|
||||
|
||||
superset = [
|
||||
transaction_group("read", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("create", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("update", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
]
|
||||
put_grants(superset)
|
||||
assert current_grants() == flatten_transaction_groups(superset)
|
||||
|
||||
subset = [transaction_group("read", "metaresource", "dashboard", ["*"])]
|
||||
put_grants(subset)
|
||||
assert current_grants() == flatten_transaction_groups(subset)
|
||||
|
||||
put_grants([])
|
||||
assert current_grants() == set()
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def test_update_changes_description(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
groups = [transaction_group("read", "metaresource", "dashboard", ["*"])]
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": "crud-desc-role", "description": "initial", "transactionGroups": groups},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "updated", "transactionGroups": None},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"null transactionGroups: expected 400, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "updated", "transactionGroups": groups},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
role = resp.json()["data"]
|
||||
assert role["description"] == "updated"
|
||||
assert flatten_transaction_groups(role["transactionGroups"]) == flatten_transaction_groups(groups)
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def test_create_validation_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
bad_bodies = {
|
||||
"reserved-prefix": {"name": "signoz-nope", "transactionGroups": []},
|
||||
"invalid-name-chars": {"name": "Bad_Name", "transactionGroups": []},
|
||||
"name-too-long": {"name": "a" * 51, "transactionGroups": []},
|
||||
"verb-invalid-for-resource": {
|
||||
"name": "crud-bad-verb",
|
||||
"transactionGroups": [transaction_group("assignee", "metaresource", "dashboard", ["*"])],
|
||||
},
|
||||
"unknown-type": {
|
||||
"name": "crud-bad-type",
|
||||
"transactionGroups": [transaction_group("read", "not-a-type", "dashboard", ["*"])],
|
||||
},
|
||||
"unknown-kind": {
|
||||
"name": "crud-bad-kind",
|
||||
"transactionGroups": [transaction_group("read", "metaresource", "not-a-kind", ["*"])],
|
||||
},
|
||||
"bad-selector-metaresource": {
|
||||
"name": "crud-bad-selector",
|
||||
"transactionGroups": [transaction_group("read", "metaresource", "dashboard", ["not-a-uuid"])],
|
||||
},
|
||||
"bad-selector-telemetry": {
|
||||
"name": "crud-bad-telemetry-selector",
|
||||
"transactionGroups": [transaction_group("read", "telemetryresource", "logs", ["not-a-wildcard"])],
|
||||
},
|
||||
}
|
||||
|
||||
for label, body in bad_bodies.items():
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"{label}: expected 400, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
def test_duplicate_name_conflict(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": "crud-dup-role", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": "crud-dup-role", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CONFLICT, f"expected 409, got {resp.status_code}: {resp.text}"
|
||||
finally:
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def test_managed_role_is_immutable(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
admin_role_id = find_role_id(admin_token, "signoz-admin")
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{admin_role_id}"),
|
||||
json={"description": "hijacked", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"update managed role: expected 400, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{admin_role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"delete managed role: expected 400, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
def test_delete_role_with_assignee_guarded(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={
|
||||
"name": CRUD_ASSIGNEE_ROLE_NAME,
|
||||
"transactionGroups": [transaction_group("read", "metaresource", "dashboard", ["*"])],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
|
||||
user_id = create_active_user(
|
||||
signoz,
|
||||
admin_token,
|
||||
email=CRUD_ASSIGNEE_USER_EMAIL,
|
||||
role="VIEWER",
|
||||
password=CRUD_ASSIGNEE_USER_PASSWORD,
|
||||
name="crud-assignee-user",
|
||||
)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": CRUD_ASSIGNEE_ROLE_NAME},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"delete role with assignee: expected 400, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
entry = next(r for r in resp.json()["data"] if r["name"] == CRUD_ASSIGNEE_ROLE_NAME)
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{entry['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
|
||||
def test_delete_removes_role(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={"name": "crud-del-role", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
role_id = resp.json()["data"]["id"]
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NOT_FOUND, f"expected 404 after delete, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
def test_cleanup_assignee_user(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
user = find_user_by_email(signoz, admin_token, CRUD_ASSIGNEE_USER_EMAIL)
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
215
tests/integration/tests/role/02_user.py
Normal file
215
tests/integration/tests/role/02_user.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
USER_EDITOR_EMAIL,
|
||||
USER_EDITOR_PASSWORD,
|
||||
change_user_role,
|
||||
)
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
|
||||
def test_user_invite_accept_role_grant(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# invite a user as editor
|
||||
invite_payload = {
|
||||
"email": USER_EDITOR_EMAIL,
|
||||
"role": "EDITOR",
|
||||
}
|
||||
invite_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json=invite_payload,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert invite_response.status_code == HTTPStatus.CREATED
|
||||
invited_user = invite_response.json()["data"]
|
||||
reset_token = invited_user["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)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
editor_data = response.json()["data"]
|
||||
editor_id = editor_data["id"]
|
||||
|
||||
# check the forbidden response for admin api for editor user
|
||||
admin_roles_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert admin_roles_response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
roles_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert roles_response.status_code == HTTPStatus.OK
|
||||
org_id = roles_response.json()["data"][0]["orgId"]
|
||||
|
||||
# check role assignment tuples in DB
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
tuple_object_id = f"organization/{org_id}/role/signoz-editor"
|
||||
tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
|
||||
{"object_id": tuple_object_id},
|
||||
)
|
||||
tuple_row = tuple_result.mappings().fetchone()
|
||||
assert tuple_row is not None
|
||||
assert tuple_row["object_type"] == "role"
|
||||
assert tuple_row["relation"] == "assignee"
|
||||
|
||||
# verify the user tuple details depending on db provider
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/user/{editor_id}"
|
||||
assert tuple_row["user_object_type"] == "user"
|
||||
assert tuple_row["user_object_id"] == user_object_id
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{editor_id}"
|
||||
assert tuple_row["user_type"] == "user"
|
||||
assert tuple_row["_user"] == _user
|
||||
|
||||
|
||||
def test_user_update_role_grant(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# Get the editor user's id
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
editor_data = response.json()["data"]
|
||||
editor_id = editor_data["id"]
|
||||
|
||||
# Get the role id for viewer
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
roles_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert roles_response.status_code == HTTPStatus.OK
|
||||
roles_data = roles_response.json()["data"]
|
||||
org_id = roles_data[0]["orgId"]
|
||||
|
||||
# Update the user's role to viewer via v2 role endpoints
|
||||
change_user_role(signoz, admin_token, editor_id, "signoz-editor", "signoz-viewer")
|
||||
|
||||
# Check that user no longer has the editor role in the db
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
editor_tuple_object_id = f"organization/{org_id}/role/signoz-editor"
|
||||
viewer_tuple_object_id = f"organization/{org_id}/role/signoz-viewer"
|
||||
# Check there is no tuple for signoz-editor assignment
|
||||
editor_tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
|
||||
{"object_id": editor_tuple_object_id},
|
||||
)
|
||||
for row in editor_tuple_result.mappings().fetchall():
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/user/{editor_id}"
|
||||
assert row["user_object_id"] != user_object_id
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{editor_id}"
|
||||
assert row["_user"] != _user
|
||||
|
||||
# Check that a tuple exists for signoz-viewer assignment
|
||||
viewer_tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
|
||||
{"object_id": viewer_tuple_object_id},
|
||||
)
|
||||
row = viewer_tuple_result.mappings().fetchone()
|
||||
assert row is not None
|
||||
assert row["object_type"] == "role"
|
||||
assert row["relation"] == "assignee"
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/user/{editor_id}"
|
||||
assert row["user_object_type"] == "user"
|
||||
assert row["user_object_id"] == user_object_id
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{editor_id}"
|
||||
assert row["user_type"] == "user"
|
||||
assert row["_user"] == _user
|
||||
|
||||
|
||||
def test_user_delete_role_revoke(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# login with editor to get the user_id and check if user exists
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
editor_data = response.json()["data"]
|
||||
editor_id = editor_data["id"]
|
||||
|
||||
# delete the editor user
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/user/{editor_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# get the role id from roles list
|
||||
roles_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert roles_response.status_code == HTTPStatus.OK
|
||||
org_id = roles_response.json()["data"][0]["orgId"]
|
||||
tuple_object_id = f"organization/{org_id}/role/signoz-editor"
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
tuple_result = conn.execute(
|
||||
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
|
||||
{"object_id": tuple_object_id},
|
||||
)
|
||||
|
||||
# there should NOT be any tuple for the current user assignment
|
||||
tuple_rows = tuple_result.mappings().fetchall()
|
||||
for row in tuple_rows:
|
||||
if request.config.getoption("--sqlstore-provider") == "sqlite":
|
||||
user_object_id = f"organization/{org_id}/user/{editor_id}"
|
||||
assert row["user_object_id"] != user_object_id
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{editor_id}"
|
||||
assert row["_user"] != _user
|
||||
@@ -1,3 +1,10 @@
|
||||
"""Tests for resource-level FGA on role endpoints.
|
||||
|
||||
Validates that a custom role with specific role permissions gets exactly
|
||||
the access it was granted — read/list allowed, create/update/delete forbidden
|
||||
until explicitly granted, and revocation removes access.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
@@ -13,13 +20,25 @@ from fixtures.auth import (
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import transaction_group
|
||||
from fixtures.role import (
|
||||
ROLES_BASE,
|
||||
create_custom_role,
|
||||
delete_custom_role,
|
||||
find_role_by_name,
|
||||
object_group,
|
||||
patch_role_objects,
|
||||
)
|
||||
|
||||
ROLE_FGA_CUSTOM_ROLE_NAME = "role-fga-readonly"
|
||||
ROLE_FGA_CUSTOM_USER_EMAIL = "customrole+rolefga@integration.test"
|
||||
ROLE_FGA_CUSTOM_USER_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Apply license (required for custom role CRUD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -29,6 +48,33 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Reject role names starting with "signoz-"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_role_with_signoz_prefix_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
for name in ("signoz-custom", "signozcustom"):
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": name, "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"expected 400 for role name '{name}', got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Create custom role + user with read/list on roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_custom_role_for_role_fga(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -36,20 +82,32 @@ def test_create_custom_role_for_role_fga(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={
|
||||
"name": ROLE_FGA_CUSTOM_ROLE_NAME,
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "role", "role", ["*"]),
|
||||
transaction_group("list", "role", "role", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
# Create the custom role.
|
||||
role_id = create_custom_role(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Grant read on role instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("role", "role", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on role collection.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("role", "role", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Create the custom-role user: invite as VIEWER, activate, change role.
|
||||
user_id = create_active_user(
|
||||
signoz,
|
||||
admin_token,
|
||||
@@ -61,78 +119,125 @@ def test_create_custom_role_for_role_fga(
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Read-only access: allowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_readonly_allowed_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
target_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get("/api/v1/roles"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# List roles.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list roles: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{target_role_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# Get role.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get role: {resp.text}"
|
||||
|
||||
# Get objects for role.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}/relations/read/objects"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get role objects: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Read-only access: forbidden operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_readonly_forbidden_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
target_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# Create role — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": "role-fga-should-fail", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{target_role_id}"),
|
||||
json={"description": "role-fga-renamed", "transactionGroups": []},
|
||||
# Patch role — forbidden.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
json={"description": "role-fga-renamed"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"update role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"patch role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
custom_role_id = find_role_id(admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{custom_role_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# Patch objects — forbidden.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}/relations/read/objects"),
|
||||
json={"additions": [object_group("metaresource", "dashboard", ["*"])]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"patch objects: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Delete role — forbidden (cannot delete managed role, but auth check comes first).
|
||||
# Use the custom role itself as target (non-managed, but user lacks delete permission).
|
||||
custom_role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{custom_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"delete role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Grant write permissions, verify access opens up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_grant_write_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "role", "role", ["*"]) for verb in ("read", "list", "create", "update", "delete")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
# Grant create, update, delete on roles.
|
||||
for verb in ("create", "update", "delete"):
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
verb,
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
|
||||
custom_token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Create role — now allowed.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
json={"name": "role-fga-write-test", "transactionGroups": []},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
@@ -140,61 +245,98 @@ def test_role_grant_write_permissions(
|
||||
assert resp.status_code == HTTPStatus.CREATED, f"create role: {resp.text}"
|
||||
new_role_id = resp.json()["data"]["id"]
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{new_role_id}"),
|
||||
json={"description": "role-fga-write-renamed", "transactionGroups": []},
|
||||
# Patch role — now allowed.
|
||||
resp = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{new_role_id}"),
|
||||
json={"description": "role-fga-write-renamed"},
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"update role: {resp.text}"
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"patch role: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{new_role_id}"), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# Delete role — now allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{new_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Revoke read/list → verify access lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_revoke_read_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
target_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
target_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "role", "role", ["*"]) for verb in ("create", "update", "delete")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Revoke read.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
deletions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
|
||||
# Revoke list.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
deletions=[object_group("role", "role", ["*"])],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(ROLE_FGA_CUSTOM_USER_EMAIL, ROLE_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get("/api/v1/roles"), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# List roles — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROLES_BASE),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"list roles after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{target_role_id}"), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# Get role — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{ROLES_BASE}/{target_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"get role after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Clean up: delete custom role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_role_fga_cleanup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, ROLE_FGA_CUSTOM_ROLE_NAME)
|
||||
user = find_user_by_email(signoz, admin_token, ROLE_FGA_CUSTOM_USER_EMAIL)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
# Remove the custom role from the user first.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
custom_entry = next((r for r in resp.json()["data"] if r["name"] == ROLE_FGA_CUSTOM_ROLE_NAME), None)
|
||||
roles = resp.json()["data"]
|
||||
custom_entry = next((r for r in roles if r["name"] == ROLE_FGA_CUSTOM_ROLE_NAME), None)
|
||||
if custom_entry is not None:
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles/{custom_entry['id']}"),
|
||||
@@ -203,5 +345,4 @@ def test_role_fga_cleanup(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
delete_custom_role(signoz, admin_token, role_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Resource-level FGA on service account endpoints.
|
||||
"""Tests for resource-level FGA on service account endpoints.
|
||||
|
||||
A custom role is granted exactly the permissions under test, and the role's full
|
||||
grant set is re-declared via PUT at each step (no incremental patching). Verifies:
|
||||
Validates that a custom role with specific SA permissions gets exactly
|
||||
the access it was granted, and that:
|
||||
- SA role assignment requires BOTH serviceaccount:attach AND role:attach.
|
||||
- SA role removal requires BOTH serviceaccount:detach AND role:detach.
|
||||
- Factor API key creation requires factor-api-key:create AND serviceaccount:attach.
|
||||
@@ -23,7 +23,13 @@ from fixtures.auth import (
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import transaction_group
|
||||
from fixtures.role import (
|
||||
create_custom_role,
|
||||
delete_custom_role,
|
||||
find_role_by_name,
|
||||
object_group,
|
||||
patch_role_objects,
|
||||
)
|
||||
from fixtures.serviceaccount import (
|
||||
SERVICE_ACCOUNT_BASE,
|
||||
create_service_account,
|
||||
@@ -37,6 +43,11 @@ SA_FGA_CUSTOM_USER_PASSWORD = "password123Z$"
|
||||
SA_FGA_TARGET_SA_NAME = "sa-fga-target"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Apply license (required for custom role CRUD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -46,6 +57,11 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Create custom role + user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_custom_role_readonly_sa(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -53,17 +69,54 @@ def test_create_custom_role_readonly_sa(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={
|
||||
"name": SA_FGA_CUSTOM_ROLE_NAME,
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("read", "list")] + [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
# Create the custom role.
|
||||
role_id = create_custom_role(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Grant read on serviceaccount instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on serviceaccount (now on the serviceaccount type directly).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant read on factor-api-key (needed for listing keys).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant list on factor-api-key.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Create the custom-role user: invite as VIEWER, activate, change role.
|
||||
user_id = create_active_user(
|
||||
signoz,
|
||||
admin_token,
|
||||
@@ -74,8 +127,10 @@ def test_create_custom_role_readonly_sa(
|
||||
)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", SA_FGA_CUSTOM_ROLE_NAME)
|
||||
|
||||
# Create a target SA (with role + key) for the custom user to operate on.
|
||||
sa_id = create_service_account(signoz, admin_token, SA_FGA_TARGET_SA_NAME, role="signoz-viewer")
|
||||
|
||||
# Create a key on the target SA.
|
||||
key_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
json={"name": "fga-key", "expiresAt": 0},
|
||||
@@ -85,6 +140,11 @@ def test_create_custom_role_readonly_sa(
|
||||
assert key_resp.status_code == HTTPStatus.CREATED, key_resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Read-only access: allowed operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_readonly_role_allowed_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -93,31 +153,56 @@ def test_readonly_role_allowed_operations(
|
||||
token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# List SAs.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list SAs: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# Get SA.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get SA: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# Get SA roles.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"get SA roles: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# List SA keys.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, f"list SA keys: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Read-only access: forbidden operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_readonly_role_forbidden_operations(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
key_id = get_first_key_id(signoz, admin_token, sa_id)
|
||||
|
||||
# Create SA — forbidden.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
json={"name": "sa-fga-should-fail"},
|
||||
@@ -126,6 +211,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Update SA — forbidden.
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
json={"name": "sa-fga-renamed"},
|
||||
@@ -134,9 +220,15 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"update SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
# Delete SA — forbidden.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"delete SA: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Assign role to SA — forbidden (needs attach on both SA and role).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -145,6 +237,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Remove role from SA — forbidden (needs detach on both SA and role).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -152,6 +245,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Create key — forbidden (needs factor-api-key:create + serviceaccount:attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys"),
|
||||
json={"name": "fga-key-fail", "expiresAt": 0},
|
||||
@@ -160,6 +254,7 @@ def test_readonly_role_forbidden_operations(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"create key: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Revoke key — forbidden (needs factor-api-key:delete + serviceaccount:detach).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/keys/{key_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -168,30 +263,95 @@ def test_readonly_role_forbidden_operations(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"revoke key: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
def test_grant_write_permissions(
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Grant write permissions, verify access opens up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_role_add_write_permissions(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("read", "list", "create", "update", "delete", "attach", "detach")] + [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list", "create", "delete")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Grant create on serviceaccount (now on serviceaccount type directly).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"create",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant update on instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"update",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant delete on instances.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"delete",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Grant factor-api-key create/delete + serviceaccount attach/detach for key operations.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"create",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"delete",
|
||||
additions=[
|
||||
object_group("metaresource", "factor-api-key", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Create SA — now allowed.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
json={"name": "sa-fga-write-test"},
|
||||
@@ -201,6 +361,7 @@ def test_grant_write_permissions(
|
||||
assert resp.status_code == HTTPStatus.CREATED, f"create SA: {resp.text}"
|
||||
new_sa_id = resp.json()["data"]["id"]
|
||||
|
||||
# Update SA — now allowed.
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}"),
|
||||
json={"name": "sa-fga-write-renamed"},
|
||||
@@ -209,6 +370,7 @@ def test_grant_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"update SA: {resp.text}"
|
||||
|
||||
# Create key — now allowed (factor-api-key:create + serviceaccount:attach).
|
||||
key_resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys"),
|
||||
json={"name": "fga-write-key", "expiresAt": 0},
|
||||
@@ -218,6 +380,7 @@ def test_grant_write_permissions(
|
||||
assert key_resp.status_code == HTTPStatus.CREATED, f"create key: {key_resp.text}"
|
||||
new_key_id = key_resp.json()["data"]["id"]
|
||||
|
||||
# Revoke key — now allowed (factor-api-key:delete + serviceaccount:detach).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}/keys/{new_key_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -225,9 +388,15 @@ def test_grant_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"revoke key: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}"), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# Delete SA — now allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{new_sa_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete SA: {resp.text}"
|
||||
|
||||
# Role assignment still forbidden (has attach on SA but not on role).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -236,6 +405,7 @@ def test_grant_write_permissions(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Role removal still forbidden (has detach on SA but not on role).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -244,17 +414,24 @@ def test_grant_write_permissions(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove SA role: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Dual-attach: SA attach only (no role attach) → assign forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_with_only_sa_attach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# SA attach already granted from previous test; role attach not yet granted.
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Assign role — forbidden (has SA attach, missing role attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -264,17 +441,25 @@ def test_attach_with_only_sa_attach_forbidden(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only SA attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Dual-detach: SA detach only (no role detach) → remove forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detach_with_only_sa_detach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
# SA detach already granted from test_patch_role_add_write_permissions;
|
||||
# role detach not yet granted.
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Remove role — forbidden (has SA detach, missing role detach).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -283,32 +468,34 @@ def test_detach_with_only_sa_detach_forbidden(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only SA detach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Dual-attach: role attach only (no SA attach) → assign forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_with_only_role_attach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("read", "list", "create", "update", "delete", "detach")]
|
||||
+ [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list", "create", "delete")]
|
||||
+ [transaction_group("attach", "role", "role", ["*"])],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Remove SA attach, grant role attach.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
deletions=[object_group("serviceaccount", "serviceaccount", ["*"])],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Assign role — forbidden (middleware SA attach check fails).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": viewer_role_id},
|
||||
@@ -318,32 +505,34 @@ def test_attach_with_only_role_attach_forbidden(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign with only role attach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Dual-detach: role detach only (no SA detach) → remove forbidden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detach_with_only_role_detach_forbidden(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
|
||||
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("read", "list", "create", "update", "delete")]
|
||||
+ [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list", "create", "delete")]
|
||||
+ [transaction_group(verb, "role", "role", ["*"]) for verb in ("attach", "detach")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Remove SA detach, grant role detach.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[object_group("role", "role", ["*"])],
|
||||
deletions=[object_group("serviceaccount", "serviceaccount", ["*"])],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
# Remove role — forbidden (SA detach check fails).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{viewer_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -352,32 +541,46 @@ def test_detach_with_only_role_detach_forbidden(
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"remove with only role detach: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Both attach + detach → assign and remove succeed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_detach_with_both_permissions_succeeds(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("read", "list", "create", "update", "delete", "attach", "detach")]
|
||||
+ [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list", "create", "delete")]
|
||||
+ [transaction_group(verb, "role", "role", ["*"]) for verb in ("attach", "detach")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Add back SA attach and SA detach (role attach/detach already present from previous tests).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"attach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"detach",
|
||||
additions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
editor_role_id = find_role_id(admin_token, "signoz-editor")
|
||||
|
||||
# The target SA currently has signoz-viewer assigned. Assign a different role.
|
||||
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
|
||||
|
||||
# Assign editor role — should succeed (both SA attach + role attach).
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles"),
|
||||
json={"id": editor_role_id},
|
||||
@@ -386,6 +589,7 @@ def test_attach_detach_with_both_permissions_succeeds(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"assign with both attach: {resp.text}"
|
||||
|
||||
# Remove the editor role — should succeed (both SA detach + role detach).
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}/roles/{editor_role_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
@@ -394,51 +598,84 @@ def test_attach_detach_with_both_permissions_succeeds(
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove with both detach: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Revoke read/list → verify access lost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_read_permissions_revokes_access(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
sa_id = find_service_account_by_name(signoz, admin_token, SA_FGA_TARGET_SA_NAME)["id"]
|
||||
|
||||
resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [transaction_group(verb, "serviceaccount", "serviceaccount", ["*"]) for verb in ("create", "update", "delete", "attach", "detach")]
|
||||
+ [transaction_group(verb, "metaresource", "factor-api-key", ["*"]) for verb in ("read", "list", "create", "delete")]
|
||||
+ [transaction_group(verb, "role", "role", ["*"]) for verb in ("attach", "detach")],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
# Revoke read.
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"read",
|
||||
deletions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Revoke list (now on serviceaccount type directly).
|
||||
patch_role_objects(
|
||||
signoz,
|
||||
admin_token,
|
||||
role_id,
|
||||
"list",
|
||||
deletions=[
|
||||
object_group("serviceaccount", "serviceaccount", ["*"]),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
|
||||
custom_token = get_token(SA_FGA_CUSTOM_USER_EMAIL, SA_FGA_CUSTOM_USER_PASSWORD)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# List SAs — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"list SAs after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"), headers={"Authorization": f"Bearer {custom_token}"}, timeout=5)
|
||||
# Get SA — forbidden.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{sa_id}"),
|
||||
headers={"Authorization": f"Bearer {custom_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"get SA after revoke: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Clean up: delete custom role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_custom_role_cleanup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_id(admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
role_id = find_role_by_name(signoz, admin_token, SA_FGA_CUSTOM_ROLE_NAME)
|
||||
user = find_user_by_email(signoz, admin_token, SA_FGA_CUSTOM_USER_EMAIL)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
# Remove the custom role from the user first — role deletion requires no assignees.
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
custom_entry = next((r for r in resp.json()["data"] if r["name"] == SA_FGA_CUSTOM_ROLE_NAME), None)
|
||||
roles = resp.json()["data"]
|
||||
custom_entry = next((r for r in roles if r["name"] == SA_FGA_CUSTOM_ROLE_NAME), None)
|
||||
if custom_entry is not None:
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles/{custom_entry['id']}"),
|
||||
@@ -447,5 +684,4 @@ def test_delete_custom_role_cleanup(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
|
||||
|
||||
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
|
||||
delete_custom_role(signoz, admin_token, role_id)
|
||||
|
||||
Reference in New Issue
Block a user