Compare commits

..

23 Commits

Author SHA1 Message Date
Naman Verma
79143be510 Revert "Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation"
This reverts commit e91785bfe3, reversing
changes made to fe838a3464.
2026-06-30 21:48:14 +05:30
Naman Verma
e91785bfe3 Merge branch 'nv/v2-public-dashboard' into nv/dashboard-create-aggregation 2026-06-30 21:41:37 +05:30
Naman Verma
fe838a3464 Merge branch 'main' into nv/dashboard-create-aggregation 2026-06-30 21:40:48 +05:30
Naman Verma
ff22facdd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-30 21:40:36 +05:30
Naman Verma
875432d7ec test: add integration test 2026-06-30 21:34:17 +05:30
Naman Verma
5f1e7a3a53 chore: remove unused err type 2026-06-30 19:15:31 +05:30
Naman Verma
6a4629a418 fix: handle multi aggregations in public dashboard panels 2026-06-30 19:03:50 +05:30
Naman Verma
72a6ca6516 Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 20:07:16 +05:30
Naman Verma
bbd5cc380e Merge branch 'main' into nv/v2-public-dashboard 2026-06-25 02:02:16 +05:30
Naman Verma
6cd9b5bbd6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 15:37:15 +05:30
Naman Verma
13f6c232a1 Merge branch 'main' into nv/v2-public-dashboard 2026-06-18 11:47:24 +05:30
Naman Verma
dac1489294 Merge branch 'main' into nv/v2-public-dashboard 2026-06-17 07:29:31 +05:30
Naman Verma
1d98e9ebf6 Merge branch 'main' into nv/v2-public-dashboard 2026-06-16 12:35:00 +05:30
Naman Verma
15e99e43ff test: add integration tests for new v2 public apis 2026-06-16 12:21:50 +05:30
Naman Verma
8c766f8c10 fix: generate api specs 2026-06-16 11:45:22 +05:30
Naman Verma
99b32f00b9 fix: add fill gaps to query 2026-06-16 02:30:59 +05:30
Naman Verma
76f8646c69 test: unit tests for GetPanelQuery 2026-06-16 02:18:01 +05:30
Naman Verma
28c00e298a chore: rename method name 2026-06-16 02:01:40 +05:30
Naman Verma
4592b12256 fix: remove fields that v1 also removes when redacting 2026-06-16 01:53:33 +05:30
Naman Verma
b990d40c5f chore: trim comments 2026-06-16 00:23:41 +05:30
Naman Verma
95a0d7c035 fix: fill fields that were in the data blob in v1 2026-06-15 23:04:21 +05:30
Naman Verma
e678728c61 fix: remove duplicate call to GetDashboardByPublicIDV2 in GetPublicWidgetQueryRangeV2 2026-06-15 22:31:45 +05:30
Naman Verma
42d3e7e0e4 feat: add first draft of v2 public dashboard apis 2026-06-15 15:00:44 +05:30
89 changed files with 2557 additions and 4444 deletions

View File

@@ -177,11 +177,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)

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:
@@ -1017,8 +1024,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -1164,8 +1169,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
type: object
CloudintegrationtypesCredentials:
properties:
@@ -1196,46 +1199,6 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
type: object
CloudintegrationtypesGCPIntegrationConfig:
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
type: object
CloudintegrationtypesGCPServiceLogsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGCPServiceMetricsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -1368,8 +1331,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -1394,8 +1355,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesService:
properties:
@@ -1440,8 +1399,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
@@ -1484,7 +1441,6 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1546,8 +1502,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
@@ -1558,22 +1512,6 @@ components:
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesUpdatableService:
properties:
config:
@@ -2529,6 +2467,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 +11756,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 +11880,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

@@ -1,36 +0,0 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
}
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}

View File

@@ -61,7 +61,5 @@
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
}

View File

@@ -86,7 +86,5 @@
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
}

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;
/**
@@ -2623,25 +2630,9 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesAccountDTO {
@@ -2749,29 +2740,9 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceConfigDTO {
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesServiceConfigDTO {
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -2802,7 +2773,6 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2867,14 +2837,9 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -2907,10 +2872,6 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
@@ -3002,7 +2963,6 @@ export type CloudintegrationtypesIntegrationConfigDTO =
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
@@ -3065,7 +3025,6 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -3195,25 +3154,9 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array,null
*/
projectIds: string[] | null;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
@@ -3242,6 +3185,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 +10184,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

@@ -3,6 +3,7 @@
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}

View File

