mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 06:00:30 +01:00
Compare commits
4 Commits
platform-t
...
service-fg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a899668a7 | ||
|
|
41f7fc1e40 | ||
|
|
7247633939 | ||
|
|
ec88cd907b |
@@ -0,0 +1,21 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabledFieldset {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px !important;
|
||||
color: var(--foreground) !important;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
205
frontend/src/components/AuthZTooltip/AuthZTooltip.test.tsx
Normal file
205
frontend/src/components/AuthZTooltip/AuthZTooltip.test.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import AuthZTooltip from './AuthZTooltip';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const TestButton = (): ReactElement => <button type="button">Action</button>;
|
||||
|
||||
describe('AuthZTooltip — single check', () => {
|
||||
it('renders child unchanged when permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
relation="create"
|
||||
object="serviceaccount"
|
||||
permissionName="serviceaccount:create"
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables child when permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
relation="create"
|
||||
object="serviceaccount"
|
||||
permissionName="serviceaccount:create"
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not disable child while loading (no premature disabled flash)', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
relation="create"
|
||||
object="serviceaccount"
|
||||
permissionName="serviceaccount:create"
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
it('renders child enabled when all checks are granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [true, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
{
|
||||
relation: 'attach',
|
||||
object: 'serviceaccount:sa-1',
|
||||
permissionName: 'serviceaccount:attach',
|
||||
},
|
||||
{ relation: 'attach', object: 'role:*', permissionName: 'role:attach' },
|
||||
]}
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables child when first check is denied, second granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
{
|
||||
relation: 'attach',
|
||||
object: 'serviceaccount:sa-1',
|
||||
permissionName: 'serviceaccount:attach',
|
||||
},
|
||||
{ relation: 'attach', object: 'role:*', permissionName: 'role:attach' },
|
||||
]}
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables child when both checks are denied and message lists all denied permissions', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
{
|
||||
relation: 'attach',
|
||||
object: 'serviceaccount:sa-1',
|
||||
permissionName: 'serviceaccount:attach',
|
||||
},
|
||||
{ relation: 'attach', object: 'role:*', permissionName: 'role:attach' },
|
||||
]}
|
||||
>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
// Tooltip wrapper span should contain the denied permission names as data attr
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
'serviceaccount:attach',
|
||||
);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
'role:attach',
|
||||
);
|
||||
});
|
||||
});
|
||||
111
frontend/src/components/AuthZTooltip/AuthZTooltip.tsx
Normal file
111
frontend/src/components/AuthZTooltip/AuthZTooltip.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
type AuthZCheck = {
|
||||
relation: AuthZRelation;
|
||||
object: string;
|
||||
permissionName: string;
|
||||
};
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
relation?: AuthZRelation;
|
||||
object?: string;
|
||||
permissionName?: string;
|
||||
checks?: AuthZCheck[];
|
||||
children: ReactElement;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function buildPermissionFromCheck(check: AuthZCheck): BrandedPermission {
|
||||
return buildPermission(
|
||||
check.relation,
|
||||
check.object as AuthZObject<typeof check.relation>,
|
||||
);
|
||||
}
|
||||
|
||||
function AuthZTooltip({
|
||||
relation,
|
||||
object,
|
||||
permissionName,
|
||||
checks,
|
||||
children,
|
||||
enabled = true,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const normalisedChecks: AuthZCheck[] = useMemo(() => {
|
||||
if (checks && checks.length > 0) {
|
||||
return checks;
|
||||
}
|
||||
if (relation && object && permissionName) {
|
||||
return [{ relation, object, permissionName }];
|
||||
}
|
||||
return [];
|
||||
}, [checks, relation, object, permissionName]);
|
||||
|
||||
const permissions = useMemo(
|
||||
() => normalisedChecks.map(buildPermissionFromCheck),
|
||||
[normalisedChecks],
|
||||
);
|
||||
|
||||
const { permissions: authZResult, isLoading } = useAuthZ(
|
||||
permissions as BrandedPermission[],
|
||||
{ enabled: enabled && normalisedChecks.length > 0 },
|
||||
);
|
||||
|
||||
const deniedChecks = useMemo(() => {
|
||||
if (isLoading || !authZResult) {
|
||||
return [];
|
||||
}
|
||||
return normalisedChecks.filter((c) => {
|
||||
const perm = buildPermissionFromCheck(c);
|
||||
return authZResult[perm as BrandedPermission]?.isGranted === false;
|
||||
});
|
||||
}, [authZResult, isLoading, normalisedChecks]);
|
||||
|
||||
if (isLoading || deniedChecks.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const deniedNames = deniedChecks.map((c) => c.permissionName);
|
||||
const tooltipMessage =
|
||||
deniedNames.length === 1
|
||||
? `You don't have ${deniedNames[0]} permission`
|
||||
: `You don't have ${deniedNames.join(', ')} permissions`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={styles.wrapper}>
|
||||
<fieldset
|
||||
disabled
|
||||
className={styles.disabledFieldset}
|
||||
data-denied-permissions={deniedNames.join(',')}
|
||||
>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
})}
|
||||
</fieldset>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{tooltipMessage}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZTooltip;
|
||||
@@ -2,6 +2,7 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -132,17 +133,23 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
<AuthZTooltip
|
||||
relation="create"
|
||||
object="serviceaccount"
|
||||
permissionName="serviceaccount:create"
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.callout {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/permission/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
permissionName="serviceaccount:read"
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
{`You don't have ${permissionName} permission`}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedCallout;
|
||||
@@ -0,0 +1,44 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 50vh;
|
||||
padding: var(--spacing-10);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
max-width: 512px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.permission {
|
||||
font-family: monospace;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Uh-oh! You don't have permission to view this page."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Ask your SigNoz administrator to grant access/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>
|
||||
Uh-oh! You don't have permission to view this page.
|
||||
</p>
|
||||
<p className={styles.subtitle}>
|
||||
You need <code className={styles.permission}>{permissionName}</code> to
|
||||
view this page. Ask your SigNoz administrator to grant access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedFullPage;
|
||||
@@ -80,6 +80,7 @@ interface BaseProps {
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -123,6 +124,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
@@ -151,6 +153,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -168,6 +171,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from '../utils';
|
||||
@@ -18,6 +19,7 @@ export interface KeyFormPhaseProps {
|
||||
isValid: boolean;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
function KeyFormPhase({
|
||||
@@ -28,6 +30,7 @@ function KeyFormPhase({
|
||||
isValid,
|
||||
onSubmit,
|
||||
onClose,
|
||||
accountId,
|
||||
}: KeyFormPhaseProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -111,17 +114,24 @@ function KeyFormPhase({
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId ?? ''}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!!accountId}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -161,6 +161,7 @@ function AddKeyModal(): JSX.Element {
|
||||
isValid={isValid}
|
||||
onSubmit={handleSubmit(handleCreate)}
|
||||
onClose={handleClose}
|
||||
accountId={accountId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -82,15 +83,22 @@ function DeleteAccountModal(): JSX.Element {
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
<AuthZTooltip
|
||||
relation="delete"
|
||||
object={`serviceaccount:${accountId}`}
|
||||
permissionName="serviceaccount:delete"
|
||||
enabled={!!accountId}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
@@ -24,6 +25,8 @@ export interface EditKeyFormProps {
|
||||
onClose: () => void;
|
||||
onRevokeClick: () => void;
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
|
||||
canUpdate?: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
function EditKeyForm({
|
||||
@@ -37,6 +40,8 @@ function EditKeyForm({
|
||||
onClose,
|
||||
onRevokeClick,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
canUpdate = true,
|
||||
accountId = '',
|
||||
}: EditKeyFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -49,6 +54,7 @@ function EditKeyForm({
|
||||
id="edit-key-name"
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
disabled={!canUpdate}
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
@@ -73,21 +79,22 @@ function EditKeyForm({
|
||||
type="single"
|
||||
value={field.value}
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
if (val && canUpdate) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.NONE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.DATE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
@@ -114,6 +121,7 @@ function EditKeyForm({
|
||||
popupClassName="edit-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
disabled={!canUpdate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -133,26 +141,40 @@ function EditKeyForm({
|
||||
</form>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!!accountId}
|
||||
>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!!accountId}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildSAUpdatePermission } from 'hooks/useAuthZ/serviceAccountPermissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -69,6 +71,15 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
|
||||
const expiryMode = watch('expiryMode');
|
||||
|
||||
const { permissions: editPermissions } = useAuthZ(
|
||||
selectedAccountId ? [buildSAUpdatePermission(selectedAccountId)] : [],
|
||||
{ enabled: !!selectedAccountId },
|
||||
);
|
||||
|
||||
const canUpdate =
|
||||
editPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? true;
|
||||
|
||||
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
@@ -169,6 +180,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
accountId={selectedAccountId ?? undefined}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -190,6 +202,8 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
onClose={handleClose}
|
||||
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId ?? ''}
|
||||
/>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import { Skeleton, Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
@@ -17,12 +18,15 @@ interface KeysTabProps {
|
||||
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
canUpdate?: boolean;
|
||||
accountId?: string;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface BuildColumnsParams {
|
||||
isDisabled: boolean;
|
||||
accountId: string;
|
||||
onRevokeClick: (keyId: string) => void;
|
||||
handleformatLastObservedAt: (
|
||||
lastObservedAt: Date | null | undefined,
|
||||
@@ -42,6 +46,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
|
||||
|
||||
function buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
|
||||
@@ -93,7 +98,12 @@ function buildColumns({
|
||||
width: 48,
|
||||
align: 'right' as const,
|
||||
render: (_, record): JSX.Element => (
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -107,7 +117,7 @@ function buildColumns({
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -117,6 +127,7 @@ function KeysTab({
|
||||
keys,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
accountId = '',
|
||||
currentPage,
|
||||
pageSize,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
@@ -149,8 +160,14 @@ function KeysTab({
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
|
||||
[isDisabled, onRevokeClick, handleformatLastObservedAt],
|
||||
() =>
|
||||
buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}),
|
||||
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -176,16 +193,23 @@ function KeysTab({
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
@@ -19,6 +20,7 @@ interface OverviewTabProps {
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
canUpdate?: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
@@ -34,6 +36,7 @@ function OverviewTab({
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
canUpdate = true,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
@@ -63,7 +66,7 @@ function OverviewTab({
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
{isDisabled || !canUpdate ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
@@ -111,18 +114,33 @@ function OverviewTab({
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
/>
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
{
|
||||
relation: 'attach',
|
||||
object: `serviceaccount:${account.id}`,
|
||||
permissionName: 'serviceaccount:attach',
|
||||
},
|
||||
{
|
||||
relation: 'attach',
|
||||
object: 'role:*',
|
||||
permissionName: 'role:attach',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
/>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -23,12 +24,14 @@ export interface RevokeKeyFooterProps {
|
||||
isRevoking: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export function RevokeKeyFooter({
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
accountId,
|
||||
}: RevokeKeyFooterProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -36,15 +39,22 @@ export function RevokeKeyFooter({
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${accountId ?? ''}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!!accountId}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +125,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
isRevoking={isRevoking}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
accountId={accountId ?? undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
@@ -27,6 +29,13 @@ import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
buildSAAttachPermission,
|
||||
buildSADeletePermission,
|
||||
buildSAUpdatePermission,
|
||||
RoleAttachWildcardPermission,
|
||||
} from 'hooks/useAuthZ/serviceAccountPermissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -37,6 +46,7 @@ import {
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
@@ -165,6 +175,22 @@ function ServiceAccountDrawer({
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const { permissions: drawerPermissions } = useAuthZ(
|
||||
selectedAccountId
|
||||
? [
|
||||
buildSAUpdatePermission(selectedAccountId),
|
||||
buildSADeletePermission(selectedAccountId),
|
||||
buildSAAttachPermission(selectedAccountId),
|
||||
RoleAttachWildcardPermission,
|
||||
]
|
||||
: [],
|
||||
{ enabled: !!selectedAccountId },
|
||||
);
|
||||
|
||||
const canUpdate =
|
||||
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
|
||||
?.isGranted ?? true;
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
@@ -392,18 +418,25 @@ function ServiceAccountDrawer({
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${selectedAccountId ?? ''}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!isDeleted && !!selectedAccountId}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -421,37 +454,49 @@ function ServiceAccountDrawer({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isAccountLoading && !isAccountError && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
{!isAccountLoading && !isAccountError && selectedAccountId && (
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
// object={`serviceaccount:${selectedAccountId}`}
|
||||
object={`serviceaccount:*`}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
>
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</GuardAuthZ>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,16 +527,23 @@ function ServiceAccountDrawer({
|
||||
) : (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
<AuthZTooltip
|
||||
relation="delete"
|
||||
object={`serviceaccount:${selectedAccountId ?? ''}`}
|
||||
permissionName="serviceaccount:delete"
|
||||
enabled={!!selectedAccountId}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
@@ -499,15 +551,22 @@ function ServiceAccountDrawer({
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
<AuthZTooltip
|
||||
relation="update"
|
||||
object={`serviceaccount:${selectedAccountId ?? ''}`}
|
||||
permissionName="serviceaccount:update"
|
||||
enabled={!!selectedAccountId}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -6,6 +11,23 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL || '';
|
||||
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
@@ -98,6 +120,18 @@ describe('ServiceAccountDrawer', () => {
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -322,6 +356,106 @@ describe('ServiceAccountDrawer', () => {
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedCallout inside drawer when read permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows drawer content when read permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
expect(screen.queryByText(/serviceaccount:read/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Save button when update permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// Deny update, grant everything else (match by relation+object)
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: payload.map((txn: { relation: string; object: string }) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: !(txn.relation === 'update'),
|
||||
})),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
// When update is denied, name renders as read-only text (not an input)
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('disables Delete button when delete permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// Deny delete, grant everything else (match by relation+object)
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: payload.map((txn: { relation: string; object: string }) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: !(txn.relation === 'delete'),
|
||||
})),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
const deleteBtn = screen.getByRole('button', {
|
||||
name: /Delete Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(deleteBtn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
@@ -359,6 +493,18 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,18 +29,6 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__header-divider {
|
||||
display: block;
|
||||
width: 1px;
|
||||
|
||||
@@ -78,39 +78,50 @@ function ResourceRow({
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
|
||||
</div>
|
||||
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-only-selected`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
|
||||
Only selected
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
{resource.type === 'metaresources' ? (
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-none`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-none`}>None</RadioGroupLabel>
|
||||
</div>
|
||||
) : (
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-only-selected`}
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
|
||||
Only selected
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
|
||||
{config.scope === PermissionScope.ONLY_SELECTED && (
|
||||
<div className="psp-resource__select-wrapper">
|
||||
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
|
||||
<Select
|
||||
mode="tags"
|
||||
value={config.selectedIds}
|
||||
onChange={(vals: string[]): void =>
|
||||
onSelectedIdsChange(resource.id, vals)
|
||||
}
|
||||
options={resource.options ?? []}
|
||||
placeholder="Select resources..."
|
||||
className="psp-resource__select"
|
||||
popupClassName="psp-resource__select-popup"
|
||||
showSearch
|
||||
filterOption={(input, option): boolean =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{config.scope === PermissionScope.ONLY_SELECTED &&
|
||||
resource.type !== 'metaresources' && (
|
||||
<div className="psp-resource__select-wrapper">
|
||||
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
|
||||
<Select
|
||||
mode="tags"
|
||||
value={config.selectedIds}
|
||||
onChange={(vals: string[]): void =>
|
||||
onSelectedIdsChange(resource.id, vals)
|
||||
}
|
||||
options={resource.options ?? []}
|
||||
placeholder="Select resources..."
|
||||
className="psp-resource__select"
|
||||
popupClassName="psp-resource__select-popup"
|
||||
showSearch
|
||||
filterOption={(input, option): boolean =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,13 +224,13 @@ function PermissionSidePanel({
|
||||
<div className="permission-side-panel">
|
||||
<div className="permission-side-panel__header">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="permission-side-panel__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={16} />
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<span className="permission-side-panel__header-divider" />
|
||||
<span className="permission-side-panel__title">
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface ResourceOption {
|
||||
|
||||
export interface ResourceDefinition {
|
||||
id: string;
|
||||
kind: string;
|
||||
type: string;
|
||||
label: string;
|
||||
options?: ResourceOption[];
|
||||
}
|
||||
|
||||
@@ -282,30 +282,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-delete-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-delete-modal {
|
||||
width: calc(100% - 30px) !important;
|
||||
max-width: 384px;
|
||||
|
||||
@@ -110,7 +110,6 @@ function RoleDetailsPage(): JSX.Element {
|
||||
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
|
||||
);
|
||||
}
|
||||
setActivePermission(null);
|
||||
};
|
||||
|
||||
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
|
||||
@@ -213,18 +212,16 @@ function RoleDetailsPage(): JSX.Element {
|
||||
{!isManaged && (
|
||||
<div className="role-details-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="link"
|
||||
color="destructive"
|
||||
className="role-details-delete-action-btn"
|
||||
onClick={(): void => setIsDeleteModalOpen(true)}
|
||||
aria-label="Delete role"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit Role Details
|
||||
|
||||
@@ -27,9 +27,8 @@ function DeleteRoleModal({
|
||||
<Button
|
||||
key="cancel"
|
||||
className="cancel-btn"
|
||||
prefix={<X size={16} />}
|
||||
prefix={<X size={14} />}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
@@ -38,10 +37,9 @@ function DeleteRoleModal({
|
||||
<Button
|
||||
key="delete"
|
||||
className="delete-btn"
|
||||
prefix={<Trash2 size={16} />}
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
>
|
||||
|
||||
@@ -285,16 +285,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input,
|
||||
input {
|
||||
&::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
box-sizing: border-box;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
background: var(--input-background, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
@@ -303,7 +310,7 @@
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -313,25 +320,6 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
|
||||
@@ -58,8 +58,18 @@ const baseAuthzResources: AuthzResources = {
|
||||
};
|
||||
|
||||
const resourceDefs: ResourceDefinition[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'alert', label: 'Alert' },
|
||||
{
|
||||
id: 'metaresource:dashboard',
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
id: 'metaresource:alert',
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
label: 'Alert',
|
||||
},
|
||||
];
|
||||
|
||||
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
@@ -69,15 +79,24 @@ const ID_C = 'cccccccc-0000-0000-0000-000000000003';
|
||||
describe('buildPatchPayload', () => {
|
||||
it('sends only the added selector as an addition', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -95,18 +114,24 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('sends only the removed selector as a deletion', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B, ID_C],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_C],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -124,18 +149,24 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('treats selector order as irrelevant — produces no payload when IDs are identical', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -151,15 +182,21 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('replaces wildcard with specific IDs when switching all → only_selected', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -179,12 +216,21 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('only deletes wildcard when switching all → only_selected with empty selector list', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -202,12 +248,18 @@ describe('buildPatchPayload', () => {
|
||||
|
||||
it('only includes resources that actually changed', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A, ID_B] }, // added ID_B
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
}, // added ID_B
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
@@ -232,7 +284,7 @@ describe('objectsToPermissionConfig', () => {
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
@@ -245,7 +297,7 @@ describe('objectsToPermissionConfig', () => {
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
});
|
||||
@@ -254,11 +306,11 @@ describe('objectsToPermissionConfig', () => {
|
||||
it('defaults to ONLY_SELECTED with empty selectedIds when resource is absent from API response', () => {
|
||||
const result = objectsToPermissionConfig([], resourceDefs);
|
||||
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result.alert).toStrictEqual({
|
||||
expect(result['metaresource:alert']).toStrictEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
@@ -268,8 +320,11 @@ describe('objectsToPermissionConfig', () => {
|
||||
describe('configsEqual', () => {
|
||||
it('returns true for identical configs', () => {
|
||||
const config: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:alert': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configsEqual(config, { ...config })).toBe(true);
|
||||
@@ -277,22 +332,25 @@ describe('configsEqual', () => {
|
||||
|
||||
it('returns false when configs differ', () => {
|
||||
const a: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configsEqual(a, b)).toBe(false);
|
||||
|
||||
const c: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_C, ID_B],
|
||||
},
|
||||
};
|
||||
const d: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
@@ -303,13 +361,13 @@ describe('configsEqual', () => {
|
||||
|
||||
it('returns true when selectedIds are the same but in different order', () => {
|
||||
const a: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
dashboard: {
|
||||
'metaresource:dashboard': {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
@@ -322,23 +380,25 @@ describe('configsEqual', () => {
|
||||
describe('buildConfig', () => {
|
||||
it('uses initial values when provided and defaults for resources not in initial', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
'metaresource:dashboard': { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildConfig(resourceDefs, initial);
|
||||
|
||||
expect(result.dashboard).toStrictEqual({
|
||||
expect(result['metaresource:dashboard']).toStrictEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
|
||||
it('applies DEFAULT_RESOURCE_CONFIG to all resources when no initial is provided', () => {
|
||||
const result = buildConfig(resourceDefs);
|
||||
|
||||
expect(result.dashboard).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result.alert).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result['metaresource:dashboard']).toStrictEqual(
|
||||
DEFAULT_RESOURCE_CONFIG,
|
||||
);
|
||||
expect(result['metaresource:alert']).toStrictEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,7 +435,10 @@ describe('deriveResourcesForRelation', () => {
|
||||
const result = deriveResourcesForRelation(baseAuthzResources, 'create');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['dashboard', 'alert']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual([
|
||||
'metaresource:dashboard',
|
||||
'metaresource:alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when authzResources is null', () => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = false;
|
||||
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = true;
|
||||
|
||||
@@ -70,7 +70,9 @@ export function deriveResourcesForRelation(
|
||||
return authzResources.resources
|
||||
.filter((r) => supportedTypes.includes(r.type))
|
||||
.map((r) => ({
|
||||
id: r.kind,
|
||||
id: `${r.type}:${r.kind}`,
|
||||
kind: r.kind,
|
||||
type: r.type,
|
||||
label: capitalize(r.kind).replaceAll('_', ' '),
|
||||
options: [],
|
||||
}));
|
||||
@@ -82,7 +84,9 @@ export function objectsToPermissionConfig(
|
||||
): PermissionConfig {
|
||||
const config: PermissionConfig = {};
|
||||
for (const res of resources) {
|
||||
const obj = objects.find((o) => o.resource.kind === res.id);
|
||||
const obj = objects.find(
|
||||
(o) => o.resource.kind === res.kind && o.resource.type === res.type,
|
||||
);
|
||||
if (!obj) {
|
||||
config[res.id] = {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
@@ -118,7 +122,9 @@ export function buildPatchPayload({
|
||||
for (const res of resources) {
|
||||
const initial = initialConfig[res.id];
|
||||
const current = newConfig[res.id];
|
||||
const resourceDef = authzRes.resources.find((r) => r.kind === res.id);
|
||||
const resourceDef = authzRes.resources.find(
|
||||
(r) => r.kind === res.kind && r.type === res.type,
|
||||
);
|
||||
if (!resourceDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import ServiceAccountsSettings from './ServiceAccountsSettings';
|
||||
|
||||
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
|
||||
const SA_LIST_URL = 'http://localhost/api/v1/service_accounts';
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={{}} hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountsSettings — FGA', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(SA_LIST_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => false),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/You don't have permission to view this page/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows table when list permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText(/You don't have permission to view this page/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables New Service Account button when create permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// grant list, deny create — matched by relation name
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn: AuthtypesTransactionDTO) => txn.relation === 'list'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables New Service Account button when create permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,15 @@ import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import Spinner from 'components/Spinner';
|
||||
import type { AuthZObject } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
PAGE_SIZE,
|
||||
@@ -51,13 +58,21 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const listPermission = buildPermission(
|
||||
'list',
|
||||
'serviceaccount' as AuthZObject<'list'>,
|
||||
);
|
||||
const { permissions: authZPermissions } = useAuthZ([listPermission]);
|
||||
const hasListPermission =
|
||||
authZPermissions?.[listPermission]?.isGranted ?? false;
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts();
|
||||
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
@@ -112,9 +127,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
void setPage(maxPage);
|
||||
} else if (currentPage < 1) {
|
||||
setPage(1);
|
||||
void setPage(1);
|
||||
}
|
||||
}, [filteredAccounts.length, currentPage, setPage]);
|
||||
|
||||
@@ -130,8 +145,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
void setFilterMode(FilterMode.All);
|
||||
void setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -143,8 +158,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Active);
|
||||
setPage(1);
|
||||
void setFilterMode(FilterMode.Active);
|
||||
void setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -156,8 +171,8 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setPage(1);
|
||||
void setFilterMode(FilterMode.Deleted);
|
||||
void setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -176,7 +191,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ServiceAccountRow): void => {
|
||||
setSelectedAccountId(row.id);
|
||||
void setSelectedAccountId(row.id);
|
||||
},
|
||||
[setSelectedAccountId],
|
||||
);
|
||||
@@ -184,9 +199,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
const handleDrawerSuccess = useCallback(
|
||||
(options?: { closeDrawer?: boolean }): void => {
|
||||
if (options?.closeDrawer) {
|
||||
setSelectedAccountId(null);
|
||||
void setSelectedAccountId(null);
|
||||
}
|
||||
handleCreateSuccess();
|
||||
void handleCreateSuccess();
|
||||
},
|
||||
[handleCreateSuccess, setSelectedAccountId],
|
||||
);
|
||||
@@ -208,64 +223,84 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
<GuardAuthZ
|
||||
relation="list"
|
||||
object={'serviceaccount' as AuthZObject<'list'>}
|
||||
fallbackOnLoading={<Spinner height="50vh" />}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
|
||||
)}
|
||||
>
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
void setSearchQuery(e.target.value);
|
||||
void setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthZTooltip
|
||||
relation="create"
|
||||
object="serviceaccount"
|
||||
permissionName="serviceaccount:create"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GuardAuthZ>
|
||||
|
||||
<CreateServiceAccountModal />
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -6,6 +7,8 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
|
||||
|
||||
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
@@ -85,6 +88,21 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
// Grant all authz permissions by default
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: payload.map((txn) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: true,
|
||||
})),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
|
||||
),
|
||||
|
||||
30
frontend/src/hooks/useAuthZ/serviceAccountPermissions.ts
Normal file
30
frontend/src/hooks/useAuthZ/serviceAccountPermissions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// src/hooks/useAuthZ/serviceAccountPermissions.ts
|
||||
import { buildPermission } from './utils';
|
||||
import type { AuthZObject, BrandedPermission } from './types';
|
||||
|
||||
// Metaresource-level — no specific ID needed
|
||||
export const SAListPermission = buildPermission(
|
||||
'list',
|
||||
'serviceaccount' as AuthZObject<'list'>,
|
||||
);
|
||||
export const SACreatePermission = buildPermission(
|
||||
'create',
|
||||
'serviceaccount' as AuthZObject<'create'>,
|
||||
);
|
||||
|
||||
// Resource-level — require a specific SA id
|
||||
export const buildSAReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `serviceaccount:${id}` as AuthZObject<'read'>);
|
||||
export const buildSAUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `serviceaccount:${id}` as AuthZObject<'update'>);
|
||||
export const buildSADeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `serviceaccount:${id}` as AuthZObject<'delete'>);
|
||||
export const buildSAAttachPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('attach', `serviceaccount:${id}` as AuthZObject<'attach'>);
|
||||
|
||||
// Wildcard role attach — used alongside buildSAAttachPermission for role selector guard.
|
||||
// Backend requires both serviceaccount:attach AND role:attach to assign/revoke roles on a SA.
|
||||
export const RoleAttachWildcardPermission = buildPermission(
|
||||
'attach',
|
||||
'role:*' as AuthZObject<'attach'>,
|
||||
);
|
||||
@@ -72,18 +72,26 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (isCloudUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
? true
|
||||
@@ -113,17 +121,25 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (isEnterpriseSelfHostedUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
? true
|
||||
@@ -152,14 +168,22 @@ function SettingsPage(): JSX.Element {
|
||||
}
|
||||
|
||||
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
|
||||
// Visible to all authenticated users
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
|
||||
@@ -62,13 +62,12 @@ export const getRoutes = (
|
||||
|
||||
settings.push(...alertChannels(t));
|
||||
|
||||
// Visible to all authenticated users
|
||||
settings.push(...serviceAccountsSettings(t), ...rolesSettings(t));
|
||||
|
||||
// Admin-only: members management and role detail/edit page
|
||||
if (isAdmin) {
|
||||
settings.push(
|
||||
...membersSettings(t),
|
||||
...serviceAccountsSettings(t),
|
||||
...rolesSettings(t),
|
||||
...roleDetails(t),
|
||||
);
|
||||
settings.push(...membersSettings(t), ...roleDetails(t));
|
||||
}
|
||||
|
||||
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
|
||||
|
||||
@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
Reference in New Issue
Block a user