Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
d104e150e8 Merge branch 'main' into nv/layout-validations 2026-07-01 12:50:02 +05:30
Naman Verma
c16c4ab863 test: add integratino test for layout validation 2026-07-01 12:49:27 +05:30
Naman Verma
3b73bbc46d chore: validate layout size and positioning in backend 2026-07-01 12:32:08 +05:30
34 changed files with 2253 additions and 1764 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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));
};

View File

@@ -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

View File

@@ -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({});
});
});

View File

@@ -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,
],
);

View File

@@ -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]);
}

View File

@@ -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;

View File

@@ -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({});
});
});

View File

@@ -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: {},
};
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -217,7 +217,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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]),
}

View File

@@ -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)},

View File

@@ -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

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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
View File

@@ -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}

View File

@@ -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(

View File

@@ -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"
]
}
]
}

View File

@@ -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",
[

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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)