mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-27 14:50:46 +01:00
Compare commits
28 Commits
platform-p
...
issue_5601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349ee00df4 | ||
|
|
31372a0bbe | ||
|
|
bde83ff0ff | ||
|
|
0a59a9ef2d | ||
|
|
f0bac9f6be | ||
|
|
2d22cb6374 | ||
|
|
5f3d869058 | ||
|
|
b2c5af428e | ||
|
|
093f9b41c4 | ||
|
|
596e128005 | ||
|
|
2e1da92367 | ||
|
|
1e8d72e8fa | ||
|
|
cce080e1ae | ||
|
|
deca060a4d | ||
|
|
03c7e524e7 | ||
|
|
815dc7d88b | ||
|
|
f50d9199fe | ||
|
|
97c49c870b | ||
|
|
31efe177a4 | ||
|
|
d502d12ac3 | ||
|
|
bd9f15a716 | ||
|
|
813ef988c9 | ||
|
|
40e6799285 | ||
|
|
1caa60a3cd | ||
|
|
3f781f0083 | ||
|
|
6aec05cf7a | ||
|
|
683a52f35a | ||
|
|
e924fa1e62 |
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
133
pkg/statementbuilder/aistatementbuilder/span_list_test.go
Normal file
133
pkg/statementbuilder/aistatementbuilder/span_list_test.go
Normal 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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,6 @@ type ResolvedResource interface {
|
||||
SourceIDs() []string
|
||||
SourceSelector() SelectorFunc
|
||||
Err() error
|
||||
Skip() bool
|
||||
ResolveResponse(ec ExtractorContext)
|
||||
hasResponsePhase() bool
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user