Compare commits

...

5 Commits

Author SHA1 Message Date
nityanandagohain
915b1e5a72 chore: add enable prefix 2026-06-22 20:43:32 +05:30
nityanandagohain
6e70d881da chore: add flag for ai observability 2026-06-22 15:36:39 +05:30
Abhi kumar
d5617657b5 fix(dashboard): clickhouse table panel collapses value columns onto query name (#11794)
* fix(dashboard): clickhouse table panel collapses value columns onto query name

A table/scalar panel backed by a ClickHouse SQL query rendered every
aggregation column with the header "A" (the query name) and the same value in
each, while only the group columns (e.g. service.name) showed correctly.

Root cause: the scalar-response column-naming utils derive a value column's
display name and row-data key from request-side aggregation metadata, which
only exists for builder_query envelopes. A clickhouse_sql query has none, so
getColName/getColId fell through to the query name for every value column.
Sharing one id ("A") collapsed all value columns onto a single row key, so the
last column written (total_requests) overwrote the rest.

The backend already returns correct data: readAsScalar names each ClickHouse
SELECT column with its real SQL alias and a unique aggregationIndex. This is a
frontend-only consumption fix.

Fix: when a column belongs to a clickhouse_sql query (determined from the
request's query type, not a name heuristic), name and key it by the response
column's real SQL alias. Builder queries are unchanged; formulas/promql keep
the legend || queryName fallback. Applied to both the V1 converter
(convertV5Response.ts, the live table-panel path) and the V2 path
(prepareScalarTables.ts).

* chore: minor type fix
2026-06-22 08:31:06 +00:00
Nityananda Gohain
5600576722 chore: add search and override filters in pricing model list api (#11735) 2026-06-22 08:23:12 +00:00
Vikrant Gupta
f84b818552 feat(authz): add unified role APIs (#11798)
* feat(authz): add unified role APIs

* feat(authz): update openapi spec

* feat(authz): restructure the chunked write to the openfga server

* feat(authz): fix the order for minimal gitdiff

* feat(authz): update openapi spec

* feat(authz): fix the create API

* feat(authz): better error messages
2026-06-22 07:31:40 +00:00
30 changed files with 1149 additions and 141 deletions

View File

@@ -647,8 +647,12 @@ components:
type: string
name:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- name
- description
- transactionGroups
type: object
AuthtypesPostableRotateToken:
properties:
@@ -703,6 +707,34 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
- transactionGroups
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -736,11 +768,35 @@ components:
- relation
- object
type: object
AuthtypesTransactionGroup:
properties:
objectGroup:
$ref: '#/components/schemas/CoretypesObjectGroup'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- objectGroup
type: object
AuthtypesTransactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUpdatableRole:
properties:
description:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- description
- transactionGroups
type: object
AuthtypesUserRole:
properties:
createdAt:
@@ -10253,6 +10309,15 @@ paths:
name: limit
schema:
type: integer
- in: query
name: q
schema:
type: string
- in: query
name: isOverride
schema:
nullable: true
type: boolean
responses:
"200":
content:
@@ -11058,7 +11123,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
status:
type: string
required:
@@ -11093,7 +11158,7 @@ paths:
tags:
- role
patch:
deprecated: false
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
@@ -11154,6 +11219,68 @@ paths:
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
operationId: UpdateRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableRole'
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: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
@@ -11233,7 +11360,7 @@ paths:
tags:
- role
patch:
deprecated: false
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects

View File

@@ -179,13 +179,36 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) 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.Create(ctx, role)
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
return err
}
if existingRole != nil {
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
err = provider.Write(ctx, tuples, nil)
if err != nil {
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
@@ -213,6 +236,26 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, 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())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
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 {
@@ -247,6 +290,36 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
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 {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
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 {
@@ -286,7 +359,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
if err != nil {
return err
}
@@ -302,7 +375,12 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
if err := provider.Write(ctx, nil, tuples); err != nil {
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
}
@@ -361,7 +439,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
@@ -371,26 +449,10 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
Object: objectType.StringValue() + ":",
})
if err != nil {
return err
return nil, err
}
tuples = append(tuples, typeTuples...)
}
if len(tuples) == 0 {
return nil
}
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
if end > len(tuples) {
end = len(tuples)
}
err := provider.Write(ctx, nil, tuples[idx:end])
if err != nil {
return err
}
}
return nil
return tuples, nil
}

View File

@@ -98,6 +98,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -20,6 +20,7 @@ import type {
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
@@ -31,6 +32,7 @@ import type {
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -365,6 +367,7 @@ export const invalidateGetRole = async (
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
@@ -436,6 +439,7 @@ export type PatchRoleMutationBody =
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
@@ -462,6 +466,105 @@ export const usePatchRole = <
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
*/
export const updateRole = (
{ id }: UpdateRolePathParameters,
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableRoleDTO,
signal,
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateRole'];
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 updateRole>>,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody =
| BodyType<AuthtypesUpdatableRoleDTO>
| undefined;
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role
*/
export const useUpdateRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
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
@@ -565,6 +668,7 @@ export const invalidateGetObjects = async (
/**
* 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 = (
@@ -636,6 +740,7 @@ export type PatchObjectsMutationBody =
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <

View File

@@ -2224,15 +2224,31 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
password?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface AuthtypesTransactionGroupDTO {
objectGroup: CoretypesObjectGroupDTO;
relation: AuthtypesRelationDTO;
}
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type string
*/
name: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesPostableRotateTokenDTO {
@@ -2275,6 +2291,40 @@ export interface AuthtypesRoleDTO {
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
/**
* @type boolean
@@ -2295,6 +2345,14 @@ export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
/**
* @type string
*/
description: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesUserRoleDTO {
/**
* @type string
@@ -3065,14 +3123,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
@@ -9450,6 +9500,16 @@ export type ListLLMPricingRulesParams = {
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
q?: string;
/**
* @type boolean,null
* @description undefined
*/
isOverride?: boolean | null;
};
export type ListLLMPricingRules200 = {
@@ -9559,7 +9619,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: AuthtypesRoleWithTransactionGroupsDTO;
/**
* @type string
*/
@@ -9569,6 +9629,9 @@ export type GetRole200 = {
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;

View File

@@ -274,4 +274,110 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
const scalar: ScalarData = {
columns: [
{
name: 'service.name',
queryName: 'A',
aggregationIndex: 0,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'current_availability',
queryName: 'A',
aggregationIndex: 0,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'error_budget_remaining',
queryName: 'A',
aggregationIndex: 1,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'budget_status',
queryName: 'A',
aggregationIndex: 2,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'total_requests',
queryName: 'A',
aggregationIndex: 4,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
],
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
};
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
// A clickhouse_sql envelope contributes no aggregation metadata.
const params = makeBaseParams('scalar', [
{
type: 'clickhouse_sql',
spec: {
name: 'A',
query: 'SELECT ...',
disabled: false,
},
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
// formatForWeb=true is the table-panel path.
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
const [tableEntry] = result.payload.data.result;
// Headers keep their real names instead of collapsing to "A".
expect(tableEntry.table?.columns).toStrictEqual([
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'current_availability',
queryName: 'A',
isValueColumn: true,
id: 'current_availability',
},
{
name: 'error_budget_remaining',
queryName: 'A',
isValueColumn: true,
id: 'error_budget_remaining',
},
{
name: 'budget_status',
queryName: 'A',
isValueColumn: false,
id: 'budget_status',
},
{
name: 'total_requests',
queryName: 'A',
isValueColumn: true,
id: 'total_requests',
},
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
});
});
});

View File

@@ -15,6 +15,7 @@ function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -39,16 +40,32 @@ function getColName(
return alias || expression || col.queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
return legend || col.queryName;
}
function getColId(
col: ScalarData['columns'][number],
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
const aggregation =
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
const expression = aggregation?.expression || '';
@@ -141,6 +158,7 @@ function convertScalarDataArrayToTable(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
// If no scalar data, return empty structure
@@ -166,10 +184,10 @@ function convertScalarDataArrayToTable(
// Collect columns for this specific query
const columns = scalarData?.columns?.map((col) => ({
name: getColName(col, legendMap, aggregationPerQuery),
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery),
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
}));
// Process rows for this specific query
@@ -177,8 +195,13 @@ function convertScalarDataArrayToTable(
const rowData: Record<string, any> = {};
scalarData?.columns?.forEach((col, colIndex) => {
const columnName = getColName(col, legendMap, aggregationPerQuery);
const columnId = getColId(col, aggregationPerQuery);
const columnName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
rowData[columnId || columnName] = dataRow[colIndex];
});
@@ -202,6 +225,7 @@ function convertScalarWithFormatForWeb(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
if (!scalarDataArray || scalarDataArray.length === 0) {
return [];
@@ -210,13 +234,18 @@ function convertScalarWithFormatForWeb(
return scalarDataArray.map((scalarData) => {
const columns =
scalarData.columns?.map((col) => {
const colName = getColName(col, legendMap, aggregationPerQuery);
const colName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
name: colName,
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery),
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
};
}) || [];
@@ -289,6 +318,7 @@ function convertV5DataByType(
v5Data: any,
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): MetricRangePayloadV3['data'] {
switch (v5Data?.type) {
case 'time_series': {
@@ -307,6 +337,7 @@ function convertV5DataByType(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
resultType: 'scalar',
@@ -373,6 +404,15 @@ export function convertV5ResponseToLegacy(
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).
const clickhouseQueryNames = new Set<string>(
(params?.compositeQuery?.queries ?? [])
.filter((query) => query.type === 'clickhouse_sql')
.map((query) => (query.spec as { name?: string })?.name)
.filter((name): name is string => !!name),
);
// If formatForWeb is true, return as-is (like existing logic)
if (formatForWeb && v5Data?.type === 'scalar') {
const scalarData = v5Data.data.results as ScalarData[];
@@ -380,6 +420,7 @@ export function convertV5ResponseToLegacy(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
@@ -402,6 +443,7 @@ export function convertV5ResponseToLegacy(
v5Data,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
// Create legacy-compatible response structure

View File

@@ -12,4 +12,5 @@ export enum FeatureKeys {
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
}

View File

@@ -116,7 +116,8 @@ function CreateRoleModal({
} else {
const data: AuthtypesPostableRoleDTO = {
name: values.name,
...(values.description ? { description: values.description } : {}),
description: values.description || '',
transactionGroups: [],
};
createRole({ data });
}

View File

@@ -5,6 +5,7 @@ import type {
import {
extractAggregationsPerQuery,
extractClickhouseQueryNames,
prepareScalarTables,
} from '../prepareScalarTables';
@@ -56,6 +57,24 @@ describe('extractAggregationsPerQuery', () => {
});
});
describe('extractClickhouseQueryNames', () => {
it('collects names of clickhouse_sql queries, ignoring other envelope types', () => {
const request = requestWith([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
{
type: 'builder_query',
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
},
{ type: 'promql', spec: { name: 'P', query: 'up' } },
]);
expect(extractClickhouseQueryNames(request)).toStrictEqual(new Set(['A']));
});
it('returns an empty set for an undefined payload', () => {
expect(extractClickhouseQueryNames(undefined)).toStrictEqual(new Set());
});
});
describe('prepareScalarTables', () => {
it('builds keyed rows with group + aggregation columns (V1 getColName/getColId parity)', () => {
const [table] = prepareScalarTables({
@@ -194,18 +213,115 @@ describe('prepareScalarTables', () => {
expect(tables.map((t) => t.queryName)).toStrictEqual(['A', 'B']);
});
it('queries without aggregation metadata fall back to legend || queryName', () => {
it('clickhouse_sql single value column uses the SQL alias over the legend', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{
name: 'current_availability',
queryName: 'A',
columnType: 'aggregation',
},
],
[],
),
],
legendMap: { A: 'Legend' },
requestPayload: requestWith([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT ...' } },
]),
});
// The query is clickhouse_sql, so the response column's real SQL alias is
// used for both header and key (a single legend can't be the column name).
expect(table.columns[0].name).toBe('current_availability');
expect(table.columns[0].id).toBe('current_availability');
});
it('non-clickhouse query without aggregation metadata falls back to legend || queryName', () => {
const [table] = prepareScalarTables({
results: [
// Formulas/promql carry placeholder names and are not clickhouse_sql,
// so they must not adopt the response column name.
scalarResult(
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
[],
),
],
legendMap: { A: 'Legend' },
requestPayload: requestWith([]),
requestPayload: requestWith([
{ type: 'promql', spec: { name: 'A', query: 'up' } },
]),
});
expect(table.columns[0].name).toBe('Legend');
expect(table.columns[0].id).toBe('A');
});
it('clickhouse_sql query keeps each value column distinct (regression: all-"A" collapse)', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{ name: 'service.name', queryName: 'A', columnType: 'group' },
{
name: 'current_availability',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
{
name: 'error_budget_remaining',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 1,
},
{ name: 'budget_status', queryName: 'A', columnType: 'group' },
{
name: 'total_requests',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 4,
},
],
[['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
),
],
legendMap: { A: '' },
// A clickhouse_sql envelope contributes no aggregation metadata.
requestPayload: requestWith([
{
type: 'clickhouse_sql',
spec: { name: 'A', query: 'SELECT ...' },
},
]),
});
// Headers keep their real names instead of collapsing to "A".
expect(table.columns.map((col) => col.name)).toStrictEqual([
'service.name',
'current_availability',
'error_budget_remaining',
'budget_status',
'total_requests',
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(table.columns.map((col) => col.id)).toStrictEqual([
'service.name',
'current_availability',
'error_budget_remaining',
'budget_status',
'total_requests',
]);
expect(table.rows).toStrictEqual([
{
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
},
]);
});
});

View File

@@ -1,5 +1,6 @@
import type {
Querybuildertypesv5ColumnDescriptorDTO,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -44,16 +45,43 @@ export function extractAggregationsPerQuery(
return perQuery;
}
/**
* Names of the request's clickhouse_sql queries. These have no aggregation
* metadata, but their value columns carry the user's real SQL alias in the
* response `col.name` — so columns of these queries are named/keyed by that
* alias rather than collapsing onto the query name. Builder/formula/promql use
* placeholder names (`__result`/`__result_N`) and are excluded here.
*/
export function extractClickhouseQueryNames(
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): Set<string> {
const names = new Set<string>();
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.clickhouse_sql) {
return;
}
const spec = (envelope as Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO)
.spec;
if (spec?.name) {
names.add(spec.name);
}
});
return names;
}
/**
* Column display name. Group columns keep their field name; aggregation
* columns resolve alias > legend > expression > queryName — with the legend
* skipped when the query has multiple aggregations, because one legend can't
* label several value columns. (Port of V1 `getColName`.)
* label several value columns. clickhouse_sql columns have no aggregation
* metadata, so their value columns are named by the real SQL alias the
* response carries in `col.name`. (Port of V1 `getColName`.)
*/
function getColName(
col: Querybuildertypesv5ColumnDescriptorDTO,
legendMap: Record<string, string>,
aggregationsPerQuery: AggregationsPerQuery,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -74,6 +102,13 @@ function getColName(
return alias || expression || queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(queryName)) {
return col.name;
}
return legend || queryName;
}
@@ -85,15 +120,23 @@ function getColName(
function getColId(
col: Querybuildertypesv5ColumnDescriptorDTO,
aggregationsPerQuery: AggregationsPerQuery,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
const queryName = col.queryName ?? '';
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(queryName)) {
return col.name;
}
const aggregations = aggregationsPerQuery[queryName];
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
if ((aggregations?.length || 0) > 1 && expression) {
return `${queryName}.${expression}`;
}
@@ -119,6 +162,7 @@ export function prepareScalarTables({
requestPayload,
}: PrepareScalarTablesArgs): PanelTable[] {
const aggregationsPerQuery = extractAggregationsPerQuery(requestPayload);
const clickhouseQueryNames = extractClickhouseQueryNames(requestPayload);
return results.map((scalarData) => {
if (!scalarData) {
@@ -132,10 +176,10 @@ export function prepareScalarTables({
const queryName = scalarData.columns?.[0]?.queryName ?? '';
const columns: PanelTableColumn[] = (scalarData.columns ?? []).map((col) => ({
name: getColName(col, legendMap, aggregationsPerQuery),
name: getColName(col, legendMap, aggregationsPerQuery, clickhouseQueryNames),
queryName: col.queryName ?? '',
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationsPerQuery),
id: getColId(col, aggregationsPerQuery, clickhouseQueryNames),
}));
const rows = (scalarData.data ?? []).map((dataRow) => {

View File

@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
Response: new(authtypes.RoleWithTransactionGroups),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -91,6 +91,60 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateRole",
Tags: []string{"role"},
Summary: "Update role",
Description: "This endpoint updates a role",
Request: new(authtypes.UpdatableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
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.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
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{
@@ -131,7 +185,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
@@ -158,7 +212,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
@@ -172,32 +226,5 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -33,8 +33,8 @@ type AuthZ interface {
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// Creates the role.
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
@@ -48,12 +48,18 @@ type AuthZ interface {
// 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
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -101,6 +107,8 @@ type Handler interface {
PatchObjects(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Check(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)

View File

@@ -83,6 +83,10 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -168,7 +172,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
@@ -180,6 +184,10 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
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")
}

View File

@@ -212,6 +212,30 @@ func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, o
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
maxTuplesPerWrite := server.config.OpenFGA.MaxTuplesPerWrite
if len(additions)+len(deletions) <= maxTuplesPerWrite {
return server.write(ctx, additions, deletions)
}
for idx := 0; idx < len(additions); idx += maxTuplesPerWrite {
end := min(idx+maxTuplesPerWrite, len(additions))
if err := server.write(ctx, additions[idx:end], nil); err != nil {
return err
}
}
for idx := 0; idx < len(deletions); idx += maxTuplesPerWrite {
end := min(idx+maxTuplesPerWrite, len(deletions))
if err := server.write(ctx, nil, deletions[idx:end]); err != nil {
return err
}
}
return nil
}
func (server *Server) write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
if len(additions) == 0 && len(deletions) == 0 {
return nil
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID))
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, role)
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
}
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
@@ -224,6 +224,48 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Update(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.UpdatableRole)
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
}
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)

View File

@@ -3,15 +3,16 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
)
func MustNewRegistry() featuretypes.Registry {
@@ -88,6 +89,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableAIObservability,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether ai observability is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -54,7 +54,7 @@ func (h *handler) List(rw http.ResponseWriter, r *http.Request) {
return
}
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit)
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit, q.Search, q.IsOverride)
if err != nil {
render.Error(rw, err)
return

View File

@@ -21,8 +21,8 @@ func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
return &module{store: store}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
return module.store.List(ctx, orgID, offset, limit)
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
return module.store.List(ctx, orgID, offset, limit, search, isOverride)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
@@ -108,7 +108,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
}
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
rules, _, err := module.List(ctx, orgID, 0, 10000)
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
if err != nil {
return nil, err
}

View File

@@ -17,14 +17,25 @@ func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
count, err := store.sqlstore.
query := store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID).
Where("org_id = ?", orgID)
if search != "" {
like := "%" + search + "%"
query = query.Where("(LOWER(model) LIKE LOWER(?) OR LOWER(provider) LIKE LOWER(?))", like, like)
}
if isOverride != nil {
query = query.Where("is_override = ?", *isOverride)
}
count, err := query.
Order("created_at DESC").
Offset(offset).
Limit(limit).

View File

@@ -13,7 +13,7 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error)
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
Delete(ctx context.Context, orgID, id valuer.UUID) error

View File

@@ -1678,6 +1678,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -20,6 +20,7 @@ var (
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")
ErrCodeRoleFailedTransactionsFromString = errors.MustNewCode("role_failed_transactions_from_string")
ErrCodeRoleUnsupported = errors.MustNewCode("role_unsupported")
ErrCodeRoleHasUserAssignees = errors.MustNewCode("role_has_user_assignees")
@@ -72,9 +73,20 @@ type Role struct {
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
}
type RoleWithTransactionGroups struct {
*Role
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
type PostableRole struct {
Name string `json:"name" required:"true"`
Description string `json:"description"`
Name string `json:"name" required:"true"`
Description string `json:"description" required:"true"`
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
type UpdatableRole struct {
Description string `json:"description" required:"true"`
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
type PatchableRole struct {
@@ -97,6 +109,22 @@ func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID
}
}
func NewRoleWithTransactionGroups(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
role := NewRole(name, description, roleType, orgID)
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
}
}
func MakeRoleWithTransactionGroups(role *Role, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
}
}
func NewManagedRoles(orgID valuer.UUID) []*Role {
return []*Role{
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
@@ -118,6 +146,18 @@ func (role *Role) PatchMetadata(description string) error {
return nil
}
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
err := role.ErrIfManaged()
if err != nil {
return err
}
role.Description = description
role.TransactionGroups = transactionGroups
role.UpdatedAt = time.Now()
return nil
}
func (role *Role) ErrIfManaged() error {
if role.Type == RoleTypeManaged {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "cannot edit/delete managed role: %s", role.Name)
@@ -127,31 +167,58 @@ func (role *Role) ErrIfManaged() error {
}
func (role *PostableRole) UnmarshalJSON(data []byte) error {
type shadowPostableRole struct {
Name string `json:"name"`
Description string `json:"description"`
}
type Alias PostableRole
var temp Alias
var shadowRole shadowPostableRole
if err := json.Unmarshal(data, &shadowRole); err != nil {
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if shadowRole.Name == "" {
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
}
if match := roleNameRegex.MatchString(shadowRole.Name); !match {
if match := roleNameRegex.MatchString(temp.Name); !match {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must contain only lowercase letters (a-z) and hyphens (-), and be at most 50 characters long.")
}
if strings.HasPrefix(shadowRole.Name, managedRolePrefix) {
if strings.HasPrefix(temp.Name, managedRolePrefix) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "role name cannot start with %q as it is reserved for SigNoz managed roles.", managedRolePrefix)
}
role.Name = shadowRole.Name
role.Description = shadowRole.Description
if temp.TransactionGroups == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to create a role with no transaction groups")
}
role.Name = temp.Name
role.Description = temp.Description
role.TransactionGroups = temp.TransactionGroups
return nil
}
func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
shadow := struct {
Description *string `json:"description"`
TransactionGroups TransactionGroups `json:"transactionGroups"`
}{}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}
// A pointer distinguishes an omitted/null description from an explicit empty string: the field
// must be sent (update reconciles to exactly what is given), but an empty string is allowed so a
// caller can deliberately clear the description.
if shadow.Description == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "description is required").WithAdditional("send an empty string to clear the description")
}
if shadow.TransactionGroups == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to clear the role's transaction groups")
}
role.Description = *shadow.Description
role.TransactionGroups = shadow.TransactionGroups
return nil
}

View File

@@ -13,6 +13,13 @@ type Transaction struct {
Object coretypes.Object `json:"object" required:"true"`
}
type TransactionGroup struct {
Relation Relation `json:"relation" required:"true"`
ObjectGroup coretypes.ObjectGroup `json:"objectGroup" required:"true"`
}
type TransactionGroups []*TransactionGroup
type GettableTransaction struct {
Relation Relation `json:"relation" required:"true"`
Object coretypes.Object `json:"object" required:"true"`
@@ -32,6 +39,18 @@ func NewTransaction(relation Relation, object coretypes.Object) (*Transaction, e
return &Transaction{ID: valuer.GenerateUUID(), Relation: relation, Object: object}, nil
}
func NewTransactionGroup(relation Relation, objectGroup coretypes.ObjectGroup) (*TransactionGroup, error) {
if err := coretypes.ErrIfVerbNotValidForResource(relation.Verb, objectGroup.Resource); err != nil {
return nil, err
}
if _, err := coretypes.NewObjectsFromObjectGroup(objectGroup); err != nil {
return nil, err
}
return &TransactionGroup{Relation: relation, ObjectGroup: objectGroup}, nil
}
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
gettableTransactions := make([]*GettableTransaction, len(results))
for i, result := range results {
@@ -45,6 +64,10 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
return gettableTransactions
}
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
return desired.subtract(groups), groups.subtract(desired)
}
func (transaction *Transaction) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation
@@ -65,6 +88,71 @@ func (transaction *Transaction) UnmarshalJSON(data []byte) error {
return nil
}
func (transactionGroup *TransactionGroup) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation
ObjectGroup coretypes.ObjectGroup
}{}
err := json.Unmarshal(data, &shadow)
if err != nil {
return err
}
group, err := NewTransactionGroup(shadow.Relation, shadow.ObjectGroup)
if err != nil {
return err
}
*transactionGroup = *group
return nil
}
func (transaction *Transaction) TransactionKey() string {
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
}
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
otherSelectors := other.selectorSet()
order := make([]string, 0)
grouped := make(map[string]*TransactionGroup)
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
continue
}
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
out, ok := grouped[groupKey]
if !ok {
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
grouped[groupKey] = out
order = append(order, groupKey)
}
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
}
}
result := make(TransactionGroups, 0, len(order))
for _, key := range order {
result = append(result, grouped[key])
}
return result
}
func (groups TransactionGroups) selectorSet() map[string]struct{} {
set := make(map[string]struct{})
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
set[group.selectorKey(selector)] = struct{}{}
}
}
return set
}
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
}

View File

@@ -47,14 +47,58 @@ func NewTuplesFromTransactions(transactions []*Transaction, subject string, orgI
return tuples, nil
}
// NewTuplesFromTransactionsWithCorrelations converts transactions to tuples for BatchCheck,
// and for each transaction whose selector is not already a wildcard, generates an additional
// tuple with the wildcard selector. This ensures that permissions granted via wildcard
// selectors (e.g., dashboard:*) are checked alongside exact selectors (e.g., dashboard:abc-123).
//
// Returns:
// - tuples: all tuples to check (exact + correlated), keyed by transaction ID or generated correlation ID
// - correlations: maps transaction ID to a slice of correlation IDs for the additional tuples
func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionGroups []*TransactionGroup) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
subject := MustNewSubject(coretypes.NewResourceRole(), name, orgID, &coretypes.VerbAssignee)
for _, transactionGroup := range transactionGroups {
if err := coretypes.ErrIfVerbNotValidForResource(transactionGroup.Relation.Verb, transactionGroup.ObjectGroup.Resource); err != nil {
return nil, err
}
resource, err := coretypes.NewResourceFromTypeAndKind(transactionGroup.ObjectGroup.Resource.Type, transactionGroup.ObjectGroup.Resource.Kind)
if err != nil {
return nil, err
}
objectGroupTuples := NewTuples(resource, subject, transactionGroup.Relation, transactionGroup.ObjectGroup.Selectors, orgID)
tuples = append(tuples, objectGroupTuples...)
}
return tuples, nil
}
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*TransactionGroup {
objectsByRelation := make(map[string][]*coretypes.Object)
for _, tuple := range tuples {
verb, err := coretypes.NewVerb(tuple.GetRelation())
if err != nil {
panic(err)
}
object := coretypes.MustNewObjectFromString(tuple.GetObject())
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
}
transactionGroups := make([]*TransactionGroup, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByRelation[verb.StringValue()]
if len(objects) == 0 {
continue
}
for _, objectGroup := range coretypes.NewObjectGroupsFromObjects(objects) {
transactionGroups = append(transactionGroups, &TransactionGroup{
Relation: Relation{Verb: verb},
ObjectGroup: *objectGroup,
})
}
}
return transactionGroups
}
func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subject string, orgID valuer.UUID) (tuples map[string]*openfgav1.TupleKey, correlations map[string][]string, err error) {
tuples = make(map[string]*openfgav1.TupleKey)
correlations = make(map[string][]string)
@@ -83,10 +127,6 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
return tuples, correlations, nil
}
// NewTuplesFromTransactionsWithManagedRoles converts transactions to tuples for BatchCheck.
// Direct role-assignment transactions (TypeRole + VerbAssignee) produce one tuple keyed by txn ID.
// Other transactions are expanded via managedRolesByTransaction into role-assignee checks, keyed by "txnID:roleName".
// Transactions with no managed role mapping are marked as pre-resolved (false) in the returned map.
func NewTuplesFromTransactionsWithManagedRoles(
transactions []*Transaction,
subject string,
@@ -131,10 +171,6 @@ func NewTuplesFromTransactionsWithManagedRoles(
return tuples, preResolved, roleCorrelations, nil
}
// NewTransactionWithAuthorizationFromBatchResults merges batch check results into an ordered
// slice of TransactionWithAuthorization matching the input transactions order.
// preResolved contains txn IDs whose authorization was determined without BatchCheck.
// roleCorrelations maps txn IDs to correlation IDs used for managed role checks.
func NewTransactionWithAuthorizationFromBatchResults(
transactions []*Transaction,
batchResults map[string]*TupleKeyAuthorization,

View File

@@ -9,6 +9,7 @@ import (
var (
ErrCodeInvalidPatchObject = errors.MustNewCode("authz_invalid_patch_objects")
ErrCodeInvalidObject = errors.MustNewCode("authz_invalid_object")
)
type Object struct {

View File

@@ -125,8 +125,10 @@ type UpdatableLLMPricingRules struct {
}
type ListPricingRulesQuery struct {
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
Search string `query:"q" json:"q"`
IsOverride *bool `query:"isOverride" json:"isOverride"`
}
type GettablePricingRules struct {

View File

@@ -7,7 +7,7 @@ import (
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*LLMPricingRule, int, error)
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*LLMPricingRule, int, error)
Get(ctx context.Context, orgID, id valuer.UUID) (*LLMPricingRule, error)
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*LLMPricingRule, error)
Create(ctx context.Context, rule *LLMPricingRule) error

View File

@@ -26,10 +26,10 @@ def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
def create_custom_role(signoz: types.SigNoz, token: str, name: str) -> str:
"""Create a custom role and return its ID."""
"""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},
json={"name": name, "transactionGroups": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)

View File

@@ -63,7 +63,7 @@ def test_create_role_with_signoz_prefix_rejected(
for name in ("signoz-custom", "signozcustom"):
resp = requests.post(
signoz.self.host_configs["8080"].get(ROLES_BASE),
json={"name": name},
json={"name": name, "transactionGroups": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -175,7 +175,7 @@ def test_role_readonly_forbidden_operations(
# Create role — forbidden.
resp = requests.post(
signoz.self.host_configs["8080"].get(ROLES_BASE),
json={"name": "role-fga-should-fail"},
json={"name": "role-fga-should-fail", "transactionGroups": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -238,7 +238,7 @@ def test_role_grant_write_permissions(
# Create role — now allowed.
resp = requests.post(
signoz.self.host_configs["8080"].get(ROLES_BASE),
json={"name": "role-fga-write-test"},
json={"name": "role-fga-write-test", "transactionGroups": []},
headers={"Authorization": f"Bearer {custom_token}"},
timeout=5,
)