Skip to content
Open
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
23 changes: 23 additions & 0 deletions backend/monitoring/lockMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { advisoryLockService } from '../services/shared/locking';

export function collectLockMetrics(): Record<string, number> {
const metrics = advisoryLockService.getMetrics();
const now = Date.now();

const avgAcquisitionTime =
metrics.lockAcquisitionTime.length > 0
? metrics.lockAcquisitionTime.reduce((a, b) => a + b, 0) / metrics.lockAcquisitionTime.length
: 0;

return {
lock_avg_acquisition_ms: avgAcquisitionTime,
lock_contention_total: metrics.contentionCount,
lock_timeout_total: metrics.timeoutCount,
lock_last_recorded_at: now,
};
}

export const lockMetricsExporter = {
getMetrics: collectLockMetrics,
resetMetrics: () => advisoryLockService.resetMetrics(),
};
75 changes: 75 additions & 0 deletions backend/services/auth/__tests__/ApiKeyRotationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ApiKeyRotationService } from '../domain/ApiKeyRotationService';

describe('ApiKeyRotationService', () => {
let service: ApiKeyRotationService;

beforeEach(() => {
service = new ApiKeyRotationService();
});

describe('registerKey', () => {
it('registers a new API key for a merchant', async () => {
const result = await service.registerKey('merchant-1');
expect(result.keyId).toBeDefined();
expect(result.rawKey).toMatch(/^sk_/);
expect(result.record.merchantId).toBe('merchant-1');
expect(result.record.status).toBe('active');
});
});

describe('rotateKey', () => {
it('rotates an existing key', async () => {
const { keyId } = await service.registerKey('merchant-1');
const rotated = await service.rotateKey(keyId);
expect(rotated.id).not.toBe(keyId);
expect(rotated.status).toBe('active');
});

it('throws for non-existent key', async () => {
await expect(service.rotateKey('nonexistent')).rejects.toThrow('not found');
});
});

describe('forceRotateKey', () => {
it('immediately revokes and replaces a key', async () => {
const { keyId } = await service.registerKey('merchant-1');
const rotated = await service.forceRotateKey(keyId);
expect(rotated.status).toBe('active');
});
});

describe('getPolicy / updatePolicy', () => {
it('returns default policy', async () => {
const policy = await service.getPolicy('merchant-1');
expect(policy.intervalDays).toBe(30);
expect(policy.gracePeriodHours).toBe(24);
});

it('updates policy', async () => {
const updated = await service.updatePolicy('merchant-1', { intervalDays: 60 });
expect(updated.intervalDays).toBe(60);
expect(updated.gracePeriodHours).toBe(24);
});
});

describe('getKeysDueForRotation', () => {
it('returns empty when no keys are due', async () => {
const due = await service.getKeysDueForRotation();
expect(due).toHaveLength(0);
});
});

describe('validateKey', () => {
it('validates a raw key', async () => {
const { rawKey, keyId } = await service.registerKey('merchant-1');
const record = await service.validateKey(rawKey);
expect(record).not.toBeNull();
expect(record!.merchantId).toBe('merchant-1');
});

it('returns null for unknown key', async () => {
const record = await service.validateKey('sk_invalid');
expect(record).toBeNull();
});
});
});
51 changes: 51 additions & 0 deletions backend/services/auth/controller/cmkConfigController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { kmsProvider } from '../../shared/encryption';
import { ok, fail } from '../../shared/apiResponse';
import type { ApiResponse } from '../../shared/apiResponse';

export interface CmkConfig {
keyId: string;
keyArn: string;
provider: 'aws_kms' | 'hashicorp_vault';
enabled: boolean;
createdAt: string;
}

export class CmkConfigController {
private cmkConfigs = new Map<string, CmkConfig>();

getConfig(merchantId: string, requestId?: string): ApiResponse<CmkConfig | null> {
try {
const config = this.cmkConfigs.get(merchantId);
return ok(config ?? null, requestId);
} catch (err) {
return fail('INTERNAL_SERVER_ERROR', err instanceof Error ? err.message : 'Failed to get config', requestId);
}
}

setConfig(merchantId: string, config: Omit<CmkConfig, 'createdAt'>, requestId?: string): ApiResponse<CmkConfig> {
try {
if (!config.keyId || !config.keyArn) {
return fail('ENCRYPTION_KEY_NOT_FOUND', 'Key ID and ARN are required', requestId);
}

const cmkConfig: CmkConfig = {
...config,
createdAt: new Date().toISOString(),
};

kmsProvider.registerMasterKey(config.keyId, config.keyArn);
this.cmkConfigs.set(merchantId, cmkConfig);

return ok(cmkConfig, requestId);
} catch (err) {
return fail('ENCRYPTION_KEK_NOT_FOUND', err instanceof Error ? err.message : 'Failed to set config', requestId);
}
}

removeConfig(merchantId: string, requestId?: string): ApiResponse<{ removed: boolean }> {
const existed = this.cmkConfigs.delete(merchantId);
return ok({ removed: existed }, requestId);
}
}