@@ -680,13 +680,6 @@ describe('formatUniversalUnit', () => {
});
describe('Datetime', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterAll(() => {
jest.useRealTimers();
});
it('formats datetime units', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
'56 years ago',

View File

@@ -1,28 +1,7 @@
.filtersBar {
display: flex;
gap: var(--spacing-6);
align-items: center;
justify-content: space-between;
}
.filtersBarLeft {
display: flex;
gap: var(--spacing-6);
align-items: center;
}
.filtersBarSearch {
width: 280px;
}
.filtersBarSource {
width: 160px;
}
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
color: var(--accent-cherry);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,164 +1,52 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { Typography } from '@signozhq/ui/typography';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SEARCH_DEBOUNCE_MS,
SEARCH_KEY,
SOURCE_FILTER_OPTIONS,
SOURCE_FILTER_TO_IS_OVERRIDE,
SOURCE_KEY,
type SourceFilter,
} from '../constants';
import type { PricingRule } from '../types';
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
import ModelCostDrawer, {
useModelCostDrawer,
} from './components/ModelCostDrawer';
import ModelCostsTable from './components/ModelCostsTable';
import { useModelCostDelete } from './hooks/useModelCostDelete';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
// "Model costs" tab: the priced-model listing, search + source filter, the add/
// edit drawer, and pagination. Page and page size live in the URL (shareable/
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
// reads them back through the same useTableParams hook so the two stay in lockstep.
function ModelCostTabPanel(): JSX.Element {
const { page, limit, setPage } = useTableParams(
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
const [search, setSearch] = useQueryState(
SEARCH_KEY,
parseAsString.withDefault(''),
);
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const [source, setSource] = useQueryState(
SOURCE_KEY,
parseAsStringEnum<SourceFilter>(
SOURCE_FILTER_OPTIONS.map((option) => option.value),
).withDefault('all'),
);
const handleSearchChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
void setSearch(event.target.value || null);
setPage(1);
};
const clearSearch = (): void => {
void setSearch(null);
setPage(1);
};
const handleSourceChange = (value: string | string[]): void => {
void setSource(value as SourceFilter);
setPage(1);
};
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
...(debouncedSearch ? { q: debouncedSearch } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
query: {
enabled: search === debouncedSearch,
},
});
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
const deletion = useModelCostDelete();
return (
<>
<div className={styles.filtersBar}>
<div className={styles.filtersBarLeft}>
<Input
className={styles.filtersBarSearch}
placeholder="Search by model or provider"
value={search}
onChange={handleSearchChange}
prefix={<Search size={14} />}
suffix={
search ? (
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<X size={14} />}
onClick={clearSearch}
aria-label="Clear search"
testId="model-cost-search-clear"
/>
) : undefined
}
testId="model-cost-search"
/>
<SelectSimple
className={styles.filtersBarSource}
items={SOURCE_FILTER_OPTIONS}
value={source}
onChange={handleSourceChange}
testId="source-filter"
/>
</div>
{canManagePricing && (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
onDelete={deletion.requestDelete}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
@@ -166,29 +54,6 @@ function ModelCostTabPanel(): JSX.Element {
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
{deletion.pendingDelete && (
<DeleteConfirmDialog
open
modelName={deletion.pendingDelete.modelName}
isDeleting={deletion.isDeleting}
onConfirm={deletion.confirmDelete}
onCancel={deletion.cancelDelete}
/>
)}
</>
);
}

View File

@@ -1,64 +0,0 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DeleteConfirmDialogProps {
open: boolean;
modelName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Confirmation step before deleting a model cost — deletion is irreversible, so
// the destructive action is gated behind an explicit confirm. AlertDialog blocks
// outside-click dismissal and hides the close button to force an explicit choice.
function DeleteConfirmDialog({
open,
modelName,
isDeleting,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete Model Cost Data "
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="drawer-delete-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="drawer-delete-confirm-btn"
>
Delete
</Button>
</>
}
>
Are you sure you want to delete <strong>{modelName}</strong>? Once deleted,
this action cannot be undone.
</AlertDialog>
);
}
export default DeleteConfirmDialog;

View File

@@ -1,58 +0,0 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.required {
composes: required from './shared.module.scss';
}
.modelCostDrawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
p {
margin: var(--spacing-2) 0 0;
color: var(--l3-foreground);
font-size: 12px;
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
}

View File

@@ -1,238 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Controller, useForm } from 'react-hook-form';
import PatternEditor from './components/PatternEditor';
import PricingFields from './components/PricingFields';
import SourceSelector from './components/SourceSelector';
import { PROVIDER_OPTIONS } from '../../../constants';
import styles from './ModelCostDrawer.module.scss';
import {
validateModelName,
validatePricing,
validateProvider,
} from '../../../utils';
import type { DrawerDraft, DrawerMode } from '../../../types';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
onClose: () => void;
onSave: (draft: DrawerDraft) => void;
isSaving: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
initialDraft,
onClose,
onSave,
isSaving,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Default mode validates on submit, then re-validates on change — so we don't
// flag empty fields before the user has tried to save, but errors clear live
// once they start fixing them.
const {
control,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<DrawerDraft>({
defaultValues: initialDraft,
});
const isOverride = watch('isOverride');
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !isOverride;
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
// read-only "View" rather than "Edit"/"Add".
let drawerTitle = 'Add model cost';
if (!canManage) {
drawerTitle = 'View model cost';
} else if (mode === 'edit') {
drawerTitle = 'Edit model cost';
}
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<Button
variant="solid"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!isDirty}
loading={isSaving}
testId="drawer-save-btn"
>
Save
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className={styles.modelCostDrawer}
footer={footer}
title={drawerTitle}
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
Billing model ID{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Controller
name="modelName"
control={control}
rules={{
validate: (value): true | string => validateModelName(value, mode),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
required
value={field.value}
disabled={mode === 'edit' || metadataReadOnly}
aria-invalid={!!fieldState.error}
onChange={(e): void => field.onChange(e.target.value)}
testId="drawer-model-id-input"
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<Controller
name="provider"
control={control}
rules={{ validate: validateProvider }}
render={({ field, fieldState }): JSX.Element => (
<>
<SelectSimple
id="provider-select"
value={field.value}
onChange={(value): void => field.onChange(value as string)}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-provider-select"
/>
{fieldState.error && (
<Typography.Text size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
name="patterns"
control={control}
render={({ field }): JSX.Element => (
<PatternEditor
patterns={field.value}
isReadOnly={metadataReadOnly}
onChange={field.onChange}
/>
)}
/>
{/* Source is auto vs. override — a choice only a manager can make, so
there's nothing to show a read-only viewer. */}
{canManage && (
<Controller
name="isOverride"
control={control}
// Pricing requirements depend on this toggle, so re-validate pricing
// whenever the source changes (clears/sets the pricing error).
rules={{ deps: ['pricing'] }}
render={({ field }): JSX.Element => (
<SourceSelector
isOverride={field.value}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add' || !initialDraft.sourceId}
onChange={field.onChange}
/>
)}
/>
)}
<Controller
name="pricing"
control={control}
rules={{
validate: (value, values): true | string =>
validatePricing(value, values.isOverride),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<PricingFields
pricing={field.value}
isReadOnly={pricingReadOnly}
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
{saveError && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{saveError}
</Typography.Text>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -1,69 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.cacheModeField {
margin-top: var(--spacing-5);
}
.extraBucketsSection {
margin-top: var(--spacing-7);
gap: var(--spacing-5);
}
.extraBucketsSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
}
.bucketRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
input {
flex: 1 auto auto;
min-width: 0;
}
}
.bucketRowName {
flex: 0 0 110px;
}
.bucketAddBtn {
width: 100%;
}
.bucketPicker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.bucketPickerTitle {
font-size: var(--periscope-font-size-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.bucketPickerChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}

View File

@@ -1,179 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from '../../../../../constants';
import styles from './ExtraPricingBuckets.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { CacheBucketKey, DrawerDraft } from '../../../../../types';
import { Tooltip } from 'antd';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
useState<boolean>(false);
// Track which buckets are shown separately from their value, so a freshly
// added bucket can start blank (value null) instead of being seeded to 0.
// Seeded from buckets that already carry a value (edit mode).
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
() =>
new Set(
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
),
);
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
const patch: Partial<Pricing> = { [key]: value };
onChange(patch);
};
const addBucket = (key: CacheBucketKey): void => {
// Leave the value null so the field renders blank until the user types.
setAddedKeys((prev) => new Set(prev).add(key));
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsExtraPricingBucketOpen(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
setAddedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
patchBucket(key, null);
};
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra pricing buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
optional
</Typography.Text>
</div>
{addedBuckets.map((bucket) => (
<div className={styles.bucketRow} key={bucket.key}>
<Typography.Text as="span" className={styles.bucketRowName}>
{bucket.label}
</Typography.Text>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
// Clearing the field is allowed — the row stays mounted because
// presence is tracked in `addedKeys`, not the value. Removal is
// explicit via the trash button.
patchBucket(bucket.key, parsePricingAmount(e.target.value))
}
testId={`drawer-${bucket.testId}-cost`}
/>
<Tooltip title="Pricing per 1M tokens" placement="left">
<Typography.Text size="xs" color="muted">
1M
</Typography.Text>
</Tooltip>
{!isReadOnly && (
<Button
size="icon"
variant="ghost"
color="destructive"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
prefix={<Trash2 size={14} />}
/>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className={styles.bucketAddBtn}
prefix={<Plus size={14} />}
onClick={(): void => setIsExtraPricingBucketOpen(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isExtraPricingBucketOpen && (
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
<div className={styles.bucketPickerChips}>
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsExtraPricingBucketOpen(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -1,49 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.help {
composes: help from '../../shared.module.scss';
}
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.patternChipRemove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--accent-cherry);
}
}
.patternAdd {
display: flex;
gap: var(--spacing-3);
}

View File

@@ -1,102 +0,0 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import styles from './PatternEditor.module.scss';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className={styles.patternChip}
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className={styles.patternChipRemove}
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className={styles.patternAdd}>
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<Typography.Text as="p" size="small" color="muted">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</Typography.Text>
</div>
);
}
export default PatternEditor;

View File

@@ -1,31 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.required {
composes: required from '../../shared.module.scss';
}
.pricingGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
}

View File

@@ -1,91 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import cx from 'classnames';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
import styles from './PricingFields.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { DrawerDraft } from '../../../../../types';
import { Typography } from '@signozhq/ui/typography';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
<Lock size={12} />
<Typography.Text color="muted">Read-only</Typography.Text>
</span>
)}
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
Input cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="input-cost"
type="number"
step={0.01}
required
value={pricing.input ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) })
}
testId="drawer-input-cost"
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
Output cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="output-cost"
type="number"
step={0.01}
required
value={pricing.output ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -1,115 +0,0 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
composes: managedLabel from '../../shared.module.scss';
}
.sourceRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.sourceRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with the title
// baseline (margin-top compensates for align-items: flex-start vs the
// title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawerSection label typography leak
// (set earlier in this file) so the title/desc divs use their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
}
}
.sourceRadioTitle {
font-weight: var(--font-weight-semibold);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.sourceRadioDesc {
margin-top: 2px;
font-size: 12px;
color: var(--l3-foreground);
}
.resetConfirm {
margin-top: var(--spacing-6);
padding: var(--spacing-6);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, transparent);
p {
margin: 0 0 var(--spacing-5);
font-size: 12px;
}
}
.resetConfirmActions {
display: flex;
gap: var(--spacing-4);
justify-content: flex-end;
}

View File

@@ -1,115 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className={styles.sourceRadioGroup}
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className={styles.sourceRadioTitle}>Auto-populated</div>
<div className={styles.sourceRadioDesc}>
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>
<div className={styles.sourceRadioDesc}>
Custom pricing. Takes precedence.
</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div className={styles.resetConfirm} aria-label="Reset to default pricing">
<p>
Reset to default pricing? Custom values will be discarded. It might take
24 hours for changes to take effect.
</p>
<div className={styles.resetConfirmActions}>
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -1,100 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: (draft: DrawerDraft) => Promise<void>;
isSaving: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [initialDraft, setInitialDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setInitialDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(
async (draft: DrawerDraft): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
},
[createOrUpdate, invalidateList, mode],
);
return {
isOpen,
mode,
initialDraft,
openForAdd,
openForEdit,
close,
save,
isSaving,
saveError,
selectedRuleId,
};
}

View File

@@ -1,2 +0,0 @@
export { default } from './ModelCostDrawer';
export { useModelCostDrawer } from './hooks/useModelCostDrawer';

View File

@@ -1,59 +0,0 @@
/* Shared drawer selectors used by 2+ of the model-cost drawer components. */
/* Components pull these in via CSS-modules `composes` from their own module so */
/* the authored class names in the TSX stay identical. */
/* NOTE: this file is a `composes` target, so it is parsed as plain CSS (no SCSS */
/* preprocessing). Keep it flat — no nesting, no slash-slash comments. */
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection .help,
.help {
margin: 0;
}
.help code {
padding: 1px var(--spacing-2);
border-radius: 3px;
background: var(--l3-background);
font-size: 10px;
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.managedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.required {
color: var(--accent-cherry);
}
.pricingField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.pricingField input {
width: 100%;
}

View File

@@ -15,6 +15,6 @@
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--l3-foreground);
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,66 +0,0 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import type { PricingRule } from '../../types';
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
// and the model name to show in the confirmation copy.
type PendingDelete = Pick<PricingRule, 'id' | 'modelName'>;
interface UseModelCostDeleteResult {
requestDelete: (rule: PendingDelete) => void;
confirmDelete: () => Promise<void>;
cancelDelete: () => void;
pendingDelete: PendingDelete | null;
isDeleting: boolean;
}
// Owns the confirm-then-delete flow for a pricing rule, independent of the
// add/edit drawer — delete is triggered from the table row menu, so this state
// lives at the panel level rather than inside useModelCostDrawer.
export function useModelCostDelete(): UseModelCostDeleteResult {
const queryClient = useQueryClient();
// The rule queued for deletion. Non-null drives the confirm dialog open.
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const requestDelete = useCallback((rule: PendingDelete): void => {
setPendingDelete({ id: rule.id, modelName: rule.modelName });
}, []);
const cancelDelete = useCallback((): void => {
setPendingDelete(null);
}, []);
const confirmDelete = useCallback(async (): Promise<void> => {
if (!pendingDelete) {
return;
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
setPendingDelete(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
toast.error(message);
}
}, [deleteRuleApi, pendingDelete, queryClient]);
return {
requestDelete,
confirmDelete,
cancelDelete,
pendingDelete,
isDeleting,
};
}

View File

@@ -1,68 +1,6 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SEARCH_KEY = 'search';
export const SEARCH_DEBOUNCE_MS = 300;
export const SOURCE_KEY = 'source';
export type SourceFilter = 'all' | 'override' | 'auto';
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'All sources' },
{ value: 'override', label: 'User override' },
{ value: 'auto', label: 'Auto' },
];
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
SourceFilter,
boolean | undefined
> = {
all: undefined,
override: true,
auto: false,
};
// Match the page size so the skeleton reserves the same number of rows the
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
// https://app.notion.com/p/signoz/LLM-Tokens-Cost-Calculation-330fcc6bcd19805283ccc841d596358e?source=copy_link#33efcc6bcd1980e6a187e442c6ba5996
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,39 +1,4 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number | null;
output: number | null;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}

View File

@@ -1,19 +1,8 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
} from './types';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
@@ -24,19 +13,6 @@ const getRelativeTime = (
return parsed?.isValid() ? parsed.fromNow() : '—';
};
const hasCacheValue = (value: number | null | undefined): value is number =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
@@ -47,117 +23,38 @@ export const formatPricePerMillion = (value: number | undefined): string => {
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (hasCacheValue(cache.read)) {
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (hasCacheValue(cache.write)) {
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (rule: PricingRule): string =>
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (rule: PricingRule): string => {
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const buildCacheCosts = (
pricing: DrawerDraft['pricing'],
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
const { cacheMode, cacheRead, cacheWrite } = pricing;
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
return undefined;
}
return {
mode: cacheMode,
...(hasCacheValue(cacheRead) && { read: cacheRead }),
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
};
};
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const cache = buildCacheCosts(draft.pricing);
return {
input: draft.pricing.input ?? 0,
output: draft.pricing.output ?? 0,
...(cache && { cache }),
};
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateModelName = (
modelName: string,
mode: DrawerMode,
): true | string =>
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
export const validateProvider = (provider: string): true | string =>
provider.trim() ? true : 'Provider is required.';
export const validatePricing = (
pricing: DrawerDraft['pricing'],
isOverride: boolean,
): true | string => {
if (!isOverride) {
return true;
}
if (pricing.input === null || pricing.input <= 0) {
return 'Input cost must be greater than 0.';
}
if (pricing.output === null || pricing.output <= 0) {
return 'Output cost must be greater than 0.';
}
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
return 'Cache costs must be non-negative.';
}
return true;
};

View File

@@ -1,14 +1,9 @@
@use '../../styles/scrollbar' as *;
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
overflow-y: auto;
@include custom-scrollbar;
}
.members-settings {

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie } from '@visx/shape';
@@ -8,10 +8,12 @@ import { themeColors } from 'constants/theme';
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
import { preparePieChartData } from './preparePieChartData';
import { lightenColor, tooltipStyles } from './utils';
import './PiePanelWrapper.styles.scss';
@@ -42,15 +44,37 @@ function PiePanelWrapper({
detectBounds: true,
});
const panelData = queryResponse.data?.payload?.data?.result || [];
const isDarkMode = useIsDarkMode();
const pieChartData = useMemo(
() =>
preparePieChartData(queryResponse.data?.payload, {
customLegendColors: widget?.customLegendColors,
colorMap: isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
}),
[queryResponse.data?.payload, widget?.customLegendColors, isDarkMode],
let pieChartData: {
label: string;
value: string;
color: string;
record: any;
}[] = [].concat(
...(panelData
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d?.values?.[0]?.[1],
record: d,
color:
widget?.customLegendColors?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
};
})
.filter((d) => d !== undefined) as never[]),
);
pieChartData = pieChartData.filter(
(arc) =>
arc.value && !isNaN(parseFloat(arc.value)) && parseFloat(arc.value) > 0,
);
let size = 0;

