import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { randomToken, sha256hex } from "../../lib/crypto.js"; import { assertRole } from "../../lib/authorize.js"; import { EVENTS, API_KEY_SCOPES } from "../../constants.js"; /** * Workspace API keys (fk_…). Raw key shown exactly once at creation; * SHA-256 hash at rest; scopes READ/WRITE/ADMIN (implied hierarchy); * optional per-key rate limit + expiry. API keys cannot manage API keys. */ @Injectable() export class ApiKeysService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, ) {} private async requireAdmin(workspaceId: string, userId: string | undefined) { if (!userId) { throw ApiError.forbidden("API key management requires user credentials (JWT).", [ { reason: "requires_user_credentials" }, ]); } await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); } async create( workspaceId: string, userId: string, input: { name: string; scopes: string[]; rateLimit?: number; expiresAt?: string }, req: any, ) { await this.requireAdmin(workspaceId, userId); const scopes = [...new Set(input.scopes)] as (typeof API_KEY_SCOPES)[number][]; for (const s of scopes) { if (!API_KEY_SCOPES.includes(s)) { throw ApiError.validation("Unknown scope.", [ { field: "scopes", issue: "unknown scope", received: s }, ]); } } if (scopes.length === 0) scopes.push("READ"); const rawKey = `fk_live_${randomToken(32)}`; const keyHash = sha256hex(rawKey); const prefix = rawKey.slice(0, 14); const dup = await this.prisma.apiKey.findFirst({ where: { workspaceId, name: input.name, revokedAt: null }, }); if (dup) { throw new ApiError("CONFLICT", 409, "An active key with this name already exists.", [ { field: "name", issue: "already_exists" }, ]); } const key = await this.prisma.apiKey.create({ data: { workspaceId, name: input.name, keyHash, prefix, scopes: scopes as never[], rateLimit: input.rateLimit ?? null, expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, createdById: userId, }, }); await this.audit.audit(req, req?.auth, { action: EVENTS.API_KEY_CREATED, resourceType: "api_key", resourceId: key.id, workspaceId, metadata: { name: input.name, scopes }, // raw key is NEVER audited }); await this.events.publish(EVENTS.API_KEY_CREATED, { workspaceId, apiKeyId: key.id, name: input.name, scopes, }); return { id: key.id, name: key.name, key: rawKey, // shown exactly once prefix: key.prefix, scopes: key.scopes, rateLimit: key.rateLimit, expiresAt: key.expiresAt, createdById: key.createdById, createdAt: key.createdAt, }; } /** List — never includes the raw key or its hash. */ async list(workspaceId: string, userId: string | undefined) { await this.requireAdmin(workspaceId, userId); const keys = await this.prisma.apiKey.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" }, select: { id: true, name: true, prefix: true, scopes: true, rateLimit: true, lastUsedAt: true, expiresAt: true, revokedAt: true, createdById: true, createdAt: true, }, }); return { data: keys }; } async update( workspaceId: string, apiKeyId: string, userId: string | undefined, patch: { name?: string; scopes?: string[]; active?: boolean; rateLimit?: number | null; expiresAt?: string | null; }, req: any, ) { await this.requireAdmin(workspaceId, userId); const key = await this.prisma.apiKey.findFirst({ where: { id: apiKeyId, workspaceId } }); if (!key) throw ApiError.notFound("API key not found."); const scopes = patch.scopes ? ([...new Set(patch.scopes)] as (typeof API_KEY_SCOPES)[number][]) : undefined; if (scopes) { for (const s of scopes) { if (!API_KEY_SCOPES.includes(s)) { throw ApiError.validation("Unknown scope.", [ { field: "scopes", issue: "unknown scope", received: s }, ]); } } } const updated = await this.prisma.apiKey.update({ where: { id: apiKeyId }, data: { ...(patch.name !== undefined ? { name: patch.name } : {}), ...(scopes ? { scopes: scopes as never[] } : {}), ...(patch.rateLimit !== undefined ? { rateLimit: patch.rateLimit } : {}), ...(patch.expiresAt !== undefined ? { expiresAt: patch.expiresAt ? new Date(patch.expiresAt) : null } : {}), ...(patch.active !== undefined ? { revokedAt: patch.active ? null : (key.revokedAt ?? new Date()) } : {}), }, }); await this.audit.audit(req, req?.auth, { action: "api_key.updated", resourceType: "api_key", resourceId: apiKeyId, workspaceId, metadata: { fields: Object.keys(patch) }, }); return updated; } /** Revoke (idempotent). */ async revoke(workspaceId: string, apiKeyId: string, userId: string | undefined, req: any) { await this.requireAdmin(workspaceId, userId); const key = await this.prisma.apiKey.findFirst({ where: { id: apiKeyId, workspaceId } }); if (!key) throw ApiError.notFound("API key not found."); if (!key.revokedAt) { await this.prisma.apiKey.update({ where: { id: apiKeyId }, data: { revokedAt: new Date() } }); // Audit the TRANSITION once (docs §7.3): an idempotent re-revoke // (DELETE again on a dead key) must not mint a second // `api_key.revoked` row — the audit trail counts revocations, not // DELETE calls. await this.audit.audit(req, req?.auth, { action: "api_key.revoked", resourceType: "api_key", resourceId: apiKeyId, workspaceId, }); } return { revoked: true }; } }