Compare commits

..

28 Commits

Author SHA1 Message Date
nityanandagohain
349ee00df4 fix: minor fixes 2026-08-27 16:52:27 +05:30
nityanandagohain
31372a0bbe fix: minor fixes 2026-08-27 16:02:15 +05:30
nityanandagohain
bde83ff0ff Merge remote-tracking branch 'origin/main' into issue_5601_spanlist 2026-08-27 15:15:03 +05:30
nityanandagohain
0a59a9ef2d Merge remote-tracking branch 'origin/main' into issue_5601_spanlist 2026-08-07 16:57:54 +05:30
nityanandagohain
f0bac9f6be fix: more changes 2026-07-24 16:47:49 +05:30
nityanandagohain
2d22cb6374 fix: rename file 2026-07-24 16:18:57 +05:30
nityanandagohain
5f3d869058 Merge branch 'issue_5601' into issue_5601_spanlist 2026-07-24 16:11:14 +05:30
nityanandagohain
b2c5af428e fix: refactor as requested 2026-07-24 12:07:37 +05:30
nityanandagohain
093f9b41c4 fix: minor cleanup 2026-07-22 16:22:19 +05:30
nityanandagohain
596e128005 fix: updated openapi 2026-07-22 15:26:52 +05:30
nityanandagohain
2e1da92367 fix: remove source and change to builder ai query 2026-07-22 15:24:43 +05:30
nityanandagohain
1e8d72e8fa Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-21 17:08:34 +05:30
nityanandagohain
cce080e1ae fix: add back the flag in metadata 2026-07-16 11:37:57 +05:30
nityanandagohain
deca060a4d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-16 11:00:55 +05:30
nityanandagohain
03c7e524e7 fix: fix tests 2026-07-15 20:23:33 +05:30
nityanandagohain
815dc7d88b Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-15 20:10:05 +05:30
nityanandagohain
f50d9199fe fix: address comments 2026-07-15 19:47:39 +05:30
nityanandagohain
97c49c870b feat: support ai trace aggregate filtering in ai span list 2026-07-15 12:27:55 +05:30
nityanandagohain
31efe177a4 fix: address comments 2026-07-14 18:53:30 +05:30
nityanandagohain
d502d12ac3 fix: update openapi 2026-07-10 14:27:07 +05:30
nityanandagohain
bd9f15a716 fix: update integration test 2026-07-10 14:21:16 +05:30
nityanandagohain
813ef988c9 fix: edge cases and correct cost key 2026-07-10 12:06:34 +05:30
nityanandagohain
40e6799285 fix: add resource fingerprint cte 2026-07-10 00:36:25 +05:30
nityanandagohain
1caa60a3cd fix: cleanup and more tests 2026-07-09 23:54:05 +05:30
nityanandagohain
3f781f0083 fix: more cleanup 2026-07-09 12:39:01 +05:30
nityanandagohain
6aec05cf7a fix: more tests 2026-07-09 08:45:22 +05:30
nityanandagohain
683a52f35a fix: take perf into consideration 2026-07-09 08:45:22 +05:30
nityanandagohain
e924fa1e62 feat: support llm trace list and span list 2026-07-09 08:45:20 +05:30
32 changed files with 347 additions and 624 deletions

View File

@@ -94,7 +94,6 @@ func runGenerateAuthz(_ context.Context) error {
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceAuthDomain).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,

View File

@@ -1541,7 +1541,6 @@ describe('PrivateRoute', () => {
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
deniedRoles: DENIED_ROLES,
},
ORG_SETTINGS: { path: ROUTES.ORG_SETTINGS, deniedRoles: DENIED_ROLES },
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
TRACE_DETAIL: {

View File

@@ -14,8 +14,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { FeatureKeys } from 'constants/features';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildAuthDomainUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -211,11 +209,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
Cancel
</Button>
)}
<AuthZButton
checks={
isCreate ? [] : [buildAuthDomainUpdatePermission(record?.id ?? '')]
}
withPortal={false}
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
@@ -223,7 +217,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
testId="auth-domain-save"
>
Save Changes
</AuthZButton>
</Button>
</section>
</div>
)}

View File