View File

@@ -1,185 +0,0 @@
import { themeColors } from 'constants/theme';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
import { preparePieChartData } from '../preparePieChartData';
const options = { colorMap: themeColors.chartcolors };
/**
* Mirrors a query-range payload: the (possibly collapsed) time-series `result`
* plus the scalar table nested under `newResult` (as getQueryResults produces it).
*/
function makePayload(
result: QueryData[],
tables: QueryDataV3[],
): MetricRangePayloadProps {
return {
data: {
result,
resultType: 'scalar',
newResult: { data: { result: tables, resultType: 'scalar' } },
},
} as MetricRangePayloadProps;
}
function tableEntry(
columns: NonNullable<QueryDataV3['table']>['columns'],
rows: NonNullable<QueryDataV3['table']>['rows'],
overrides: Partial<QueryDataV3> = {},
): QueryDataV3 {
return {
queryName: 'A',
legend: '',
series: null,
list: null,
table: { columns, rows },
...overrides,
} as QueryDataV3;
}
describe('preparePieChartData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
// SELECT count() AS col1, sum(value) AS col2 — the backend collapses the
// time-series result onto col1; the full data lives in the scalar table.
const payload = makePayload(
[
{
metric: {},
queryName: 'A',
legend: '',
values: [[0, '23399927']],
} as QueryData,
],
[
tableEntry(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices).toHaveLength(2);
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', '23399927'],
['col2', '588691297'],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const payload = makePayload(
[],
[
tableEntry(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
expect(slices[0].record.metric).toStrictEqual({ env: 'prod' });
});
it('drops non-positive and non-numeric values', () => {
const payload = makePayload(
[],
[
tableEntry(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('keeps the series path for a single value column (grouped panel)', () => {
// One value column → the time-series result is authoritative (one slice per
// group), so existing behaviour is preserved.
const payload = makePayload(
[
{
metric: { 'service.name': 'adservice' },
queryName: 'A',
legend: 'adservice',
values: [[0, '100']],
} as QueryData,
{
metric: { 'service.name': 'cartservice' },
queryName: 'A',
legend: 'cartservice',
values: [[0, '200']],
} as QueryData,
],
[
tableEntry(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', '100'],
['cartservice', '200'],
]);
});
it('uses the legacy series result when there is no scalar table', () => {
const payload = makePayload(
[
{
metric: { 'service.name': 'adservice' },
queryName: 'A',
legend: '{{service.name}}',
values: [[1000, '42']],
} as QueryData,
],
[],
);
const slices = preparePieChartData(payload, options);
expect(slices).toHaveLength(1);
expect(slices[0].value).toBe('42');
});
it('returns no slices for an empty payload', () => {
expect(preparePieChartData(undefined, options)).toStrictEqual([]);
});
});

View File

@@ -1,144 +0,0 @@
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
export interface PieChartSlice {
label: string;
value: string;
color: string;
record: {
queryName: string;
legend?: string;
/** Group-by labels, used for drilldown; absent when the slice has no group. */
metric?: QueryData['metric'];
};
}
interface PreparePieChartDataOptions {
customLegendColors?: Record<string, string>;
colorMap: Record<string, string>;
}
const colorFor = (
label: string,
{ customLegendColors, colorMap }: PreparePieChartDataOptions,
): string => customLegendColors?.[label] || generateColor(label, colorMap);
const isPositive = (value: string): boolean =>
!!value && !isNaN(parseFloat(value)) && parseFloat(value) > 0;
/**
* Time-series result: one slice per series, value = first datapoint. This is the
* original pie behaviour — kept verbatim (same label/value/colour/record) so
* single-value and grouped panels are unaffected.
*/
function slicesFromSeries(
result: QueryData[],
options: PreparePieChartDataOptions,
): PieChartSlice[] {
return result
.filter((d) => d?.values?.[0]?.[1] !== undefined)
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d.values[0][1],
color: colorFor(label, options),
record: d,
};
});
}
/**
* V5 scalar table: one slice per (row × value column). With more than one value
* column the column name keeps the slices distinct, so a ClickHouse query like
* `count() AS col1, sum() AS col2` renders a slice per column instead of
* collapsing onto the first; group-by columns become the slice label.
*/
function slicesFromTables(
tables: QueryDataV3[],
options: PreparePieChartDataOptions,
): PieChartSlice[] {
const slices: PieChartSlice[] = [];
tables.forEach((entry) => {
const { table } = entry;
if (!table?.columns?.length || !table?.rows?.length) {
return;
}
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
return;
}
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
// Drilldown filters by group-by labels; leave it undefined when there
// are none (e.g. a ClickHouse query) so no filterless menu is offered.
const metric = labelColumns.length
? labelColumns.reduce<Record<string, string>>((acc, column) => {
acc[column.name] = String(row.data[column.id || column.name]);
return acc;
}, {})
: undefined;
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || entry.legend || entry.queryName || '';
}
slices.push({
label,
value: String(row.data[column.id || column.name]),
color: colorFor(label, options),
record: { queryName: entry.queryName, legend: entry.legend, metric },
});
});
});
});
return slices;
}
/**
* Builds pie slices from a query-range payload, dropping non-positive/non-numeric
* values.
*
* A scalar response with several value columns (e.g. a ClickHouse
* `count() AS col1, sum() AS col2`) collapses to a single series in
* `data.result` — only the first value column survives. The full data is kept in
* the scalar table under `newResult`, so in that case slices are built from the
* table (one per value column). Otherwise the legacy time-series result is used,
* preserving existing behaviour for single-value and grouped panels.
*/
export function preparePieChartData(
payload: MetricRangePayloadProps | undefined,
options: PreparePieChartDataOptions,
): PieChartSlice[] {
const tables = (payload?.data?.newResult?.data?.result || []).filter(
(entry) => entry?.table?.rows?.length,
);
const hasMultipleValueColumns = tables.some(
(entry) =>
(entry.table?.columns || []).filter((column) => column.isValueColumn)
.length > 1,
);
const slices = hasMultipleValueColumns
? slicesFromTables(tables, options)
: slicesFromSeries(payload?.data?.result || [], options);
return slices.filter((slice) => isPositive(slice.value));
}

