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:
+8
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
+32
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+192
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+43
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user