@@ -7,8 +7,6 @@ import {
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildAuthDomainUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
@@ -62,14 +60,12 @@ function SSOEnforcementToggle({
};
return (
<AuthZTooltip checks={[buildAuthDomainUpdatePermission(record.id ?? '')]}>
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
</AuthZTooltip>
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
);
}

View File

@@ -1,164 +0,0 @@
import {
AuthDomainListPermission,
buildAuthDomainDeletePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDenyAll,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AuthDomain from '../index';
import { AUTH_DOMAINS_LIST_ENDPOINT, mockDomainsListResponse } from './mocks';
function setupListHandler(): void {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
}
describe('AuthDomain authz', () => {
afterEach(() => {
server.resetHandlers();
});
describe('when all permissions are denied', () => {
it('disables the add button and blocks the table with a callout', async () => {
server.use(setupAuthzDenyAll());
setupListHandler();
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
});
await expect(
screen.findByText(/is not authorized to perform/i),
).resolves.toBeInTheDocument();
expect(screen.getByText('list:auth-domain:*')).toBeInTheDocument();
expect(screen.queryByText('signoz.io')).not.toBeInTheDocument();
});
});
describe('when only list is granted', () => {
it('renders rows but disables the row actions and the add button', async () => {
server.use(setupAuthzGrantByPrefix('list'));
setupListHandler();
render(<AuthDomain />);
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
});
screen.getAllByTestId('auth-domain-configure').forEach((button) => {
expect(button).toBeDisabled();
});
screen.getAllByTestId('auth-domain-delete').forEach((button) => {
expect(button).toBeDisabled();
});
screen.getAllByRole('switch').forEach((toggle) => {
expect(toggle).toBeDisabled();
});
});
});
describe('when all permissions are granted', () => {
it('keeps every control interactive', async () => {
server.use(setupAuthzAdmin());
setupListHandler();
render(<AuthDomain />);
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
expect(screen.getByTestId('auth-domain-add')).toBeEnabled();
await waitFor(() => {
screen.getAllByTestId('auth-domain-configure').forEach((button) => {
expect(button).toBeEnabled();
});
});
screen.getAllByTestId('auth-domain-delete').forEach((button) => {
expect(button).toBeEnabled();
});
screen.getAllByRole('switch').forEach((toggle) => {
expect(toggle).toBeEnabled();
});
});
});
describe('when read is granted but update is not', () => {
it('keeps configure clickable and disables save inside the modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(setupAuthzGrantByPrefix('list', 'read'));
setupListHandler();
render(<AuthDomain />);
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
const configureButtons = screen.getAllByTestId('auth-domain-configure');
await waitFor(() => {
expect(configureButtons[0]).toBeEnabled();
});
await user.click(configureButtons[0]);
await screen.findByTestId('auth-domain-save');
await waitFor(() => {
const saveButton = screen.getByTestId('auth-domain-save');
expect(saveButton).toBeDisabled();
expect(saveButton).toHaveAttribute('data-denied-permissions');
});
});
});
describe('when delete is granted on a single domain', () => {
it('enables delete only for that row', async () => {
server.use(
setupAuthzAllow(
AuthDomainListPermission,
buildAuthDomainDeletePermission('domain-1'),
),
);
setupListHandler();
render(<AuthDomain />);
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
const deleteButtons = screen.getAllByTestId('auth-domain-delete');
expect(deleteButtons).toHaveLength(3);
// Row order follows mockDomainsListResponse: domain-1, domain-2, domain-3
await waitFor(() => {
expect(deleteButtons[0]).toBeEnabled();
});
expect(deleteButtons[1]).toBeDisabled();
expect(deleteButtons[2]).toBeDisabled();
});
});
describe('while permission checks are loading', () => {
it('keeps the add button disabled', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
setupListHandler();
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
});
});
});
});

View File