View File

@@ -1,6 +1,7 @@
.rolesListingTable {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.scrollContainer {

View File

@@ -40,7 +40,6 @@
.rolesSettingsContent {
padding: 0 16px;
padding-bottom: 16px;
}
.rolesSettingsToolbar {

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

@@ -20,8 +20,7 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
| 'add_panel_locked_dashboard';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -43,7 +42,6 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {

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

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_SQL_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="4.67 10.44 4.67 13.45 12 17.35 12 14.34 4.67 10.44"/><polygon class="cls-1" points="4.67 15.09 4.67 18.1 12 22 12 18.99 4.67 15.09"/><polygon class="cls-2" points="12 17.35 19.33 13.45 19.33 10.44 12 14.34 12 17.35"/><polygon class="cls-2" points="12 22 19.33 18.1 19.33 15.09 12 18.99 12 22"/><polygon class="cls-3" points="19.33 8.91 19.33 5.9 12 2 12 5.01 19.33 8.91"/><polygon class="cls-2" points="12 2 4.67 5.9 4.67 8.91 12 5.01 12 2"/><polygon class="cls-1" points="4.67 5.87 4.67 8.89 12 12.79 12 9.77 4.67 5.87"/><polygon class="cls-2" points="12 12.79 19.33 8.89 19.33 5.87 12 9.77 12 12.79"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 933 B

View File

@@ -1,27 +0,0 @@
{
"id": "cloudsql",
"title": "GCP Cloud SQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL Overview",
"description": "Overview of GCP Cloud SQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Cloud SQL with SigNoz
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.

View File

@@ -481,7 +481,6 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// TODO: Rename AgentCheckIn to just CheckIn.
func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

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

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

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

@@ -31,13 +31,11 @@ type AgentReport struct {
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type UpdatableAccountConfig struct {
AWS *UpdatableAWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *UpdatableAzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *UpdatableGCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type PostableAccount struct {
@@ -50,7 +48,6 @@ type PostableAccountConfig struct {
AgentVersion string
AWS *AWSPostableAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzurePostableAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPPostableAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type Credentials struct {
@@ -69,7 +66,6 @@ type ConnectionArtifact struct {
// required till new providers are added
AWS *AWSConnectionArtifact `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureConnectionArtifact `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPConnectionArtifact `json:"gcp,omitempty" required:"false" nullable:"false"`
}
type GetConnectionArtifactRequest = PostableAccount
@@ -215,30 +211,6 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
}
return &AccountConfig{Azure: &AzureAccountConfig{DeploymentRegion: config.Azure.DeploymentRegion, ResourceGroups: config.Azure.ResourceGroups}}, nil
case CloudProviderTypeGCP:
if config.GCP == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
}
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
return nil, err
}
if len(config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
},
}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -272,30 +244,6 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
return &AccountConfig{Azure: &AzureAccountConfig{ResourceGroups: config.Config.Azure.ResourceGroups}}, nil
case CloudProviderTypeGCP:
if config.Config.GCP == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if err := validateGCPRegion(config.Config.GCP.DeploymentRegion); err != nil {
return nil, err
}
if len(config.Config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
if config.Config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.Config.GCP.DeploymentProjectID,
ProjectIDs: config.Config.GCP.ProjectIDs,
DeploymentRegion: config.Config.GCP.DeploymentRegion,
},
}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -384,16 +332,15 @@ func (config *PostableAccountConfig) SetAgentVersion(agentVersion string) {
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
// NOTE: this entertains first non-null provider's config.
func (config *AccountConfig) ToJSON() ([]byte, error) {
switch {
case config.AWS != nil:
if config.AWS != nil {
return json.Marshal(config.AWS)
case config.Azure != nil:
return json.Marshal(config.Azure)
case config.GCP != nil:
return json.Marshal(config.GCP)
default:
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
if config.Azure != nil {
return json.Marshal(config.Azure)
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
func NewIngestionKeyName(provider CloudProviderType) string {

View File

@@ -50,7 +50,6 @@ type IntegrationConfig struct {
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureIntegrationConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPIntegrationConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.

View File

@@ -63,7 +63,6 @@ type StorableCloudIntegrationService struct {
type StorableServiceConfig struct {
AWS *StorableAWSServiceConfig
Azure *StorableAzureServiceConfig
GCP *StorableGCPServiceConfig
}
type StorableAWSServiceConfig struct {
@@ -93,15 +92,6 @@ type StorableAzureMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableGCPServiceConfig struct {
Logs *StorableGCPServiceLogsConfig `json:"logs,omitempty"`
Metrics *StorableGCPServiceMetricsConfig `json:"metrics,omitempty"`
}
type StorableGCPServiceLogsConfig = GCPServiceLogsConfig
type StorableGCPServiceMetricsConfig = GCPServiceMetricsConfig
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
@@ -235,30 +225,6 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
}
return &StorableServiceConfig{Azure: storableAzureServiceConfig}, nil
case CloudProviderTypeGCP:
storableGCPServiceConfig := new(StorableGCPServiceConfig)
if supportedSignals.Logs {
if serviceConfig.GCP.Logs == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for GCP service: %s", serviceID.StringValue())
}
storableGCPServiceConfig.Logs = &StorableGCPServiceLogsConfig{
Enabled: serviceConfig.GCP.Logs.Enabled,
}
}
if supportedSignals.Metrics {
if serviceConfig.GCP.Metrics == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for GCP service: %s", serviceID.StringValue())
}
storableGCPServiceConfig.Metrics = &StorableGCPServiceMetricsConfig{
Enabled: serviceConfig.GCP.Metrics.Enabled,
}
}
return &StorableServiceConfig{GCP: storableGCPServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -280,13 +246,6 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse Azure service config JSON")
}
return &StorableServiceConfig{Azure: azureConfig}, nil
case CloudProviderTypeGCP:
gcpConfig := new(StorableGCPServiceConfig)
err := json.Unmarshal([]byte(jsonStr), gcpConfig)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse GCP service config JSON")
}
return &StorableServiceConfig{GCP: gcpConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -307,13 +266,6 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize Azure service config to JSON")
}
return jsonBytes, nil
case CloudProviderTypeGCP:
jsonBytes, err := json.Marshal(config.GCP)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize GCP service config to JSON")
}
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())

View File

@@ -11,7 +11,6 @@ var (
// cloud providers.
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
)
@@ -22,8 +21,6 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderTypeAWS, nil
case CloudProviderTypeAzure.StringValue():
return CloudProviderTypeAzure, nil
case CloudProviderTypeGCP.StringValue():
return CloudProviderTypeGCP, nil
default:
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}

View File

@@ -1,40 +0,0 @@
package cloudintegrationtypes
type GCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Project ID where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPPostableAccountConfig = GCPAccountConfig
type UpdatableGCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Compute service region where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true"`
}
type GCPConnectionArtifact struct{}
type GCPIntegrationConfig struct{}
type GCPTelemetryCollectionStrategy struct{}
type GCPServiceConfig struct {
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`
Metrics *GCPServiceMetricsConfig `json:"metrics,omitempty" required:"false"`
}
type GCPServiceLogsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}
type GCPServiceMetricsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}

View File

@@ -102,51 +102,6 @@ var (
AzureRegionWestUS = CloudProviderRegion{valuer.NewString("westus")} // West US.
AzureRegionWestUS2 = CloudProviderRegion{valuer.NewString("westus2")} // West US 2.
AzureRegionWestUS3 = CloudProviderRegion{valuer.NewString("westus3")} // West US 3.
// GCP regions.
GCPRegionAfricaSouth1 = CloudProviderRegion{valuer.NewString("africa-south1")} // Johannesburg, South Africa. Africa.
GCPRegionAsiaEast1 = CloudProviderRegion{valuer.NewString("asia-east1")} // Changhua County, Taiwan. APAC.
GCPRegionAsiaEast2 = CloudProviderRegion{valuer.NewString("asia-east2")} // Hong Kong. APAC.
GCPRegionAsiaNortheast1 = CloudProviderRegion{valuer.NewString("asia-northeast1")} // Tokyo, Japan. APAC.
GCPRegionAsiaNortheast2 = CloudProviderRegion{valuer.NewString("asia-northeast2")} // Osaka, Japan. APAC.
GCPRegionAsiaNortheast3 = CloudProviderRegion{valuer.NewString("asia-northeast3")} // Seoul, South Korea. APAC.
GCPRegionAsiaSouth1 = CloudProviderRegion{valuer.NewString("asia-south1")} // Mumbai, India. APAC.
GCPRegionAsiaSouth2 = CloudProviderRegion{valuer.NewString("asia-south2")} // Delhi, India. APAC.
GCPRegionAsiaSoutheast1 = CloudProviderRegion{valuer.NewString("asia-southeast1")} // Jurong West, Singapore. APAC.
GCPRegionAsiaSoutheast2 = CloudProviderRegion{valuer.NewString("asia-southeast2")} // Jakarta, Indonesia. APAC.
GCPRegionAsiaSoutheast3 = CloudProviderRegion{valuer.NewString("asia-southeast3")} // Bangkok, Thailand. APAC.
GCPRegionAustraliaSoutheast1 = CloudProviderRegion{valuer.NewString("australia-southeast1")} // Sydney, Australia. APAC.
GCPRegionAustraliaSoutheast2 = CloudProviderRegion{valuer.NewString("australia-southeast2")} // Melbourne, Australia. APAC.
GCPRegionEuropeCentral2 = CloudProviderRegion{valuer.NewString("europe-central2")} // Warsaw, Poland. Europe.
GCPRegionEuropeNorth1 = CloudProviderRegion{valuer.NewString("europe-north1")} // Hamina, Finland. Europe.
GCPRegionEuropeNorth2 = CloudProviderRegion{valuer.NewString("europe-north2")} // Stockholm, Sweden. Europe.
GCPRegionEuropeSouthwest1 = CloudProviderRegion{valuer.NewString("europe-southwest1")} // Madrid, Spain. Europe.
GCPRegionEuropeWest1 = CloudProviderRegion{valuer.NewString("europe-west1")} // St. Ghislain, Belgium. Europe.
GCPRegionEuropeWest2 = CloudProviderRegion{valuer.NewString("europe-west2")} // London, England. Europe.
GCPRegionEuropeWest3 = CloudProviderRegion{valuer.NewString("europe-west3")} // Frankfurt, Germany. Europe.
GCPRegionEuropeWest4 = CloudProviderRegion{valuer.NewString("europe-west4")} // Eemshaven, Netherlands. Europe.
GCPRegionEuropeWest6 = CloudProviderRegion{valuer.NewString("europe-west6")} // Zurich, Switzerland. Europe.
GCPRegionEuropeWest8 = CloudProviderRegion{valuer.NewString("europe-west8")} // Milan, Italy. Europe.
GCPRegionEuropeWest9 = CloudProviderRegion{valuer.NewString("europe-west9")} // Paris, France. Europe.
GCPRegionEuropeWest10 = CloudProviderRegion{valuer.NewString("europe-west10")} // Berlin, Germany. Europe.
GCPRegionEuropeWest12 = CloudProviderRegion{valuer.NewString("europe-west12")} // Turin, Italy. Europe.
GCPRegionMECentral1 = CloudProviderRegion{valuer.NewString("me-central1")} // Doha, Qatar. Middle East.
GCPRegionMECentral2 = CloudProviderRegion{valuer.NewString("me-central2")} // Dammam, Saudi Arabia. Middle East.
GCPRegionMEWest1 = CloudProviderRegion{valuer.NewString("me-west1")} // Tel Aviv, Israel. Middle East.
GCPRegionNorthamericaNortheast1 = CloudProviderRegion{valuer.NewString("northamerica-northeast1")} // Montréal, Québec, Canada. North America.
GCPRegionNorthamericaNortheast2 = CloudProviderRegion{valuer.NewString("northamerica-northeast2")} // Toronto, Ontario, Canada. North America.
GCPRegionNorthamericaSouth1 = CloudProviderRegion{valuer.NewString("northamerica-south1")} // Querétaro, Mexico. North America.
GCPRegionSouthamericaEast1 = CloudProviderRegion{valuer.NewString("southamerica-east1")} // Osasco, São Paulo, Brazil. South America.
GCPRegionSouthamericaWest1 = CloudProviderRegion{valuer.NewString("southamerica-west1")} // Santiago, Chile. South America.
GCPRegionUSCentral1 = CloudProviderRegion{valuer.NewString("us-central1")} // Council Bluffs, Iowa. North America.
GCPRegionUSEast1 = CloudProviderRegion{valuer.NewString("us-east1")} // Moncks Corner, South Carolina. North America.
GCPRegionUSEast4 = CloudProviderRegion{valuer.NewString("us-east4")} // Ashburn, Virginia. North America.
GCPRegionUSEast5 = CloudProviderRegion{valuer.NewString("us-east5")} // Columbus, Ohio. North America.
GCPRegionUSSouth1 = CloudProviderRegion{valuer.NewString("us-south1")} // Dallas, Texas. North America.
GCPRegionUSWest1 = CloudProviderRegion{valuer.NewString("us-west1")} // The Dalles, Oregon. North America.
GCPRegionUSWest2 = CloudProviderRegion{valuer.NewString("us-west2")} // Los Angeles, California. North America.
GCPRegionUSWest3 = CloudProviderRegion{valuer.NewString("us-west3")} // Salt Lake City, Utah. North America.
GCPRegionUSWest4 = CloudProviderRegion{valuer.NewString("us-west4")} // Las Vegas, Nevada. North America.
)
func Enum() []any {
@@ -172,18 +127,6 @@ func Enum() []any {
AzureRegionSwedenCentral, AzureRegionSwitzerlandNorth, AzureRegionSwitzerlandWest,
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
// GCP regions.
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
}
}
@@ -211,19 +154,6 @@ var SupportedRegions = map[CloudProviderType][]CloudProviderRegion{
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
},
CloudProviderTypeGCP: {
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
},
}
func validateAWSRegion(region string) error {
@@ -245,13 +175,3 @@ func validateAzureRegion(region string) error {
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid Azure region: %s", region)
}
func validateGCPRegion(region string) error {
for _, r := range SupportedRegions[CloudProviderTypeGCP] {
if r.StringValue() == region {
return nil
}
}
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid GCP region: %s", region)
}

View File

@@ -21,7 +21,6 @@ type CloudIntegrationService struct {
type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureServiceConfig `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPServiceConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
@@ -97,7 +96,6 @@ type DataCollected struct {
type TelemetryCollectionStrategy struct {
AWS *AWSTelemetryCollectionStrategy `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureTelemetryCollectionStrategy `json:"azure,omitempty" required:"false" nullable:"false"`
GCP *GCPTelemetryCollectionStrategy `json:"gcp,omitempty" required:"false" nullable:"false"`
}
// Assets represents the collection of dashboards.
@@ -147,10 +145,6 @@ func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.U
if config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config is required for Azure service")
}
case CloudProviderTypeGCP:
if config.GCP == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config is required for GCP service")
}
}
return &CloudIntegrationService{
@@ -267,22 +261,6 @@ func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*S
}
return &ServiceConfig{Azure: azureServiceConfig}, nil
case CloudProviderTypeGCP:
gcpServiceConfig := new(GCPServiceConfig)
if storableServiceConfig.GCP.Logs != nil {
gcpServiceConfig.Logs = &GCPServiceLogsConfig{
Enabled: storableServiceConfig.GCP.Logs.Enabled,
}
}
if storableServiceConfig.GCP.Metrics != nil {
gcpServiceConfig.Metrics = &GCPServiceMetricsConfig{
Enabled: storableServiceConfig.GCP.Metrics.Enabled,
}
}
return &ServiceConfig{GCP: gcpServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -307,10 +285,6 @@ func (service *CloudIntegrationService) Update(provider CloudProviderType, servi
if config.Azure == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "Azure config is required for Azure service")
}
case CloudProviderTypeGCP:
if config.GCP == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "GCP config is required for GCP service")
}
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -332,10 +306,6 @@ func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
logsEnabled := config.Azure.Logs != nil && config.Azure.Logs.Enabled
metricsEnabled := config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
return logsEnabled || metricsEnabled
case CloudProviderTypeGCP:
logsEnabled := config.GCP.Logs != nil && config.GCP.Logs.Enabled
metricsEnabled := config.GCP.Metrics != nil && config.GCP.Metrics.Enabled
return logsEnabled || metricsEnabled
default:
return false
}
@@ -349,8 +319,6 @@ func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
case CloudProviderTypeAzure:
return config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
case CloudProviderTypeGCP:
return config.GCP.Metrics != nil && config.GCP.Metrics.Enabled
default:
return false
}
@@ -363,8 +331,6 @@ func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
case CloudProviderTypeAzure:
return config.Azure.Logs != nil && config.Azure.Logs.Enabled
case CloudProviderTypeGCP:
return config.GCP.Logs != nil && config.GCP.Logs.Enabled
default:
return false
}

View File

@@ -39,9 +39,6 @@ var (
AzureServiceCosmosDB = ServiceID{valuer.NewString("cosmosdb")}
AzureServiceCassandraDB = ServiceID{valuer.NewString("cassandradb")}
AzureServiceRedis = ServiceID{valuer.NewString("redis")}
// GCP services.
GCPServiceCloudSQL = ServiceID{valuer.NewString("cloudsql")}
)
func (ServiceID) Enum() []any {
@@ -73,7 +70,6 @@ func (ServiceID) Enum() []any {
AzureServiceCosmosDB,
AzureServiceCassandraDB,
AzureServiceRedis,
GCPServiceCloudSQL,
}
}
@@ -110,9 +106,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AzureServiceCassandraDB,
AzureServiceRedis,
},
CloudProviderTypeGCP: {
GCPServiceCloudSQL,
},
}
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {

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

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -406,27 +405,34 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
isRawRequest := dashboard.getQueryRequestTypeFromPanelType(widgetData.PanelTypes) == querybuildertypesv5.RequestTypeRaw
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
// build aggregations the same way the frontend does before hitting the query
// range API; raw requests carry no aggregations.
if isRawRequest {
delete(query, "aggregations")
} else {
query["aggregations"] = querybuildertypesv5.CreateAggregation(query, widgetData.PanelTypes)
}
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -0,0 +1,214 @@
package querybuildertypesv5
import (
"regexp"
"strings"
)
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// aggregationExprRegexp matches a function-style aggregation like `count()` or
// `sum(field)` with an optional `as <alias>`, as the frontend's parseAggregations does.
var aggregationExprRegexp = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?`)
// CreateAggregation builds the v5 aggregations for a stored builder query, mirroring
// createAggregation in the frontend's prepareQueryRangePayloadV5.ts. Metrics yield a
// single structured aggregation; logs/traces split their comma-separated expression into
// one aggregation per call, defaulting to count() when nothing parses.
func CreateAggregation(queryData map[string]any, panelType string) []any {
if queryData == nil {
return []any{}
}
if dataSource, _ := queryData["dataSource"].(string); dataSource == "metrics" {
var first map[string]any
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
first, _ = aggs[0].(map[string]any)
}
attribute, _ := queryData["aggregateAttribute"].(map[string]any)
metric := map[string]any{}
setFirstNonEmpty(metric, "metricName", first["metricName"], attribute["key"])
setFirstNonEmpty(metric, "temporality", first["temporality"], attribute["temporality"])
setFirstNonEmpty(metric, "timeAggregation", first["timeAggregation"], queryData["timeAggregation"])
setFirstNonEmpty(metric, "spaceAggregation", first["spaceAggregation"], queryData["spaceAggregation"])
if panelType == "table" || panelType == "pie" || panelType == "value" {
setFirstNonEmpty(metric, "reduceTo", first["reduceTo"], queryData["reduceTo"])
}
return []any{metric}
}
aggs, ok := queryData["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return []any{map[string]any{"expression": "count()"}}
}
result := []any{}
for _, agg := range aggs {
aggMap, _ := agg.(map[string]any)
expression, _ := aggMap["expression"].(string)
alias, _ := aggMap["alias"].(string)
parsed := parseAggregations(expression, alias)
if len(parsed) == 0 {
result = append(result, map[string]any{"expression": "count()"})
continue
}
result = append(result, parsed...)
}
return result
}
// parseAggregations extracts each function-style call from a (possibly comma-separated)
// aggregation expression, attaching the inline `as` alias or the fallback alias.
func parseAggregations(expression, fallbackAlias string) []any {
result := []any{}
for _, match := range aggregationExprRegexp.FindAllStringSubmatch(expression, -1) {
agg := map[string]any{"expression": match[1]}
if alias := match[2]; alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
} else if fallbackAlias != "" {
agg["alias"] = fallbackAlias
}
result = append(result, agg)
}
return result
}
// setFirstNonEmpty sets key to the first value that is neither nil nor "", mirroring the
// JS `a || b` fallback the frontend uses for the metric aggregation fields.
func setFirstNonEmpty(target map[string]any, key string, values ...any) {
for _, v := range values {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
target[key] = v
return
}
}

View File

@@ -0,0 +1,176 @@
package querybuildertypesv5
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateAggregation(t *testing.T) {
testCases := []struct {
description string
queryData map[string]any
panelType string
expectedOutput []any
}{
{
description: "nil query data yields no aggregations",
queryData: nil,
expectedOutput: []any{},
},
{
description: "single logs expression is left untouched",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "comma separated trace expressions are split into one object each",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "count(), sum(price)"}}},
expectedOutput: []any{
map[string]any{"expression": "count()"},
map[string]any{"expression": "sum(price)"},
},
},
{
description: "inline alias is preserved and unquoted",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as 'total', sum(price) as revenue"}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "total"},
map[string]any{"expression": "sum(price)", "alias": "revenue"},
},
},
{
description: "space separated expressions split with an unquoted alias on the first only",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count() as cnt avg(code.lineno) "}}},
expectedOutput: []any{
map[string]any{"expression": "count()", "alias": "cnt"},
map[string]any{"expression": "avg(code.lineno)"},
},
},
{
description: "fallback alias is applied when expression has no inline alias",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "count()", "alias": "hits"}}},
expectedOutput: []any{map[string]any{"expression": "count()", "alias": "hits"}},
},
{
description: "commas inside function arguments do not split the expression",
queryData: map[string]any{"dataSource": "traces", "aggregations": []any{map[string]any{"expression": "countIf(day > 10, status)"}}},
expectedOutput: []any{map[string]any{"expression": "countIf(day > 10, status)"}},
},
{
description: "unparseable expression falls back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{map[string]any{"expression": "not-an-aggregation"}}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "empty aggregations fall back to count()",
queryData: map[string]any{"dataSource": "logs", "aggregations": []any{}},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "missing aggregations fall back to count()",
queryData: map[string]any{"dataSource": "traces"},
expectedOutput: []any{map[string]any{"expression": "count()"}},
},
{
description: "metric aggregation is built from the first aggregation",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"temporality": "delta",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric omits temporality when empty, matching the frontend `|| undefined`",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"temporality": "",
"reduceTo": "avg",
"aggregations": []any{map[string]any{
"metricName": "cpu_usage",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "cpu_usage",
"timeAggregation": "sum",
"spaceAggregation": "avg",
"reduceTo": "avg",
}},
},
{
description: "metric includes reduceTo for table/pie/value panels",
panelType: "table",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
{
description: "metric drops reduceTo for other panels even when query data has it",
panelType: "graph",
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "avg",
}},
},
expectedOutput: []any{map[string]any{
"metricName": "http_requests_total",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
},
{
description: "metric falls back to legacy aggregateAttribute and top-level fields",
queryData: map[string]any{
"dataSource": "metrics",
"aggregateAttribute": map[string]any{"key": "legacy_metric", "temporality": "cumulative"},
"timeAggregation": "avg",
"spaceAggregation": "max",
},
expectedOutput: []any{map[string]any{
"metricName": "legacy_metric",
"temporality": "cumulative",
"timeAggregation": "avg",
"spaceAggregation": "max",
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.expectedOutput, CreateAggregation(testCase.queryData, testCase.panelType))
})
}
}

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

