mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-27 23:00:44 +01:00
Compare commits
4 Commits
main
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a785af78 | ||
|
|
dbb989b625 | ||
|
|
024f55cb36 | ||
|
|
7df1b2fd85 |
@@ -94,6 +94,7 @@ 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,6 +1541,7 @@ 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,6 +14,8 @@ 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';
|
||||
@@ -209,7 +211,11 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={
|
||||
isCreate ? [] : [buildAuthDomainUpdatePermission(record?.id ?? '')]
|
||||
}
|
||||
withPortal={false}
|
||||
onClick={onSubmitHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -217,7 +223,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
testId="auth-domain-save"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
@@ -60,12 +62,14 @@ function SSOEnforcementToggle({
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
disabled={isLoading}
|
||||
value={isChecked}
|
||||
onChange={onChangeHandler}
|
||||
testId="auth-domain-enforce-sso"
|
||||
/>
|
||||
<AuthZTooltip checks={[buildAuthDomainUpdatePermission(record.id ?? '')]}>
|
||||
<Switch
|
||||
disabled={isLoading}
|
||||
value={isChecked}
|
||||
onChange={onChangeHandler}
|
||||
testId="auth-domain-enforce-sso"
|
||||
/>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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,3 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -20,6 +21,7 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
describe('AuthDomain', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -122,6 +124,9 @@ 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(() => {
|
||||
@@ -148,8 +153,13 @@ describe('AuthDomain', () => {
|
||||
expect(screen.getByText('signoz.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const configureLinks = await screen.findAllByText(/configure google auth/i);
|
||||
await user.click(configureLinks[0]);
|
||||
const configureButtons = await screen.findAllByTestId(
|
||||
'auth-domain-configure',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(configureButtons[0]).toBeEnabled();
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -9,6 +11,9 @@ 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
|
||||
@@ -45,7 +50,15 @@ 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,4 +1,6 @@
|
||||
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,
|
||||
@@ -15,6 +17,9 @@ 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
|
||||
@@ -112,6 +117,10 @@ 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,4 +1,6 @@
|
||||
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,
|
||||
@@ -16,6 +18,13 @@ 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,3 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
describe('SSOEnforcementToggle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,6 +89,9 @@ describe('SSOEnforcementToggle', () => {
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -122,7 +127,11 @@ describe('SSOEnforcementToggle', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('switch'));
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdateAPI).toHaveBeenCalledWith({
|
||||
@@ -149,6 +158,9 @@ describe('SSOEnforcementToggle', () => {
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,6 +14,15 @@ 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';
|
||||
@@ -41,13 +50,17 @@ 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();
|
||||
} = useListAuthDomains({ query: { enabled: canListAuthDomains } });
|
||||
|
||||
const { mutate: deleteAuthDomain, isLoading } =
|
||||
useDeleteAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
@@ -153,22 +166,24 @@ function AuthDomain(): JSX.Element {
|
||||
width: 100,
|
||||
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
|
||||
<section className="auth-domain-list-column-action">
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[buildAuthDomainReadPermission(record.id ?? '')]}
|
||||
className="auth-domain-list-action-link"
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
testId="auth-domain-configure"
|
||||
>
|
||||
Configure {SSOType.get(record.config?.kind || '')}
|
||||
</Button>
|
||||
<Button
|
||||
</AuthZButton>
|
||||
<AuthZButton
|
||||
checks={[buildAuthDomainDeletePermission(record.id ?? '')]}
|
||||
className="auth-domain-list-action-link delete"
|
||||
onClick={(): void => showDeleteModal(record)}
|
||||
variant="link"
|
||||
testId="auth-domain-delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
@@ -182,7 +197,8 @@ function AuthDomain(): JSX.Element {
|
||||
<h3 className="auth-domain-title" data-testid="auth-domain-title">
|
||||
Authenticated Domains
|
||||
</h3>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[AuthDomainCreatePermission]}
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
@@ -193,28 +209,32 @@ function AuthDomain(): JSX.Element {
|
||||
testId="auth-domain-add"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
{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 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>
|
||||
{(addDomain || record) && (
|
||||
<CreateEdit
|
||||
isCreate={!record}
|
||||
|
||||
@@ -72,7 +72,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
await updateMyOrganization({ data: { id: orgId, displayName: name } });
|
||||
};
|
||||
|
||||
if (!org) {
|
||||
// The organization resource is not authz-backed yet, keep the legacy admin gate
|
||||
if (!org || !isAdmin) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
|
||||
@@ -329,21 +329,41 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toHaveLength(8);
|
||||
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',
|
||||
);
|
||||
@@ -418,15 +438,16 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'auth-domain',
|
||||
'factor-api-key',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
Gauge,
|
||||
Globe,
|
||||
Key,
|
||||
Logs,
|
||||
Shield,
|
||||
@@ -38,7 +39,16 @@ 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.',
|
||||
@@ -46,6 +56,33 @@ 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.',
|
||||
@@ -61,15 +98,6 @@ 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.',
|
||||
@@ -79,24 +107,6 @@ 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,6 +3,19 @@ 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',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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,7 +10,6 @@ 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';
|
||||
@@ -40,10 +39,6 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
const [isCurrentOrgSettings] = useComponentPermission(
|
||||
['current_org_settings'],
|
||||
user.role,
|
||||
);
|
||||
const { t } = useTranslation(['routes']);
|
||||
|
||||
const isGatewayEnabled =
|
||||
@@ -80,7 +75,8 @@ 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.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -92,7 +88,6 @@ 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
|
||||
@@ -131,7 +126,8 @@ 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.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -142,7 +138,6 @@ 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
|
||||
@@ -180,7 +175,8 @@ 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.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -188,10 +184,7 @@ function SettingsPage(): JSX.Element {
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
isEnabled: item.key === ROUTES.MEMBERS_SETTINGS ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -222,7 +215,6 @@ function SettingsPage(): JSX.Element {
|
||||
() =>
|
||||
getRoutes(
|
||||
user.role,
|
||||
isCurrentOrgSettings,
|
||||
isGatewayEnabled,
|
||||
isWorkspaceBlocked,
|
||||
isCloudUser,
|
||||
@@ -231,7 +223,6 @@ function SettingsPage(): JSX.Element {
|
||||
),
|
||||
[
|
||||
user.role,
|
||||
isCurrentOrgSettings,
|
||||
isGatewayEnabled,
|
||||
isWorkspaceBlocked,
|
||||
isCloudUser,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
|
||||
export const getRoutes = (
|
||||
userRole: ROLES | null,
|
||||
isCurrentOrgSettings: boolean,
|
||||
isGatewayEnabled: boolean,
|
||||
isWorkspaceBlocked: boolean,
|
||||
isCloudUser: boolean,
|
||||
@@ -47,9 +46,8 @@ export const getRoutes = (
|
||||
|
||||
settings.push(...generalSettings(t));
|
||||
|
||||
if (isCurrentOrgSettings) {
|
||||
settings.push(...organizationSettings(t));
|
||||
}
|
||||
// Visible to all authenticated users — in-page authz gates the content
|
||||
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'],
|
||||
ORG_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -172,6 +172,7 @@ 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: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: provider.authDomainRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
@@ -146,21 +146,23 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainAttachedRoleNames),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainAttachedRoleNames},
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
SkipIfNoIDs: true,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainDetachedRoleNames),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: provider.authDomainStoredRoleNamesExtractor(),
|
||||
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainDetachedRoleNames},
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
SkipIfNoIDs: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
@@ -197,67 +199,119 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainRequestEffectiveRoleNames}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (provider *provider) authDomainIDWhenRolesChangeExtractor(roleNamesDiff func(coretypes.ExtractorContext) ([]string, error)) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
diff, err := roleNamesDiff(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(diff) == 0 || 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 authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
|
||||
return []string{mux.Vars(ec.Request)["id"]}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
if roleMapping == nil {
|
||||
return []string{authtypes.SigNozViewerRoleName}
|
||||
}
|
||||
|
||||
if roleMapping.UseRoleAttribute {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
return []string{coretypes.WildCardSelectorString, authtypes.SigNozViewerRoleName}
|
||||
}
|
||||
|
||||
roleNames := roleMapping.RoleNames()
|
||||
|
||||
@@ -53,6 +53,9 @@ 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 {
|
||||
@@ -67,6 +70,7 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
|
||||
def.TargetIDs,
|
||||
def.TargetSelector,
|
||||
false,
|
||||
def.SkipIfNoIDs,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
@@ -96,6 +100,7 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
def.ChildIDs,
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ 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,6 +186,10 @@ 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
|
||||
|
||||
@@ -19,6 +19,7 @@ type ResolvedResource interface {
|
||||
SourceIDs() []string
|
||||
SourceSelector() SelectorFunc
|
||||
Err() error
|
||||
Skip() bool
|
||||
ResolveResponse(ec ExtractorContext)
|
||||
hasResponsePhase() bool
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext)
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Skip() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Err() error {
|
||||
return resolved.err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type resolvedResourceWithTarget struct {
|
||||
targetExtractor ResourceIDsExtractor
|
||||
targetIDs []string
|
||||
parentChild bool
|
||||
skipIfNoIDs bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ func NewResolvedResourceWithTarget(
|
||||
targetExtractor ResourceIDsExtractor,
|
||||
targetSelector SelectorFunc,
|
||||
parentChild bool,
|
||||
skipIfNoIDs bool,
|
||||
ec ExtractorContext,
|
||||
) ResolvedResourceWithTargetResource {
|
||||
resolved := &resolvedResourceWithTarget{
|
||||
@@ -37,6 +39,7 @@ func NewResolvedResourceWithTarget(
|
||||
targetSelector: targetSelector,
|
||||
targetExtractor: targetExtractor,
|
||||
parentChild: parentChild,
|
||||
skipIfNoIDs: skipIfNoIDs,
|
||||
}
|
||||
resolved.fill(PhaseRequest, ec)
|
||||
|
||||
@@ -69,6 +72,10 @@ 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,6 +28,7 @@ _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",
|
||||
@@ -428,6 +429,75 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user