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:
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
export const JwtKeyManagerExceptionCode = appendCommonExceptionCode({
|
||||
INVALID_PRIVATE_KEY: 'INVALID_PRIVATE_KEY',
|
||||
SIGNING_KEY_NOT_FOUND: 'SIGNING_KEY_NOT_FOUND',
|
||||
} as const);
|
||||
|
||||
const getJwtKeyManagerExceptionUserFriendlyMessage = (
|
||||
@@ -16,6 +17,7 @@ const getJwtKeyManagerExceptionUserFriendlyMessage = (
|
||||
): MessageDescriptor => {
|
||||
switch (code) {
|
||||
case JwtKeyManagerExceptionCode.INVALID_PRIVATE_KEY:
|
||||
case JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND:
|
||||
case JwtKeyManagerExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return STANDARD_ERROR_MESSAGE;
|
||||
default:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-k
|
||||
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SigningKeyEntityCacheProviderService } from 'src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service';
|
||||
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -44,7 +45,12 @@ const InternalJwtModule = NestJwtModule.registerAsync({
|
||||
JwtWrapperService,
|
||||
JwtKeyManagerService,
|
||||
SigningKeyEntityCacheProviderService,
|
||||
SigningKeyVerifyCounterService,
|
||||
],
|
||||
exports: [
|
||||
JwtWrapperService,
|
||||
JwtKeyManagerService,
|
||||
SigningKeyVerifyCounterService,
|
||||
],
|
||||
exports: [JwtWrapperService, JwtKeyManagerService],
|
||||
})
|
||||
export class JwtModule {}
|
||||
|
||||
+51
-1
@@ -21,6 +21,7 @@ export type CurrentSigningKey = {
|
||||
};
|
||||
|
||||
const UNIQUE_VIOLATION_PG_CODE = '23505';
|
||||
const CURRENT_SIGNING_KEY_LOCAL_TTL_MS = 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class JwtKeyManagerService {
|
||||
@@ -28,6 +29,7 @@ export class JwtKeyManagerService {
|
||||
|
||||
private currentSigningKeyPromise: Promise<CurrentSigningKey | null> | null =
|
||||
null;
|
||||
private currentSigningKeyCachedAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SigningKeyEntity)
|
||||
@@ -37,8 +39,13 @@ export class JwtKeyManagerService {
|
||||
) {}
|
||||
|
||||
async getCurrentSigningKey(): Promise<CurrentSigningKey | null> {
|
||||
if (this.currentSigningKeyPromise === null) {
|
||||
const isLocalCacheExpired =
|
||||
Date.now() - this.currentSigningKeyCachedAt >
|
||||
CURRENT_SIGNING_KEY_LOCAL_TTL_MS;
|
||||
|
||||
if (!isDefined(this.currentSigningKeyPromise) || isLocalCacheExpired) {
|
||||
this.currentSigningKeyPromise = this.loadOrCreateCurrentSigningKey();
|
||||
this.currentSigningKeyCachedAt = Date.now();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -46,11 +53,13 @@ export class JwtKeyManagerService {
|
||||
|
||||
if (!isDefined(result)) {
|
||||
this.currentSigningKeyPromise = null;
|
||||
this.currentSigningKeyCachedAt = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.currentSigningKeyPromise = null;
|
||||
this.currentSigningKeyCachedAt = 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +72,47 @@ export class JwtKeyManagerService {
|
||||
return this.coreEntityCacheService.get('signingKeyPublicKey', id);
|
||||
}
|
||||
|
||||
async listSigningKeys(): Promise<SigningKeyEntity[]> {
|
||||
return this.signingKeyRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSigningKey(id: string): Promise<SigningKeyEntity> {
|
||||
if (!isNonEmptyString(id) || !isValidUuid(id)) {
|
||||
throw new JwtKeyManagerException(
|
||||
`Invalid signing key id: ${id}`,
|
||||
JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.signingKeyRepository.findOne({ where: { id } });
|
||||
|
||||
if (!isDefined(existing)) {
|
||||
throw new JwtKeyManagerException(
|
||||
`Signing key not found: ${id}`,
|
||||
JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(existing.revokedAt)) {
|
||||
await this.signingKeyRepository.update(
|
||||
{ id },
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
isCurrent: false,
|
||||
privateKey: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id);
|
||||
this.currentSigningKeyPromise = null;
|
||||
this.currentSigningKeyCachedAt = 0;
|
||||
|
||||
return this.signingKeyRepository.findOneByOrFail({ id });
|
||||
}
|
||||
|
||||
private async loadOrCreateCurrentSigningKey(): Promise<CurrentSigningKey | null> {
|
||||
try {
|
||||
const existing = await this.findCurrentSigningKeyRow();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
JWT_LEGACY_ALGORITHM,
|
||||
} from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
|
||||
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
|
||||
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util';
|
||||
import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util';
|
||||
@@ -45,6 +46,7 @@ export class JwtWrapperService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly jwtKeyManagerService: JwtKeyManagerService,
|
||||
private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService,
|
||||
) {}
|
||||
|
||||
async signAsyncOrThrow(
|
||||
@@ -131,6 +133,7 @@ export class JwtWrapperService {
|
||||
options?: JwtVerifyOptions,
|
||||
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
|
||||
): Promise<any> {
|
||||
const header = decodeJwtHeader(token);
|
||||
const payload = this.decode<JwtPayload>(token, { json: true });
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
@@ -140,7 +143,14 @@ export class JwtWrapperService {
|
||||
const { key, algorithm } = await this.resolveVerificationKey(token);
|
||||
|
||||
try {
|
||||
return jwt.verify(token, key, { ...options, algorithms: [algorithm] });
|
||||
const verified = jwt.verify(token, key, {
|
||||
...options,
|
||||
algorithms: [algorithm],
|
||||
});
|
||||
|
||||
this.recordVerifyForAlgorithm(algorithm, header);
|
||||
|
||||
return verified;
|
||||
} catch (error) {
|
||||
// API_KEY tokens created before 12/12/2025 were accidentally signed
|
||||
// with ACCESS type instead of API_KEY. Fall back to the legacy ACCESS
|
||||
@@ -154,11 +164,15 @@ export class JwtWrapperService {
|
||||
|
||||
if (isDefined(appSecretBody)) {
|
||||
try {
|
||||
return jwt.verify(
|
||||
const verified = jwt.verify(
|
||||
token,
|
||||
this.generateAppSecret(JwtTokenTypeEnum.ACCESS, appSecretBody),
|
||||
{ ...options, algorithms: [JWT_LEGACY_ALGORITHM] },
|
||||
);
|
||||
|
||||
this.recordVerifyForAlgorithm(JWT_LEGACY_ALGORITHM, header);
|
||||
|
||||
return verified;
|
||||
} catch {
|
||||
throw this.toAuthException(error);
|
||||
}
|
||||
@@ -185,6 +199,22 @@ export class JwtWrapperService {
|
||||
return ExtractJwt.fromAuthHeaderAsBearerToken();
|
||||
}
|
||||
|
||||
private recordVerifyForAlgorithm(
|
||||
algorithm: ResolvedVerificationKey['algorithm'],
|
||||
header: ReturnType<typeof decodeJwtHeader>,
|
||||
): void {
|
||||
if (
|
||||
algorithm === JWT_ASYMMETRIC_ALGORITHM &&
|
||||
isAsymmetricJwtHeader(header)
|
||||
) {
|
||||
this.signingKeyVerifyCounterService.recordKidVerify(header.kid);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.signingKeyVerifyCounterService.recordLegacyVerify();
|
||||
}
|
||||
|
||||
private extractAppSecretBody(payload: JwtPayload): string | undefined {
|
||||
const workspaceParse = APP_SECRET_BODY_WORKSPACE_SCHEMA.safeParse(payload);
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
|
||||
const WINDOW_DAYS = 7;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const BUCKET_TTL_MS = (WINDOW_DAYS + 1) * ONE_DAY_MS;
|
||||
const FLUSH_INTERVAL_MS = 30 * 1000;
|
||||
const REDIS_KEY_PREFIX = 'signing-key-verifies';
|
||||
const LEGACY_BUCKET_ID = 'legacy';
|
||||
|
||||
export type SigningKeyUsage = {
|
||||
byKid: Record<string, number>;
|
||||
legacyCount: number;
|
||||
windowDays: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SigningKeyVerifyCounterService
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(SigningKeyVerifyCounterService.name);
|
||||
|
||||
private pendingCounts = new Map<string, number>();
|
||||
private flushIntervalHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineMetrics)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.flushIntervalHandle = setInterval(() => {
|
||||
void this.flush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
if (isDefined(this.flushIntervalHandle.unref)) {
|
||||
this.flushIntervalHandle.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (isDefined(this.flushIntervalHandle)) {
|
||||
clearInterval(this.flushIntervalHandle);
|
||||
this.flushIntervalHandle = null;
|
||||
}
|
||||
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
recordKidVerify(kid: string): void {
|
||||
this.increment(kid);
|
||||
}
|
||||
|
||||
recordLegacyVerify(): void {
|
||||
this.increment(LEGACY_BUCKET_ID);
|
||||
}
|
||||
|
||||
async getUsageInWindow(kids: string[]): Promise<SigningKeyUsage> {
|
||||
await this.flush();
|
||||
|
||||
const bucketIds = [...kids, LEGACY_BUCKET_ID];
|
||||
const keysByBucket = bucketIds.map((bucketId) =>
|
||||
this.buildBucketKeysInWindow(bucketId),
|
||||
);
|
||||
|
||||
let valuesByBucket: (number | undefined)[][];
|
||||
|
||||
try {
|
||||
const flatValues = await this.cacheStorage.mget<number>(
|
||||
keysByBucket.flat(),
|
||||
);
|
||||
|
||||
valuesByBucket = bucketIds.map((_, bucketIndex) =>
|
||||
flatValues.slice(
|
||||
bucketIndex * WINDOW_DAYS,
|
||||
(bucketIndex + 1) * WINDOW_DAYS,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to read signing key verify counts: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
|
||||
valuesByBucket = bucketIds.map(() => []);
|
||||
}
|
||||
|
||||
const sumWindow = (windowValues: (number | undefined)[]): number =>
|
||||
windowValues.reduce<number>(
|
||||
(total, value) =>
|
||||
isDefined(value) && Number.isFinite(value) ? total + value : total,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
byKid: Object.fromEntries(
|
||||
kids.map((kid, kidIndex) => [kid, sumWindow(valuesByBucket[kidIndex])]),
|
||||
),
|
||||
legacyCount: sumWindow(valuesByBucket[bucketIds.length - 1]),
|
||||
windowDays: WINDOW_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
private increment(bucketId: string): void {
|
||||
const key = this.buildBucketKey(bucketId, Date.now());
|
||||
|
||||
this.pendingCounts.set(key, (this.pendingCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.pendingCounts.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = this.pendingCounts;
|
||||
|
||||
this.pendingCounts = new Map();
|
||||
|
||||
const entries = Array.from(snapshot.entries());
|
||||
const incrResults = await Promise.allSettled(
|
||||
entries.map(([key, increment]) =>
|
||||
this.cacheStorage.incrBy(key, increment),
|
||||
),
|
||||
);
|
||||
|
||||
const incrementedKeys: string[] = [];
|
||||
let failedCount = 0;
|
||||
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const [key, increment] = entries[index];
|
||||
|
||||
if (incrResults[index].status === 'rejected') {
|
||||
this.pendingCounts.set(
|
||||
key,
|
||||
(this.pendingCounts.get(key) ?? 0) + increment,
|
||||
);
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
incrementedKeys.push(key);
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
incrementedKeys.map((key) =>
|
||||
this.cacheStorage.expire(key, BUCKET_TTL_MS),
|
||||
),
|
||||
);
|
||||
|
||||
if (failedCount > 0) {
|
||||
this.logger.warn(
|
||||
`Failed to flush ${failedCount}/${entries.length} signing key verify bucket(s); re-buffered for next flush`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildBucketKey(bucketId: string, timestamp: number): string {
|
||||
const bucketStart = Math.floor(timestamp / ONE_DAY_MS) * ONE_DAY_MS;
|
||||
|
||||
return `${REDIS_KEY_PREFIX}:${bucketId}:${bucketStart}`;
|
||||
}
|
||||
|
||||
private buildBucketKeysInWindow(bucketId: string): string[] {
|
||||
const currentBucketStart = Math.floor(Date.now() / ONE_DAY_MS) * ONE_DAY_MS;
|
||||
|
||||
return Array.from(
|
||||
{ length: WINDOW_DAYS },
|
||||
(_, index) =>
|
||||
`${REDIS_KEY_PREFIX}:${bucketId}:${currentBucketStart - index * ONE_DAY_MS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user