@@ -1,4 +1,3 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -21,7 +20,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
describe('AuthDomain', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(setupAuthzAdmin());
});
afterEach(() => {
@@ -124,9 +122,6 @@ describe('AuthDomain', () => {
render(<AuthDomain />);
const addButton = await screen.findByRole('button', { name: /add domain/i });
await waitFor(() => {
expect(addButton).toBeEnabled();
});
await user.click(addButton);
await waitFor(() => {
@@ -153,13 +148,8 @@ describe('AuthDomain', () => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
});
const configureButtons = await screen.findAllByTestId(
'auth-domain-configure',
);
await waitFor(() => {
expect(configureButtons[0]).toBeEnabled();
});
await user.click(configureButtons[0]);
const configureLinks = await screen.findAllByText(/configure google auth/i);
await user.click(configureLinks[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();

View File

@@ -1,6 +1,4 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import CreateEdit from '../CreateEdit/CreateEdit';
@@ -11,9 +9,6 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
@@ -50,15 +45,7 @@ jest.mock('@signozhq/ui/button', () => ({
),
}));
// Heavy real-timer integration tests (antd Collapse + form.validateFields() + a
// react-query mutation); the default 5000ms budget flakes under parallel runs.
jest.setTimeout(20000);
describe('CreateEdit — save payload correctness', () => {
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
});

View File

@@ -1,6 +1,4 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import {
allRoles,
@@ -17,9 +15,6 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
// prevent form.validateFields() from resolving inside act(). Replacing with a
@@ -117,10 +112,6 @@ const saveChanges = (user: User): Promise<void> =>
user.click(screen.getByRole('button', { name: /save changes/i }));
describe('CreateEdit — role mapping uses API roles', () => {
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
});

View File

@@ -1,6 +1,4 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import {
AuthtypesAuthDomainConfigGoogleDTO,
@@ -18,13 +16,6 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
// @signozhq/ui/button internal effects block form.validateFields() in tests
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),

View File

@@ -1,4 +1,3 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -35,7 +34,6 @@ import {
describe('SSOEnforcementToggle', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(setupAuthzAdmin());
});
afterEach(() => {
@@ -89,9 +87,6 @@ describe('SSOEnforcementToggle', () => {
);
const switchElement = screen.getByRole('switch');
await waitFor(() => {
expect(switchElement).toBeEnabled();
});
await user.click(switchElement);
await waitFor(() => {
@@ -127,11 +122,7 @@ describe('SSOEnforcementToggle', () => {
/>,
);
const switchElement = screen.getByRole('switch');
await waitFor(() => {
expect(switchElement).toBeEnabled();
});
await user.click(switchElement);
await user.click(screen.getByRole('switch'));
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
expect(mockUpdateAPI).toHaveBeenCalledWith({
@@ -158,9 +149,6 @@ describe('SSOEnforcementToggle', () => {
);
const switchElement = screen.getByRole('switch');
await waitFor(() => {
expect(switchElement).toBeEnabled();
});
await user.click(switchElement);
await waitFor(() => {

View File

@@ -14,15 +14,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
AuthDomainCreatePermission,
AuthDomainListPermission,
buildAuthDomainDeletePermission,
buildAuthDomainReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -50,17 +41,13 @@ function AuthDomain(): JSX.Element {
const { showErrorModal } = useErrorModal();
const { permissions: authzPermissions } = useAuthZ([AuthDomainListPermission]);
const canListAuthDomains =
authzPermissions?.[AuthDomainListPermission]?.isGranted ?? false;
const {
data: authDomainListResponse,
isLoading: isLoadingAuthDomainListResponse,
isFetching: isFetchingAuthDomainListResponse,
error: errorFetchingAuthDomainListResponse,
refetch: refetchAuthDomainListResponse,
} = useListAuthDomains({ query: { enabled: canListAuthDomains } });
} = useListAuthDomains();
const { mutate: deleteAuthDomain, isLoading } =
useDeleteAuthDomain<AxiosError<RenderErrorResponseDTO>>();
@@ -166,24 +153,22 @@ function AuthDomain(): JSX.Element {
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<AuthZButton
checks={[buildAuthDomainReadPermission(record.id ?? '')]}
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
testId="auth-domain-configure"
>
Configure {SSOType.get(record.config?.kind || '')}
</AuthZButton>
<AuthZButton
checks={[buildAuthDomainDeletePermission(record.id ?? '')]}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
testId="auth-domain-delete"
>
Delete
</AuthZButton>
</Button>
</section>
),
},
@@ -197,8 +182,7 @@ function AuthDomain(): JSX.Element {
<h3 className="auth-domain-title" data-testid="auth-domain-title">
Authenticated Domains
</h3>
<AuthZButton
checks={[AuthDomainCreatePermission]}
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
setAddDomain(true);
@@ -209,32 +193,28 @@ function AuthDomain(): JSX.Element {
testId="auth-domain-add"
>
Add Domain
</AuthZButton>
</Button>
</section>
<AuthZGuardContent checks={[AuthDomainListPermission]}>
<>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
</>
</AuthZGuardContent>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
{(addDomain || record) && (
<CreateEdit
isCreate={!record}

View File

@@ -72,8 +72,7 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
await updateMyOrganization({ data: { id: orgId, displayName: name } });
};
// The organization resource is not authz-backed yet, keep the legacy admin gate
if (!org || !isAdmin) {
if (!org) {
return <div />;
}

View File

@@ -329,41 +329,21 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(8);
expect(result).toHaveLength(7);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'auth-domain',
'factor-api-key',
'logs',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});
it('sets correct resource metadata from permissions config', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
const authDomainResource = result.find(
(r) => r.resourceKind === 'auth-domain',
);
expect(authDomainResource).toMatchObject({
resourceId: 'auth-domain',
resourceKind: 'auth-domain',
resourceType: CoretypesTypeDTO.metaresource,
resourceLabel: 'Auth Domains',
availableActions: [
'attach',
'create',
'delete',
'detach',
'list',
'read',
'update',
],
});
const apiKeyResource = result.find(
(r) => r.resourceKind === 'factor-api-key',
);
@@ -438,16 +418,15 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(8);
expect(result).toHaveLength(7);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'auth-domain',
'factor-api-key',
'logs',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});

View File

@@ -3,7 +3,6 @@ import {
ChartLine,
DraftingCompass,
Gauge,
Globe,
Key,
Logs,
Shield,
@@ -39,16 +38,7 @@ export interface ResourcePanelConfig {
* we want to add resource panel configs for only types we actually are using,
* not all of them
*/
// Keys must stay alphabetically sorted — RESOURCE_ORDER derives the display order from them.
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'auth-domain': {
label: 'Auth Domains',
description: 'Authenticated domains and their SSO configuration.',
icon: Globe,
selectorPlaceholder:
'Type auth domain ID, separate multiple with comma or space',
docsAnchor: 'auth-domain',
},
'factor-api-key': {
label: 'API Keys',
description: 'Programmatic access tokens for the workspace.',
@@ -56,33 +46,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type API key ID, separate multiple with comma or space',
docsAnchor: 'factor-api-key',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
icon: Logs,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'logs',
selectorType: 'telemetryBuilder',
},
'meter-metrics': {
label: 'Meter Metrics',
description: 'Usage metering data for the workspace.',
icon: Gauge,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'meter-metrics',
selectorType: 'telemetryBuilder',
},
metrics: {
label: 'Metrics',
description: 'Metric data collected across the workspace.',
icon: ChartLine,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'metrics',
selectorType: 'telemetryBuilder',
},
role: {
label: 'Roles',
description: 'Custom and managed roles and their assignments.',
@@ -98,6 +61,15 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type service account ID, separate multiple with comma or space',
docsAnchor: 'service-account',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
icon: Logs,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'logs',
selectorType: 'telemetryBuilder',
},
traces: {
label: 'Traces',
description: 'Distributed tracing data collected across the workspace.',
@@ -107,6 +79,24 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
docsAnchor: 'traces',
selectorType: 'telemetryBuilder',
},
metrics: {
label: 'Metrics',
description: 'Metric data collected across the workspace.',
icon: ChartLine,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'metrics',
selectorType: 'telemetryBuilder',
},
'meter-metrics': {
label: 'Meter Metrics',
description: 'Usage metering data for the workspace.',
icon: Gauge,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'meter-metrics',
selectorType: 'telemetryBuilder',
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];

View File

@@ -3,19 +3,6 @@ export default {
status: 'success',
data: {
resources: [
{
kind: 'auth-domain',
type: 'metaresource',
allowedVerbs: [
'attach',
'create',
'delete',
'detach',
'list',
'read',
'update',
],
},
{
kind: 'factor-api-key',
type: 'metaresource',

View File

@@ -1,22 +0,0 @@
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level — wildcard selector required for correct response key matching
export const AuthDomainListPermission = buildPermission(
'list',
'auth-domain:*',
);
export const AuthDomainCreatePermission = buildPermission(
'create',
'auth-domain:*',
);
// Resource-level — require a specific auth domain id
export const buildAuthDomainReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `auth-domain:${id}`);
export const buildAuthDomainUpdatePermission = (
id: string,
): BrandedPermission => buildPermission('update', `auth-domain:${id}`);
export const buildAuthDomainDeletePermission = (
id: string,
): BrandedPermission => buildPermission('delete', `auth-domain:${id}`);

View File

@@ -10,6 +10,7 @@ import { buildNavUrl, getQueryString } from 'container/SideNav/helper';
import { settingsNavSections } from 'container/SideNav/menuItems';
import NavItem from 'container/SideNav/NavItem/NavItem';
import { SidebarItem } from 'container/SideNav/sideNav.types';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { Cog } from '@signozhq/icons';
@@ -39,6 +40,10 @@ function SettingsPage(): JSX.Element {
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
const [isCurrentOrgSettings] = useComponentPermission(
['current_org_settings'],
user.role,
);
const { t } = useTranslation(['routes']);
const isGatewayEnabled =
@@ -75,8 +80,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.ROLE_EDIT ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
@@ -88,6 +92,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SHORTCUTS ||
item.key === ROUTES.MCP_SERVER
@@ -126,8 +131,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.ROLE_EDIT ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
@@ -138,6 +142,7 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.MCP_SERVER
@@ -175,8 +180,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
item.key === ROUTES.ROLE_EDIT ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));
@@ -184,7 +188,10 @@ function SettingsPage(): JSX.Element {
if (isAdmin) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MEMBERS_SETTINGS ? true : item.isEnabled,
isEnabled:
item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS
? true
: item.isEnabled,
}));
}
@@ -215,6 +222,7 @@ function SettingsPage(): JSX.Element {
() =>
getRoutes(
user.role,
isCurrentOrgSettings,
isGatewayEnabled,
isWorkspaceBlocked,
isCloudUser,
@@ -223,6 +231,7 @@ function SettingsPage(): JSX.Element {
),
[
user.role,
isCurrentOrgSettings,
isGatewayEnabled,
isWorkspaceBlocked,
isCloudUser,

View File

@@ -21,6 +21,7 @@ import {
export const getRoutes = (
userRole: ROLES | null,
isCurrentOrgSettings: boolean,
isGatewayEnabled: boolean,
isWorkspaceBlocked: boolean,
isCloudUser: boolean,
@@ -46,8 +47,9 @@ export const getRoutes = (
settings.push(...generalSettings(t));
// Visible to all authenticated users — in-page authz gates the content
settings.push(...organizationSettings(t));
if (isCurrentOrgSettings) {
settings.push(...organizationSettings(t));
}
if (isGatewayEnabled && (isAdmin || isEditor)) {
settings.push(...multiIngestionSettings(t));

View File

@@ -59,7 +59,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERTS_NEW: ['ADMIN', 'EDITOR'],
ORG_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ORG_SETTINGS: ['ADMIN'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -172,7 +172,6 @@ export const routeWithInitialAuthZSupport = {
LOGS: true,
LOGS_EXPLORER: true,
LIVE_LOGS: true,
ORG_SETTINGS: true,
ROLES_SETTINGS: true,
ROLE_CREATE: true,
ROLE_DETAILS: true,

View File

@@ -77,7 +77,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: provider.authDomainRoleNamesExtractor(),
TargetIDs: authDomainRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
},
),
@@ -146,23 +146,21 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainAttachedRoleNames),
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainAttachedRoleNames},
TargetIDs: authDomainRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
SkipIfNoIDs: true,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainDetachedRoleNames),
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainDetachedRoleNames},
TargetIDs: provider.authDomainStoredRoleNamesExtractor(),
TargetSelector: coretypes.IDSelector,
SkipIfNoIDs: true,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
@@ -199,119 +197,67 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
return nil
}
func (provider *provider) authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainRequestEffectiveRoleNames}
// The extracted names are the roles the request body's mapping grants at SSO
// login — see authDomainEffectiveRoleNames.
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
return authDomainEffectiveRoleNames(nil), nil
}
roleMapping := new(authtypes.RoleMapping)
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
}
return authDomainEffectiveRoleNames(roleMapping), nil
}}
}
func (provider *provider) authDomainIDWhenRolesChangeExtractor(roleNamesDiff func(coretypes.ExtractorContext) ([]string, error)) coretypes.ResourceIDsExtractor {
// The extracted names are the roles the stored domain's mapping grants at SSO
// login — an update replaces that mapping, so the caller must be able to detach
// them.
func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
diff, err := roleNamesDiff(ec)
if ec.Request == nil {
return nil, nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return nil, err
}
if len(diff) == 0 || ec.Request == nil {
return nil, nil
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
return []string{mux.Vars(ec.Request)["id"]}, nil
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
if err != nil {
return nil, err
}
return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
}}
}
func (provider *provider) authDomainAttachedRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
requestRoleNames, err := provider.authDomainRequestEffectiveRoleNames(ec)
if err != nil {
return nil, err
}
storedRoleNames, err := provider.authDomainStoredEffectiveRoleNames(ec)
if err != nil {
return nil, err
}
return provider.subtractRoleNames(requestRoleNames, storedRoleNames), nil
}
func (provider *provider) authDomainDetachedRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
requestRoleNames, err := provider.authDomainRequestEffectiveRoleNames(ec)
if err != nil {
return nil, err
}
storedRoleNames, err := provider.authDomainStoredEffectiveRoleNames(ec)
if err != nil {
return nil, err
}
return provider.subtractRoleNames(storedRoleNames, requestRoleNames), nil
}
func (provider *provider) authDomainRequestEffectiveRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
return provider.authDomainEffectiveRoleNames(nil), nil
}
roleMapping := new(authtypes.RoleMapping)
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
}
return provider.authDomainEffectiveRoleNames(roleMapping), nil
}
func (provider *provider) authDomainStoredEffectiveRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
if err != nil {
return nil, err
}
return provider.authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
}
func (provider *provider) subtractRoleNames(roleNames []string, roleNamesToRemove []string) []string {
removeSet := make(map[string]struct{}, len(roleNamesToRemove))
for _, roleName := range roleNamesToRemove {
removeSet[roleName] = struct{}{}
}
remaining := make([]string, 0, len(roleNames))
for _, roleName := range roleNames {
if _, ok := removeSet[roleName]; !ok {
remaining = append(remaining, roleName)
}
}
return remaining
}
// Never empty — a check with no selectors is forbidden.
func (provider *provider) authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
// The effective names are the roles a domain grants at SSO login: the mapped
// roles plus the default (signoz-viewer when unset), or every role when the IDP
// role attribute is trusted. Never empty — a check with no selectors is forbidden.
func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
if roleMapping == nil {
return []string{authtypes.SigNozViewerRoleName}
}
if roleMapping.UseRoleAttribute {
return []string{coretypes.WildCardSelectorString, authtypes.SigNozViewerRoleName}
return []string{coretypes.WildCardSelectorString}
}
roleNames := roleMapping.RoleNames()

View File

@@ -53,9 +53,6 @@ type AttachDetachSiblingResourceDef struct {
TargetResource coretypes.Resource
TargetIDs coretypes.ResourceIDsExtractor
TargetSelector coretypes.SelectorFunc
// SkipIfNoIDs skips the authz checks entirely when neither source nor target
// ids resolve — an attach/detach of nothing authorizes nothing.
SkipIfNoIDs bool
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -70,7 +67,6 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
def.TargetIDs,
def.TargetSelector,
false,
def.SkipIfNoIDs,
ec,
),
}
@@ -100,7 +96,6 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
def.ChildIDs,
nil,
true,
false,
ec,
),
}

View File

@@ -123,10 +123,6 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
}
resource.ResolveResponse(extractorCtx)
if resource.Skip() {
continue
}
verb, category := resource.Verb(), resource.Category()
switch typed := resource.(type) {

View File

@@ -186,10 +186,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
return
}
if resource.Skip() {
continue
}
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return

View File

@@ -0,0 +1,133 @@
package aistatementbuilder
import (
"context"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mixed filter: the span-level part gates the scan, the trace-level part becomes the
// __trace_scope qualification.
func TestBuild_FullSQL_SpanList_TraceScoped(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeRaw,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.output_tokens > 1000"},
Limit: 10,
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __trace_scope AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING output_tokens > 1000
)
SELECT timestamp AS __SELECT_KEY_0_timestamp, trace_id AS __SELECT_KEY_1_trace_id, span_id AS __SELECT_KEY_2_span_id,
trace_state AS __SELECT_KEY_3_trace_state, parent_span_id AS __SELECT_KEY_4_parent_span_id, flags AS __SELECT_KEY_5_flags,
name AS __SELECT_KEY_6_name, kind AS __SELECT_KEY_7_kind, kind_string AS __SELECT_KEY_8_kind_string, duration_nano AS __SELECT_KEY_9_duration_nano,
status_code AS __SELECT_KEY_10_status_code, status_message AS __SELECT_KEY_11_status_message,
status_code_string AS __SELECT_KEY_12_status_code_string, events AS __SELECT_KEY_13_events, links AS __SELECT_KEY_14_links,
response_status_code AS __SELECT_KEY_15_response_status_code, external_http_url AS __SELECT_KEY_16_external_http_url,
http_url AS __SELECT_KEY_17_http_url, external_http_method AS __SELECT_KEY_18_external_http_method,
http_method AS __SELECT_KEY_19_http_method, http_host AS __SELECT_KEY_20_http_host, db_name AS __SELECT_KEY_21_db_name,
db_operation AS __SELECT_KEY_22_db_operation, has_error AS __SELECT_KEY_23_has_error, is_remote AS __SELECT_KEY_24_is_remote,
attributes_string, attributes_number, attributes_bool, resources_string
FROM signoz_traces.distributed_signoz_index_v3
WHERE trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
AND (((mapContains(attributes_string, 'gen_ai.request.model')
OR mapContains(attributes_string, 'gen_ai.tool.name')
OR mapContains(attributes_string, 'gen_ai.agent.name')))
AND ((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini'
AND mapContains(attributes_string, 'gen_ai.request.model'))))
AND timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
LIMIT 10
`, stmt)
}
// A resource attribute mixed with a trace-level condition: the resource part flows
// through the fingerprint machinery, the trace-level part through __trace_scope.
func TestBuild_SpanList_ResourcePlusTraceFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeRaw,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "resource.service.name = 'checkout' AND trace.output_tokens > 1000"},
Limit: 10,
}, nil)
require.NoError(t, err)
got := renderSQL(t, stmt)
assert.Contains(t, got, "__resource_filter AS (")
assert.Contains(t, got, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
assert.Contains(t, got, "__trace_scope AS (")
assert.Contains(t, got, "trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
assert.Contains(t, got, "HAVING output_tokens > 1000")
}
// Trace-level order keys are rejected — known aggregate alias or not — while a bare
// span column sharing an alias (duration_nano) stays orderable.
func TestBuild_SpanList_OrderKeyValidation(t *testing.T) {
b := newTestBuilder(t)
build := func(q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) error {
q.Signal = telemetrytypes.SignalTraces
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeRaw, q, nil)
return err
}
err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.output_tokens"}}}},
})
require.ErrorContains(t, err, `ordering the span list by trace-level key "trace.output_tokens" is not supported`)
err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.foo"}}}},
})
require.ErrorContains(t, err, `trace-level key "trace.foo"`)
err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "duration_nano"}}, Direction: qbtypes.OrderDirectionDesc}},
Limit: 10,
})
require.NoError(t, err, "bare duration_nano is a span column, not a trace-level key")
}
// Variables in a trace-level condition resolve through the standard pipeline; a
// dynamic __all__ drops the condition (no scope CTE).
func TestBuild_SpanList_TraceFilter_Variables(t *testing.T) {
b := newTestBuilder(t)
build := func(expr string, vars map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeRaw,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: expr},
Limit: 10,
}, vars)
}
stmt, err := build("trace.output_tokens > $threshold",
map[string]qbtypes.VariableItem{"threshold": {Value: 700}})
require.NoError(t, err)
assert.Contains(t, renderSQL(t, stmt), "HAVING output_tokens > 700")
stmt, err = build("trace.output_tokens > $threshold",
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.DynamicVariableType, Value: "__all__"}})
require.NoError(t, err)
assert.NotContains(t, stmt.Query, "__trace_scope")
}

View File

@@ -114,6 +114,9 @@ func (b *scopedTraceStatementBuilder) Build(
case qbtypes.RequestTypeTrace:
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
case qbtypes.RequestTypeRaw:
if err := b.validateRawOrderKeys(query); err != nil {
return nil, err
}
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
case qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries:
return b.buildAggregation(ctx, orgID, start, end, requestType, query, variables)
@@ -122,27 +125,18 @@ func (b *scopedTraceStatementBuilder) Build(
}
}
// buildDelegated ANDs the base gate into the user filter and delegates to the
// standard trace builder (the span-list / raw path).
func (b *scopedTraceStatementBuilder) buildDelegated(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
gate := b.scope.FilterExpression
expr := gate
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
// validateRawOrderKeys rejects trace-level order keys — no per-trace value exists on
// span rows. A bare name may be a span column sharing an alias (duration_nano), so it passes.
func (b *scopedTraceStatementBuilder) validateRawOrderKeys(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) error {
for _, o := range query.Order {
key := o.Key.TelemetryFieldKey
key.Normalize()
if key.FieldContext == telemetrytypes.FieldContextTrace {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"ordering the span list by trace-level key %q is not supported; order by span columns instead (e.g. timestamp, duration_nano)", o.Key.Name)
}
}
// shallow copy; only Filter is replaced, caller's query untouched
gated := query
gated.Filter = &qbtypes.Filter{Expression: expr}
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
return nil
}
// traceScopedStatementBuilder is the delegate's optional capability of constraining a
@@ -153,10 +147,10 @@ type traceScopedStatementBuilder interface {
BuildTraceScoped(ctx context.Context, orgID valuer.UUID, start, end uint64, requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], variables map[string]qbtypes.VariableItem, traceScope, traceScopeResource *qbtypes.Statement) (*qbtypes.Statement, error)
}
// buildDelegatedAggregation serves span-level scalar/time-series through the standard
// trace builder, with the gate ANDed into the span-level filter part; a trace-level
// part becomes a qualification the delegate constrains trace_id by.
func (b *scopedTraceStatementBuilder) buildDelegatedAggregation(
// buildDelegated serves the raw span list and span-level scalar/time-series through
// the standard trace builder, with the gate ANDed into the span-level filter part; a
// trace-level part becomes a qualification the delegate constrains trace_id by.
func (b *scopedTraceStatementBuilder) buildDelegated(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,

View File

@@ -45,7 +45,7 @@ func (b *scopedTraceStatementBuilder) buildAggregation(
return nil, err
}
if len(traceAggs) == 0 {
return b.buildDelegatedAggregation(ctx, orgID, start, end, requestType, query, variables)
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
}
return b.buildTraceAggregationQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), requestType, query, variables, traceAggs)
}

View File

@@ -361,6 +361,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
cteArgs = append(cteArgs, args)
}
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
for i, field := range query.SelectFields {
expr, err := b.fm.ColumnExpressionFor(ctx, orgID, start, end, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {

View File

@@ -19,7 +19,6 @@ type ResolvedResource interface {
SourceIDs() []string
SourceSelector() SelectorFunc
Err() error
Skip() bool
ResolveResponse(ec ExtractorContext)
hasResponsePhase() bool
}

View File

@@ -59,10 +59,6 @@ func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext)
}
}
func (resolved *resolvedResource) Skip() bool {
return false
}
func (resolved *resolvedResource) Err() error {
return resolved.err
}

View File

@@ -12,7 +12,6 @@ type resolvedResourceWithTarget struct {
targetExtractor ResourceIDsExtractor
targetIDs []string
parentChild bool
skipIfNoIDs bool
err error
}
@@ -26,7 +25,6 @@ func NewResolvedResourceWithTarget(
targetExtractor ResourceIDsExtractor,
targetSelector SelectorFunc,
parentChild bool,
skipIfNoIDs bool,
ec ExtractorContext,
) ResolvedResourceWithTargetResource {
resolved := &resolvedResourceWithTarget{
@@ -39,7 +37,6 @@ func NewResolvedResourceWithTarget(
targetSelector: targetSelector,
targetExtractor: targetExtractor,
parentChild: parentChild,
skipIfNoIDs: skipIfNoIDs,
}
resolved.fill(PhaseRequest, ec)
@@ -72,10 +69,6 @@ func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec Extracto
}
}
func (resolved *resolvedResourceWithTarget) Skip() bool {
return resolved.skipIfNoIDs && len(resolved.sourceIDs) == 0 && len(resolved.targetIDs) == 0
}
func (resolved *resolvedResourceWithTarget) Err() error {
return resolved.err
}

View File

@@ -28,7 +28,6 @@ _TARGET_A = "target-a-authdomain.integration.test"
_TARGET_B = "target-b-authdomain.integration.test"
_ADMIN_DOMAIN = "admin-crud-authdomain.integration.test"
_ACTOR_DOMAIN = "actor-crud-authdomain.integration.test"
_DIFF_DOMAIN = "diff-crud-authdomain.integration.test"
_SAML_CONFIG = {
"kind": "saml",
@@ -429,75 +428,6 @@ def test_update_requires_detach_on_stored_roles(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_update_with_unchanged_mapping_needs_only_update(
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)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
json={
"name": _DIFF_DOMAIN,
"enabled": True,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "EDITOR"},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
domain_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("update", "metaresource", "auth-domain", [domain_id]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
# The mapping is echoed back unchanged, so the attach/detach checks are
# skipped and update alone suffices.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={
"enabled": False,
"config": _SAML_CONFIG,
"roleMapping": {"defaultRole": "EDITOR"},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"unchanged mapping with update only: {response.text}"
# Dropping the mapping attaches signoz-viewer and detaches signoz-editor,
# neither of which the actor can do.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
json={"enabled": False, "config": _SAML_CONFIG},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"changed mapping without attach/detach: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_instance_verbs_scoped_to_granted_domain(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument

View File

@@ -209,6 +209,52 @@ def test_ai_span_list_excludes_non_gen_ai_spans(
assert "POST /api/chat" not in names # root span excluded
def test_ai_span_list_trace_level_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Span list (raw) with a trace-level condition returns only the gen_ai spans
of traces whose window-clipped aggregates qualify."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-spanlist-tracefilter"
small = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=100)
large = ai_trace(now=now, service=service, user="b", in_tokens=30, out_tokens=300)
insert_traces(small + large)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
query = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}' AND trace.output_tokens > 100",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 1, f"expected only the large trace's LLM span, got {len(rows)} rows"
body = json.dumps(rows)
assert large[0].trace_id in body
assert small[0].trace_id not in body
# a threshold no trace meets: the empty qualification yields no spans, not an error
query = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}' AND trace.output_tokens > 1000",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
assert response.status_code == HTTPStatus.OK, response.text
assert not (response.json()["data"]["data"]["results"][0].get("rows") or [])
def test_ai_list_having_or_aggregates(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument