Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/events/event-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 40 additions & 0 deletions src/modules/organizations/organization.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<UserRole[]>(
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);
});
});
19 changes: 18 additions & 1 deletion src/modules/organizations/organization.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { OrganizationService } from './organization.service';
import {
inviteMemberSchema,
InviteMemberInput,
InviteMemberDto,
rotateKeysSchema,
RotateKeysInput,
updateMemberSchema,
UpdateMemberInput,
UpdateMemberDto,
Expand Down Expand Up @@ -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);
}
}
22 changes: 22 additions & 0 deletions src/modules/organizations/organization.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export const updateMemberSchema = z.object({

export type UpdateMemberInput = z.infer<typeof updateMemberSchema>;

/**
* 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<typeof rotateKeysSchema>;

// ── Swagger DTOs (documentation only; validation is done by Zod pipes) ──

export class UpdateOrganizationDto {
Expand Down Expand Up @@ -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;
}
16 changes: 16 additions & 0 deletions src/modules/organizations/organization.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() } });
}
}
126 changes: 126 additions & 0 deletions src/modules/organizations/organization.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
58 changes: 58 additions & 0 deletions src/modules/organizations/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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'];

Expand Down Expand Up @@ -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,
};
}
}
Loading