export const cmkConfigController = new CmkConfigController();
42 changes: 42 additions & 0 deletions backend/services/auth/controller/rotationConfigController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { apiKeyRotationService } from '../domain/ApiKeyRotationService';
import { ok, fail } from '../../shared/apiResponse';
import type { ApiResponse } from '../../shared/apiResponse';
import type { ApiKeyRotationPolicy } from '../interfaces';

export class RotationConfigController {
async getPolicy(merchantId: string, requestId?: string): Promise<ApiResponse<ApiKeyRotationPolicy>> {
try {
const policy = await apiKeyRotationService.getPolicy(merchantId);
return ok(policy, requestId);
} catch (err) {
return fail('INTERNAL_SERVER_ERROR', err instanceof Error ? err.message : 'Failed to get policy', requestId);
}
}

async updatePolicy(
merchantId: string,
policy: Partial<ApiKeyRotationPolicy>,
requestId?: string
): Promise<ApiResponse<ApiKeyRotationPolicy>> {
try {
const updated = await apiKeyRotationService.updatePolicy(merchantId, policy);
return ok(updated, requestId);
} catch (err) {
return fail('INTERNAL_SERVER_ERROR', err instanceof Error ? err.message : 'Failed to update policy', requestId);
}
}

async forceRotate(keyId: string, requestId?: string): Promise<ApiResponse<{ keyId: string; status: string }>> {
try {
const record = await apiKeyRotationService.forceRotateKey(keyId);
return ok({ keyId: record.id, status: 'revoked' }, requestId);
} catch (err) {
if (err instanceof Error && err.message.includes('not found')) {
return fail('AUTH_API_KEY_NOT_FOUND', err.message, requestId);
}
return fail('AUTH_API_KEY_ROTATION_FAILED', err instanceof Error ? err.message : 'Rotation failed', requestId);
}
}
}

export const rotationConfigController = new RotationConfigController();
167 changes: 167 additions & 0 deletions backend/services/auth/domain/ApiKeyRotationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { randomBytes, createHash } from 'crypto';
import { AuthError } from '../errors';
import { logger } from '../../shared/logging';
import type { ApiKeyRecord, ApiKeyRotationPolicy, IApiKeyRotationService } from '../interfaces';

const KEY_PREFIX_LENGTH = 8;
const KEY_BYTE_LENGTH = 32;
const KEY_HASH_ALGORITHM = 'sha256';
const MAX_HISTORY = 5;

