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:
@@ -15,6 +15,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
|
||||
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
|
||||
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
|
||||
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
|
||||
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
|
||||
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
|
||||
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
|
||||
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
|
||||
@@ -29,6 +30,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
@@ -76,6 +78,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
UserVarsModule,
|
||||
UpgradeModule,
|
||||
UserModule,
|
||||
JwtModule,
|
||||
],
|
||||
providers: [
|
||||
AdminPanelResolver,
|
||||
@@ -85,6 +88,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AdminPanelBillingService,
|
||||
AdminPanelChatService,
|
||||
AdminPanelConfigService,
|
||||
AdminPanelSigningKeyService,
|
||||
AdminPanelVersionService,
|
||||
AdminPanelHealthService,
|
||||
AdminPanelQueueService,
|
||||
|
||||
@@ -25,6 +25,9 @@ import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/con
|
||||
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
|
||||
import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto';
|
||||
import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto';
|
||||
import { RevokeSigningKeyInput } from 'src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input';
|
||||
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
|
||||
import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto';
|
||||
import { SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
|
||||
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
|
||||
@@ -37,6 +40,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
|
||||
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
|
||||
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
|
||||
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
|
||||
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
|
||||
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
|
||||
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
|
||||
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
|
||||
@@ -98,6 +102,7 @@ export class AdminPanelResolver {
|
||||
private readonly adminConfigService: AdminPanelConfigService,
|
||||
private readonly adminVersionService: AdminPanelVersionService,
|
||||
private readonly adminPanelHealthService: AdminPanelHealthService,
|
||||
private readonly adminPanelSigningKeyService: AdminPanelSigningKeyService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private adminPanelQueueService: AdminPanelQueueService,
|
||||
private featureFlagService: FeatureFlagService,
|
||||
@@ -729,4 +734,18 @@ export class AdminPanelResolver {
|
||||
|
||||
return this.upgradeStatusService.getWorkspaceStatuses(workspaceIds);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => SigningKeysAdminPanelDTO)
|
||||
async getSigningKeys(): Promise<SigningKeysAdminPanelDTO> {
|
||||
return this.adminPanelSigningKeyService.getSigningKeys();
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => SigningKeyDTO)
|
||||
async revokeSigningKey(
|
||||
@Args() { id }: RevokeSigningKeyInput,
|
||||
): Promise<SigningKeyDTO> {
|
||||
return this.adminPanelSigningKeyService.revokeSigningKey(id);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class RevokeSigningKeyInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType()
|
||||
export class SigningKeyDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
publicKey: string;
|
||||
|
||||
@Field()
|
||||
isCurrent: boolean;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
revokedAt: Date | null;
|
||||
|
||||
@Field(() => Int)
|
||||
verifyCountInWindow: number;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class SigningKeysAdminPanelDTO {
|
||||
@Field(() => [SigningKeyDTO])
|
||||
signingKeys: SigningKeyDTO[];
|
||||
|
||||
@Field(() => Int)
|
||||
legacyVerifyCountInWindow: number;
|
||||
|
||||
@Field(() => Int)
|
||||
verifyWindowDays: number;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
|
||||
import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto';
|
||||
import { type SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPanelSigningKeyService {
|
||||
constructor(
|
||||
private readonly jwtKeyManagerService: JwtKeyManagerService,
|
||||
private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService,
|
||||
) {}
|
||||
|
||||
async getSigningKeys(): Promise<SigningKeysAdminPanelDTO> {
|
||||
const signingKeys = await this.jwtKeyManagerService.listSigningKeys();
|
||||
const usage = await this.signingKeyVerifyCounterService.getUsageInWindow(
|
||||
signingKeys.map((signingKey) => signingKey.id),
|
||||
);
|
||||
|
||||
return {
|
||||
signingKeys: signingKeys.map((signingKey) =>
|
||||
this.toSigningKeyDTO(signingKey, usage.byKid[signingKey.id] ?? 0),
|
||||
),
|
||||
legacyVerifyCountInWindow: usage.legacyCount,
|
||||
verifyWindowDays: usage.windowDays,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeSigningKey(id: string): Promise<SigningKeyDTO> {
|
||||
const revoked = await this.jwtKeyManagerService.revokeSigningKey(id);
|
||||
const usage = await this.signingKeyVerifyCounterService.getUsageInWindow([
|
||||
revoked.id,
|
||||
]);
|
||||
|
||||
return this.toSigningKeyDTO(revoked, usage.byKid[revoked.id] ?? 0);
|
||||
}
|
||||
|
||||
private toSigningKeyDTO(
|
||||
signingKey: SigningKeyEntity,
|
||||
verifyCountInWindow: number,
|
||||
): SigningKeyDTO {
|
||||
return {
|
||||
id: signingKey.id,
|
||||
publicKey: signingKey.publicKey,
|
||||
isCurrent: signingKey.isCurrent,
|
||||
createdAt: signingKey.createdAt,
|
||||
revokedAt: signingKey.revokedAt,
|
||||
verifyCountInWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user