@@ -143,7 +143,7 @@ def test_get_credentials_unsupported_provider(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/unknown/credentials"),
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/credentials"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -56,14 +56,14 @@ def test_create_account_unsupported_provider(
) -> None:
"""Test that creating an account with an unsupported cloud provider returns 400."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "unknown"
cloud_provider = "gcp"
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {"unknown": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}},
"config": {"gcp": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}},
"credentials": {
"sigNozApiURL": "https://test.signoz.cloud",
"sigNozApiKey": "test-key",

View File

@@ -341,7 +341,7 @@ def test_list_services_unsupported_provider(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/unknown/services"),
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -1,3 +1,4 @@
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
@@ -8,6 +9,7 @@ from sqlalchemy import sql
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logs import Logs
from fixtures.metrics import Metrics
from fixtures.types import Operation, SigNoz, TestContainerDocker
@@ -205,6 +207,147 @@ def test_public_dashboard_widget_query_range(
assert resp.status_code == HTTPStatus.BAD_REQUEST
def test_public_dashboard_widget_query_range_multi_aggregation(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
):
"""
A logs/traces widget stores several aggregations as one comma-separated expression
(e.g. "count(), sum(latency_ms)"). The public widget query path must split it into
one aggregation per call, mirroring the frontend, before handing it to the querier.
If the split does not happen the querier receives a single malformed aggregation and
the request fails - so a successful response with two aggregations proves the split.
"""
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Unique per-run service so the widget query only sees this run's logs.
service_name = f"multiagg-public-{uuid.uuid4()}"
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(minutes=5),
resources={"service.name": service_name},
attributes={"latency_ms": 100},
body="multi-agg log 1",
),
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": service_name},
attributes={"latency_ms": 200},
body="multi-agg log 2",
),
Logs(
timestamp=now - timedelta(minutes=1),
resources={"service.name": service_name},
attributes={"latency_ms": 300},
body="multi-agg log 3",
),
]
)
dashboard_req = {
"title": "Multi Aggregation Public Widget",
"description": "Comma-separated aggregations must be split on the public query path",
"version": "v5",
"widgets": [
{
"id": "b2c0a1d4-9f3e-4c2a-8a7b-1e2f3a4b5c6d",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregations": [{"expression": "count(), sum(latency_ms)"}],
"dataSource": "logs",
"disabled": False,
"expression": "A",
"filter": {"expression": f"service.name = '{service_name}'"},
"functions": [],
"groupBy": [],
"having": {"expression": ""},
"legend": "",
"limit": 10,
"orderBy": [],
"queryName": "A",
"source": "",
"stepInterval": 60,
}
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"id": "c3d1b2e5-0a4f-4d3b-9b8c-2f3a4b5c6d7e",
"promql": [{"disabled": False, "legend": "", "name": "A", "query": ""}],
"queryType": "builder",
},
}
],
}
create_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/dashboards"),
json=dashboard_req,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert create_response.status_code == HTTPStatus.CREATED
dashboard_id = create_response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
json={"timeRangeEnabled": False, "defaultTimeRange": "30m"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
public_path = response.json()["data"]["publicPath"]
public_dashboard_id = public_path.split("/public/dashboard/")[-1]
resp = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/public/dashboards/{public_dashboard_id}/widgets/0/query_range"),
timeout=5,
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "success"
# The single "count(), sum(latency_ms)" expression must have been split into two
# separate aggregations on the way to the querier.
results = body["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 2
# With no group-by each aggregation produces a single series.
for aggregation in aggregations:
assert len(aggregation["series"]) == 1
assert len(aggregation["series"][0]["values"]) > 0
# Each aggregation is computed independently over the three logs: count() totals 3,
# sum(latency_ms) totals 100 + 200 + 300 = 600. Summing each aggregation's points is
# robust to step bucketing and to the order the aggregations come back in.
aggregation_totals = sorted(
sum(point["value"] for series in aggregation["series"] for point in series["values"])
for aggregation in aggregations
)
assert aggregation_totals == [3, 600]
def test_anonymous_role_has_public_dashboard_permission(
request: pytest.FixtureRequest,
signoz: SigNoz,

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)