feat(admin-panel): signing keys management tab with usage tracking (#20586)

## Summary
- Adds a new admin-only **Security** tab to the Admin Panel (alongside
General/Apps/AI/Config/Health) containing a **Signing Keys** section.
The tab is intentionally introduced now so the upcoming **Encryption
rotation** work can land as a sibling section.
- Lists every JWT signing key with key id, `createdAt`, `revokedAt`,
current/active/revoked status, and a **7-day verification count** read
from Redis. A trailing row aggregates **legacy HS256** verifications so
it is clear when the deprecated path is still in use.
- Lets an admin **revoke** a public key. Revoking the current key drops
`isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and
clears the in-process cached current key; the existing lazy path in
`JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current
key on the next sign.

## Backend
- `SigningKeyVerifyCounterService` — bucketed Redis counter under the
existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL
refreshed on every increment, batched read via `mget`. Failures are
swallowed and logged at `warn` so a Redis hiccup cannot break auth.
- `JwtWrapperService.verifyJwtToken` records verifies **after success**
for both ES256 (`kid` as identifier) and HS256 (the literal `legacy`
identifier).
- `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`:
list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent,
validates the UUID, invalidates the public-key cache, and resets the
cached current-key promise.
- `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey`
(mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they
are admin-only, like the 35 existing admin-only methods on this
resolver. `privateKey` is never returned over GraphQL.

## Frontend
- New `SECURITY` tab id wired into `SettingsAdminContent` and
`SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`).
- `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly
reuse existing admin-panel components: `Section`, `H2Title`,
`Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`,
`Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the
queue retry/delete modals. Only one minimal styled helper for the
monospaced UUID rendering.
- `useRevokeSigningKey` uses `useApolloAdminClient`, refetches
`GetSigningKeys`, shows success/error snackbars (same pattern as
`useRetryJobs`/`useDeleteJobs`).

<img width="1293" height="881" alt="image"
src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c"
/>
This commit is contained in:
Charles Bochet
2026-05-15 12:49:18 +02:00
committed by GitHub
parent 218799636f
commit 75b9b2fe5d
19 changed files with 1015 additions and 5 deletions
@@ -3,6 +3,7 @@ import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health
import { SettingsAdminUpgradeStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminUpgradeStatusListCard';
import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode';
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
import { SettingsAdminSigningKeysTable } from '@/settings/admin-panel/signing-keys/components/SettingsAdminSigningKeysTable';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
@@ -60,6 +61,13 @@ export const SettingsAdminHealthStatus = () => {
<SettingsAdminUpgradeStatusListCard upgradeStatus={upgradeStatus} />
</Section>
)}
<Section>
<H2Title
title={t`Signing Keys`}
description={t`Asymmetric public keys used to sign and verify access tokens. Revoking a key immediately invalidates every JWT signed with it.`}
/>
<SettingsAdminSigningKeysTable />
</Section>
<SettingsAdminMaintenanceMode />
</>
);
@@ -0,0 +1,32 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
type SettingsAdminRevokeSigningKeyConfirmationModalProps = {
modalInstanceId: string;
isCurrent: boolean;
onConfirm: () => void;
onClose?: () => void;
};
export const SettingsAdminRevokeSigningKeyConfirmationModal = ({
modalInstanceId,
isCurrent,
onConfirm,
onClose,
}: SettingsAdminRevokeSigningKeyConfirmationModalProps) => {
const subtitle = isCurrent
? t`This is the current signing key. Revoking it will invalidate every JWT signed with it and users may need to sign in again. A new signing key will be generated automatically on the next request.`
: t`Revoking this key will invalidate every JWT signed with it. Users with active tokens signed by this key will be logged out.`;
return (
<ConfirmationModal
modalInstanceId={modalInstanceId}
title={t`Revoke signing key`}
subtitle={subtitle}
onConfirmClick={onConfirm}
onClose={onClose}
confirmButtonText={t`Revoke`}
confirmButtonAccent="danger"
/>
);
};
@@ -0,0 +1,192 @@
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsAdminRevokeSigningKeyConfirmationModal } from '@/settings/admin-panel/signing-keys/components/SettingsAdminRevokeSigningKeyConfirmationModal';
import { useRevokeSigningKey } from '@/settings/admin-panel/signing-keys/hooks/useRevokeSigningKey';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Tag, type TagColor } from 'twenty-ui/components';
import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
GetSigningKeysDocument,
type SigningKeyDto,
} from '~/generated-admin/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
const REVOKE_MODAL_ID = 'revoke-signing-key-modal';
const SIGNING_KEYS_GRID_TEMPLATE_COLUMNS = '2fr 88px 96px 96px 88px';
const EM_DASH = '\u2014';
type SelectedSigningKey = {
id: string;
isCurrent: boolean;
};
const getStatusTag = (
signingKey: Pick<SigningKeyDto, 'isCurrent' | 'revokedAt'>,
): { text: string; color: TagColor } => {
if (isDefined(signingKey.revokedAt)) {
return { text: t`Revoked`, color: 'red' };
}
if (signingKey.isCurrent === true) {
return { text: t`Current`, color: 'green' };
}
return { text: t`Active`, color: 'gray' };
};
export const SettingsAdminSigningKeysTable = () => {
const apolloAdminClient = useApolloAdminClient();
const { openModal } = useModal();
const { copyToClipboard } = useCopyToClipboard();
const [selectedSigningKey, setSelectedSigningKey] =
useState<SelectedSigningKey | null>(null);
const { data, loading } = useQuery(GetSigningKeysDocument, {
client: apolloAdminClient,
fetchPolicy: 'network-only',
});
const { revokeSigningKey, isRevoking } = useRevokeSigningKey(() => {
setSelectedSigningKey(null);
});
const handleRevokeClick = (signingKey: SelectedSigningKey) => {
setSelectedSigningKey(signingKey);
openModal(REVOKE_MODAL_ID);
};
const handleConfirmRevoke = async () => {
if (!isDefined(selectedSigningKey)) {
return;
}
await revokeSigningKey(selectedSigningKey.id);
};
if (loading && !isDefined(data)) {
return <SettingsSectionSkeletonLoader />;
}
const signingKeys = data?.getSigningKeys.signingKeys ?? [];
const legacyVerifyCountInWindow =
data?.getSigningKeys.legacyVerifyCountInWindow ?? 0;
const verifyWindowDays = data?.getSigningKeys.verifyWindowDays ?? 7;
return (
<>
<Table>
<TableBody>
<TableRow gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Key ID`}</TableHeader>
<TableHeader>{t`Revoked`}</TableHeader>
<TableHeader>{t`Status`}</TableHeader>
<TableHeader align="right">
{t`Uses (last ${verifyWindowDays}d)`}
</TableHeader>
<TableHeader />
</TableRow>
{signingKeys.map((signingKey) => {
const status = getStatusTag(signingKey);
const isRevoked = isDefined(signingKey.revokedAt);
return (
<TableRow
key={signingKey.id}
gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}
>
<TableCell overflow="hidden" gap={themeCssVariables.spacing[1]}>
<OverflowingTextWithTooltip
text={signingKey.id}
tooltipContent={t`Created on ${beautifyExactDateTime(signingKey.createdAt)}`}
alwaysShowTooltip
/>
<Button
Icon={IconCopy}
size="small"
variant="tertiary"
ariaLabel={t`Copy key ID`}
onClick={() =>
copyToClipboard(signingKey.id, t`Key ID copied`)
}
/>
</TableCell>
<TableCell>
{isDefined(signingKey.revokedAt) ? (
<OverflowingTextWithTooltip
text={beautifyPastDateRelativeToNow(signingKey.revokedAt)}
tooltipContent={beautifyExactDateTime(
signingKey.revokedAt,
)}
alwaysShowTooltip
/>
) : (
EM_DASH
)}
</TableCell>
<TableCell>
<Tag text={status.text} color={status.color} />
</TableCell>
<TableCell align="right">
{signingKey.verifyCountInWindow}
</TableCell>
<TableCell align="right">
<Button
title={t`Revoke`}
size="small"
variant="secondary"
accent="danger"
disabled={isRevoked || isRevoking}
onClick={() =>
handleRevokeClick({
id: signingKey.id,
isCurrent: signingKey.isCurrent,
})
}
/>
</TableCell>
</TableRow>
);
})}
<TableRow gridTemplateColumns={SIGNING_KEYS_GRID_TEMPLATE_COLUMNS}>
<TableCell overflow="hidden">
<OverflowingTextWithTooltip
text={t`Legacy (HS256)`}
tooltipContent={t`Legacy HS256 verifications across all tokens`}
alwaysShowTooltip
/>
</TableCell>
<TableCell>{EM_DASH}</TableCell>
<TableCell>
<Tag text={t`Legacy`} color="gray" />
</TableCell>
<TableCell align="right">{legacyVerifyCountInWindow}</TableCell>
<TableCell align="right" />
</TableRow>
</TableBody>
</Table>
<SettingsAdminRevokeSigningKeyConfirmationModal
modalInstanceId={REVOKE_MODAL_ID}
isCurrent={selectedSigningKey?.isCurrent === true}
onConfirm={handleConfirmRevoke}
onClose={() => setSelectedSigningKey(null)}
/>
</>
);
};
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const REVOKE_SIGNING_KEY = gql`
mutation RevokeSigningKey($id: UUID!) {
revokeSigningKey(id: $id) {
id
isCurrent
createdAt
revokedAt
verifyCountInWindow
}
}
`;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const GET_SIGNING_KEYS = gql`
query GetSigningKeys {
getSigningKeys {
signingKeys {
id
publicKey
isCurrent
createdAt
revokedAt
verifyCountInWindow
}
legacyVerifyCountInWindow
verifyWindowDays
}
}
`;
@@ -0,0 +1,43 @@
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { GET_SIGNING_KEYS } from '@/settings/admin-panel/signing-keys/graphql/queries/getSigningKeys';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { RevokeSigningKeyDocument } from '~/generated-admin/graphql';
import { getErrorMessageFromApolloError } from '~/utils/get-error-message-from-apollo-error.util';
export const useRevokeSigningKey = (onSuccess?: () => void) => {
const apolloAdminClient = useApolloAdminClient();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isRevoking, setIsRevoking] = useState(false);
const [revokeSigningKeyMutation] = useMutation(RevokeSigningKeyDocument, {
client: apolloAdminClient,
});
const revokeSigningKey = async (id: string) => {
setIsRevoking(true);
try {
await revokeSigningKeyMutation({
variables: { id },
refetchQueries: [{ query: GET_SIGNING_KEYS }],
awaitRefetchQueries: true,
});
enqueueSuccessSnackBar({ message: t`Signing key revoked` });
onSuccess?.();
} catch (error) {
enqueueErrorSnackBar({
message: CombinedGraphQLErrors.is(error)
? getErrorMessageFromApolloError(error)
: t`Failed to revoke signing key. Please try again later.`,
});
} finally {
setIsRevoking(false);
}
};
return { revokeSigningKey, isRevoking };
};