export class ApiKeyRotationService implements IApiKeyRotationService {
private keys = new Map<string, ApiKeyRecord>();
private history = new Map<string, ApiKeyRecord[]>();
private policies = new Map<string, ApiKeyRotationPolicy>();

constructor() {
this.policies.set('default', { intervalDays: 30, gracePeriodHours: 24 });
}

async rotateKey(keyId: string): Promise<ApiKeyRecord> {
const existing = this.keys.get(keyId);
if (!existing) throw AuthError.apiKeyNotFound(keyId);

const policy = this.policies.get(existing.merchantId) ?? this.policies.get('default')!;

const newKey = this.generateKey();
const now = new Date();
const expiresAt = new Date(now.getTime() + policy.intervalDays * 24 * 60 * 60 * 1000);
const gracePeriodEndsAt = new Date(now.getTime() + policy.gracePeriodHours * 60 * 60 * 1000);

existing.status = 'expired';
existing.rotatedAt = now.toISOString();
existing.expiresAt = now.toISOString();

const oldHistory = this.history.get(keyId) ?? [];
oldHistory.push({ ...existing });
if (oldHistory.length > MAX_HISTORY) oldHistory.shift();
this.history.set(keyId, oldHistory);

const newRecord: ApiKeyRecord = {
id: keyId + '_' + Date.now(),
merchantId: existing.merchantId,
keyPrefix: newKey.prefix,
keyHash: newKey.hash,
status: 'active',
rotatedAt: null,
expiresAt: expiresAt.toISOString(),
gracePeriodEndsAt: gracePeriodEndsAt.toISOString(),
createdAt: now.toISOString(),
};

this.keys.set(keyId, newRecord);
logger.info('API key rotated', { keyId, merchantId: existing.merchantId, expiresAt: newRecord.expiresAt });

return newRecord;
}

async forceRotateKey(keyId: string): Promise<ApiKeyRecord> {
const existing = this.keys.get(keyId);
if (!existing) throw AuthError.apiKeyNotFound(keyId);

existing.status = 'revoked';
existing.rotatedAt = new Date().toISOString();
existing.expiresAt = new Date().toISOString();

const policy = this.policies.get(existing.merchantId) ?? this.policies.get('default')!;
const newKey = this.generateKey();
const now = new Date();
const expiresAt = new Date(now.getTime() + policy.intervalDays * 24 * 60 * 60 * 1000);

const newRecord: ApiKeyRecord = {
id: keyId + '_' + Date.now(),
merchantId: existing.merchantId,
keyPrefix: newKey.prefix,
keyHash: newKey.hash,
status: 'active',
rotatedAt: null,
expiresAt: expiresAt.toISOString(),
gracePeriodEndsAt: null,
createdAt: now.toISOString(),
};

this.keys.set(keyId, newRecord);
logger.info('API key force-rotated (immediate revoke)', { keyId, merchantId: existing.merchantId });

return newRecord;
}

async getRotationHistory(keyId: string): Promise<ApiKeyRecord[]> {
return this.history.get(keyId) ?? [];
}

async getPolicy(merchantId: string): Promise<ApiKeyRotationPolicy> {
return this.policies.get(merchantId) ?? this.policies.get('default')!;
}

async updatePolicy(merchantId: string, policy: Partial<ApiKeyRotationPolicy>): Promise<ApiKeyRotationPolicy> {
const current = this.policies.get(merchantId) ?? { ...this.policies.get('default')! };
const updated: ApiKeyRotationPolicy = {
intervalDays: policy.intervalDays ?? current.intervalDays,
gracePeriodHours: policy.gracePeriodHours ?? current.gracePeriodHours,
};
this.policies.set(merchantId, updated);
logger.info('API key rotation policy updated', { merchantId, policy: updated });
return updated;
}

async registerKey(merchantId: string): Promise<{ keyId: string; rawKey: string; record: ApiKeyRecord }> {
const keyId = `key_${randomBytes(8).toString('hex')}`;
const key = this.generateKey();
const policy = this.policies.get(merchantId) ?? this.policies.get('default')!;
const now = new Date();
const expiresAt = new Date(now.getTime() + policy.intervalDays * 24 * 60 * 60 * 1000);

const record: ApiKeyRecord = {
id: keyId,
merchantId,
keyPrefix: key.prefix,
keyHash: key.hash,
status: 'active',
rotatedAt: null,
expiresAt: expiresAt.toISOString(),
gracePeriodEndsAt: null,
createdAt: now.toISOString(),
};

this.keys.set(keyId, record);
return { keyId, rawKey: key.raw, record };
}

async validateKey(rawKey: string): Promise<ApiKeyRecord | null> {
const hash = createHash(KEY_HASH_ALGORITHM).update(rawKey).digest('hex');
for (const [, record] of this.keys) {
if (record.keyHash === hash) {
if (record.status === 'revoked') throw AuthError.apiKeyRevoked(record.id);
if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
throw AuthError.apiKeyExpired(record.id);
}
return record;
}
}
return null;
}

async getKeysDueForRotation(): Promise<ApiKeyRecord[]> {
const due: ApiKeyRecord[] = [];
const now = new Date();

for (const [, record] of this.keys) {
if (record.status !== 'active') continue;
if (record.expiresAt && new Date(record.expiresAt) <= now) {
due.push(record);
}
}

return due;
}

private generateKey(): { raw: string; prefix: string; hash: string } {
const raw = 'sk_' + randomBytes(KEY_BYTE_LENGTH).toString('base64url');
const prefix = raw.substring(0, KEY_PREFIX_LENGTH);
const hash = createHash(KEY_HASH_ALGORITHM).update(raw).digest('hex');
return { raw, prefix, hash };
}
}

export const apiKeyRotationService = new ApiKeyRotationService();
31 changes: 31 additions & 0 deletions backend/services/auth/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DomainError } from '../shared/errors';
import { ErrorCode } from '../shared/apiResponse';

export const AuthErrorCode = {
API_KEY_NOT_FOUND: 'AUTH_API_KEY_NOT_FOUND' as ErrorCode,
API_KEY_EXPIRED: 'AUTH_API_KEY_EXPIRED' as ErrorCode,
API_KEY_ROTATION_FAILED: 'AUTH_API_KEY_ROTATION_FAILED' as ErrorCode,
API_KEY_REVOKED: 'AUTH_API_KEY_REVOKED' as ErrorCode,
} as const;

export class AuthError extends DomainError {
constructor(code: ErrorCode, message: string, details?: Record<string, string>) {
super(code, message, details);
}

static apiKeyNotFound(keyId: string): AuthError {
return new AuthError(AuthErrorCode.API_KEY_NOT_FOUND, `API key not found: ${keyId}`, { keyId });
}

static apiKeyExpired(keyId: string): AuthError {
return new AuthError(AuthErrorCode.API_KEY_EXPIRED, `API key expired: ${keyId}`, { keyId });
}

static rotationFailed(keyId: string, reason: string): AuthError {
return new AuthError(AuthErrorCode.API_KEY_ROTATION_FAILED, `Key rotation failed for ${keyId}: ${reason}`, { keyId, reason });
}

static apiKeyRevoked(keyId: string): AuthError {
return new AuthError(AuthErrorCode.API_KEY_REVOKED, `API key revoked: ${keyId}`, { keyId });
}
}
Loading