diff --git a/src/events/event-names.ts b/src/events/event-names.ts index 4271f9a..fb26e6e 100644 --- a/src/events/event-names.ts +++ b/src/events/event-names.ts @@ -6,6 +6,7 @@ export const DomainEventName = { // Organization / user OrganizationRegistered: 'organization.registered', OrganizationUpdated: 'organization.updated', + OrganizationKeyRotated: 'organization.key_rotated', UserInvited: 'user.invited', UserUpdated: 'user.updated', UserRemoved: 'user.removed', diff --git a/src/modules/organizations/organization.controller.spec.ts b/src/modules/organizations/organization.controller.spec.ts new file mode 100644 index 0000000..7b974ed --- /dev/null +++ b/src/modules/organizations/organization.controller.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '@prisma/client'; +import { OrganizationController } from './organization.controller'; +import { OrganizationService } from './organization.service'; +import { ROLES_KEY } from '../../common/decorators/roles.decorator'; +import { AuthenticatedUser } from '../../common/interfaces/authenticated-user.interface'; + +describe('OrganizationController.rotateKeys', () => { + let controller: OrganizationController; + let service: OrganizationService; + + beforeEach(() => { + service = { rotateKeys: vi.fn().mockResolvedValue({ id: 'key-new' }) } as unknown as OrganizationService; + controller = new OrganizationController(service); + }); + + it('is restricted to organization owners via @Roles(OWNER)', () => { + const reflector = new Reflector(); + const roles = reflector.get( + ROLES_KEY, + OrganizationController.prototype.rotateKeys, + ); + expect(roles).toEqual([UserRole.OWNER]); + }); + + it('delegates to the service with the caller context and validated body', async () => { + const user: AuthenticatedUser = { + id: 'user-1', + organizationId: 'org-1', + email: 'owner@acme.io', + role: UserRole.OWNER, + }; + const body = { reason: 'key may have leaked' }; + + await controller.rotateKeys(user, body); + + expect(service.rotateKeys).toHaveBeenCalledWith('org-1', 'user-1', body); + }); +}); diff --git a/src/modules/organizations/organization.controller.ts b/src/modules/organizations/organization.controller.ts index 3e299ab..92cdd1d 100644 --- a/src/modules/organizations/organization.controller.ts +++ b/src/modules/organizations/organization.controller.ts @@ -13,7 +13,8 @@ import { OrganizationService } from './organization.service'; import { inviteMemberSchema, InviteMemberInput, - InviteMemberDto, + rotateKeysSchema, + RotateKeysInput, updateMemberSchema, UpdateMemberInput, UpdateMemberDto, @@ -141,4 +142,20 @@ export class OrganizationController { removeMember(@CurrentUser() user: AuthenticatedUser, @Param('id') id: string) { return this.organizationService.removeMember(user.organizationId, user.id, id); } + + @Post('keys/rotate') + @Roles(UserRole.OWNER) + @ApiOperation({ + summary: 'Rotate the organization admin API key', + description: + 'Revokes every active admin key and issues a fresh one. The new key is returned ' + + 'exactly once — only its SHA-256 hash is stored, so an old key can never be recovered. ' + + 'Organization owners only.', + }) + rotateKeys( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(rotateKeysSchema)) body: RotateKeysInput, + ) { + return this.organizationService.rotateKeys(user.organizationId, user.id, body); + } } diff --git a/src/modules/organizations/organization.dto.ts b/src/modules/organizations/organization.dto.ts index f47eb20..c995d04 100644 --- a/src/modules/organizations/organization.dto.ts +++ b/src/modules/organizations/organization.dto.ts @@ -26,6 +26,20 @@ export const updateMemberSchema = z.object({ export type UpdateMemberInput = z.infer; +/** + * Request body for admin key rotation. Both fields are optional — the rotation + * itself is the action — but unknown keys are rejected (strict) so clients get + * a deterministic validation envelope instead of silently ignored typos. + */ +export const rotateKeysSchema = z + .object({ + name: z.string().min(1).max(120).optional(), + reason: z.string().min(1).max(500).optional(), + }) + .strict(); + +export type RotateKeysInput = z.infer; + // ── Swagger DTOs (documentation only; validation is done by Zod pipes) ── export class UpdateOrganizationDto { @@ -60,3 +74,11 @@ export class UpdateMemberDto { @ApiPropertyOptional({ enum: UserStatus }) status?: UserStatus; } + +export class RotateKeysDto { + @ApiPropertyOptional({ example: 'CI admin key', description: 'Label for the new admin key' }) + name?: string; + + @ApiPropertyOptional({ example: 'Key may have leaked', description: 'Optional reason for the rotation' }) + reason?: string; +} diff --git a/src/modules/organizations/organization.repository.ts b/src/modules/organizations/organization.repository.ts index 1f2f153..d6a545a 100644 --- a/src/modules/organizations/organization.repository.ts +++ b/src/modules/organizations/organization.repository.ts @@ -116,4 +116,20 @@ export class OrganizationRepository { where: { organizationId, role: 'OWNER', deletedAt: null }, }); } + + /** Active (non-revoked) API keys for an organization. */ + findActiveApiKeys(organizationId: string) { + return this.prisma.apiKey.findMany({ + where: { organizationId, revokedAt: null }, + orderBy: { createdAt: 'asc' }, + }); + } + + createApiKey(data: Prisma.ApiKeyUncheckedCreateInput) { + return this.prisma.apiKey.create({ data }); + } + + revokeApiKey(id: string) { + return this.prisma.apiKey.update({ where: { id }, data: { revokedAt: new Date() } }); + } } diff --git a/src/modules/organizations/organization.service.spec.ts b/src/modules/organizations/organization.service.spec.ts new file mode 100644 index 0000000..de8bc80 --- /dev/null +++ b/src/modules/organizations/organization.service.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { OrganizationService } from './organization.service'; +import { OrganizationRepository } from './organization.repository'; +import { EventBusService } from '../../events/event-bus.service'; +import { PrismaService } from '../../database/prisma.service'; +import { DomainEventName } from '../../events/event-names'; +import { sha256 } from '../../utils/crypto.util'; + +describe('OrganizationService.rotateKeys', () => { + let service: OrganizationService; + let repository: OrganizationRepository; + let eventBus: EventBusService; + + beforeEach(() => { + repository = { + findById: vi.fn().mockResolvedValue({ id: 'org-1', deletedAt: null }), + findActiveApiKeys: vi.fn().mockResolvedValue([]), + createApiKey: vi.fn().mockImplementation((data) => + Promise.resolve({ id: 'key-new', ...data, createdAt: new Date(), revokedAt: null }), + ), + revokeApiKey: vi.fn().mockImplementation((id) => + Promise.resolve({ id, revokedAt: new Date() }), + ), + } as unknown as OrganizationRepository; + + eventBus = { + emit: vi.fn().mockResolvedValue(undefined), + } as unknown as EventBusService; + + service = new OrganizationService( + repository, + eventBus, + {} as unknown as PrismaService, + ); + }); + + const adminKey = (id: string, permissions: string[] = ['admin']) => ({ + id, + organizationId: 'org-1', + createdById: 'user-1', + name: 'Admin API key', + prefix: 'ak_live_abcd', + hashedKey: 'hashed', + permissions, + allowedIps: [], + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + createdAt: new Date(), + }); + + it('revokes every active admin key and mints a fresh one', async () => { + vi.mocked(repository.findActiveApiKeys).mockResolvedValue([ + adminKey('key-1'), + adminKey('key-2'), + ]); + + const result = await service.rotateKeys('org-1', 'user-1', {}); + + expect(repository.revokeApiKey).toHaveBeenCalledWith('key-1'); + expect(repository.revokeApiKey).toHaveBeenCalledWith('key-2'); + expect(repository.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', + createdById: 'user-1', + permissions: ['admin'], + }), + ); + // The raw secret is returned exactly once, its hash is what gets stored. + expect(result.key).toMatch(/^ak_live_/); + const created = vi.mocked(repository.createApiKey).mock.calls[0][0]; + expect(created.hashedKey).toBe(sha256(result.key)); + expect(result.revokedCount).toBe(2); + }); + + it('emits an OrganizationKeyRotated domain event with audit context', async () => { + vi.mocked(repository.findActiveApiKeys).mockResolvedValue([adminKey('key-1')]); + + await service.rotateKeys('org-1', 'user-1', { reason: 'possible leak' }); + + expect(eventBus.emit).toHaveBeenCalledWith( + DomainEventName.OrganizationKeyRotated, + expect.objectContaining({ + keyId: 'key-new', + revokedCount: 1, + reason: 'possible leak', + }), + expect.objectContaining({ + organizationId: 'org-1', + actorId: 'user-1', + aggregateType: 'organization', + aggregateId: 'org-1', + }), + ); + }); + + it('mints a key when the organization has no admin key yet', async () => { + vi.mocked(repository.findActiveApiKeys).mockResolvedValue([ + adminKey('key-1', ['transactions:read']), + ]); + + const result = await service.rotateKeys('org-1', 'user-1', {}); + + // Non-admin keys are never touched. + expect(repository.revokeApiKey).not.toHaveBeenCalled(); + expect(repository.createApiKey).toHaveBeenCalledTimes(1); + expect(result.revokedCount).toBe(0); + }); + + it('uses the provided name for the new key', async () => { + await service.rotateKeys('org-1', 'user-1', { name: 'CI admin key' }); + + expect(repository.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ name: 'CI admin key' }), + ); + }); + + it('throws when the organization does not exist', async () => { + vi.mocked(repository.findById).mockResolvedValue(null); + + await expect(service.rotateKeys('org-missing', 'user-1', {})).rejects.toThrow( + "Organization 'org-missing' not found", + ); + expect(repository.createApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/organizations/organization.service.ts b/src/modules/organizations/organization.service.ts index 3c7b20c..c7afa39 100644 --- a/src/modules/organizations/organization.service.ts +++ b/src/modules/organizations/organization.service.ts @@ -3,6 +3,7 @@ import { Organization, Prisma, UserRole, UserStatus } from '@prisma/client'; import { OrganizationRepository } from './organization.repository'; import { InviteMemberInput, + RotateKeysInput, UpdateMemberInput, UpdateOrganizationInput, } from './organization.dto'; @@ -20,6 +21,7 @@ import { Paginated } from '../../common/interfaces/api-response.interface'; import { EventBusService } from '../../events/event-bus.service'; import { DomainEventName } from '../../events/event-names'; import { PrismaService } from '../../database/prisma.service'; +import { generateApiKey } from '../../utils/crypto.util'; const MEMBER_SORTABLE = ['createdAt', 'name', 'email', 'role', 'status']; @@ -158,4 +160,60 @@ export class OrganizationService { ); return { id: memberId, removed: true }; } + + /** + * Rotates the organization's admin API key (owners only — enforced by + * @Roles(OWNER) at the controller). Every currently active admin key is + * revoked and a single fresh admin key is minted. Only the SHA-256 hash of + * the new key is persisted; the raw secret is returned to the caller exactly + * once. An immutable DomainEvent is emitted on completion, which the audit + * listener also records as an AuditLog row. + */ + async rotateKeys(organizationId: string, actorId: string, input: RotateKeysInput) { + // Guards against deleted/missing orgs and keeps the event aggregate valid. + await this.getCurrent(organizationId); + + const activeKeys = await this.repository.findActiveApiKeys(organizationId); + const adminKeys = activeKeys.filter((key) => + key.permissions.some((permission) => permission.toLowerCase() === 'admin'), + ); + + // Supersede every active admin key — rotation must leave no old key valid. + for (const key of adminKeys) { + await this.repository.revokeApiKey(key.id); + } + + const { raw, prefix, hashedKey } = generateApiKey('live'); + const apiKey = await this.repository.createApiKey({ + organizationId, + createdById: actorId, + name: input.name ?? 'Admin API key', + prefix, + hashedKey, + permissions: ['admin'], + allowedIps: [], + }); + + await this.eventBus.emit( + DomainEventName.OrganizationKeyRotated, + { + keyId: apiKey.id, + name: apiKey.name, + prefix: apiKey.prefix, + revokedCount: adminKeys.length, + reason: input.reason ?? null, + }, + { organizationId, actorId, aggregateType: 'organization', aggregateId: organizationId }, + ); + + // The raw secret is shown exactly once and never stored or logged. + return { + id: apiKey.id, + name: apiKey.name, + prefix: apiKey.prefix, + permissions: apiKey.permissions, + revokedCount: adminKeys.length, + key: raw, + }; + } }