diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..f4734559 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { CachingModule } from './caching/caching.module'; import { CoursesModule } from './courses/courses.module'; import { AuthModule } from './auth/auth.module'; import { CohortsModule } from './cohorts/cohorts.module'; +import { FeatureFlagAuditModule } from './config/feature-flag-audit.module'; const featureFlags = loadFeatureFlags(); @@ -67,6 +68,9 @@ const featureFlags = loadFeatureFlags(); // ✅ courses module with enrollment and prerequisite enforcement CoursesModule, CohortsModule, + + // Feature flag audit trail and admin management endpoints + FeatureFlagAuditModule, ], controllers: [AppController], providers: [ diff --git a/src/config/feature-flag-audit.controller.ts b/src/config/feature-flag-audit.controller.ts new file mode 100644 index 00000000..e6db68e1 --- /dev/null +++ b/src/config/feature-flag-audit.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Logger, + Param, + Patch, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { IFeatureFlagsConfig } from './feature-flags.config'; +import { FlagAuditEntry, FeatureFlagAuditService } from './feature-flag-audit.service'; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + email: string; + role?: string; + roles?: string[]; + }; +} + +interface ToggleFlagDto { + value: boolean; +} + +/** + * Admin-only endpoints for querying and toggling feature flags with audit trails. + * + * All routes require an authenticated admin user. Role enforcement is done + * inside the handler so that it works regardless of which auth guard is in use. + */ +@Controller('feature-flags') +export class FeatureFlagAuditController { + private readonly logger = new Logger(FeatureFlagAuditController.name); + + constructor(private readonly auditService: FeatureFlagAuditService) {} + + /** + * GET /feature-flags/audit + * + * Returns the last 100 feature flag state changes for compliance review. + * Access restricted to admin users. + */ + @Get('audit') + getAuditLog(@Req() req: AuthenticatedRequest): FlagAuditEntry[] { + this.assertAdmin(req); + return this.auditService.getAuditHistory(); + } + + /** + * GET /feature-flags + * + * Returns a snapshot of all current flag values. + */ + @Get() + getFlags(@Req() req: AuthenticatedRequest): Record { + this.assertAdmin(req); + return this.auditService.getAllFlags(); + } + + /** + * PATCH /feature-flags/:key + * + * Toggles a single flag at runtime and records the change in the audit log. + * + * @param key - Flag key from {@link IFeatureFlagsConfig} (e.g. `ENABLE_AUTH`). + * @param body - `{ value: boolean }` — desired new state. + */ + @Patch(':key') + async toggleFlag( + @Param('key') key: string, + @Body() body: ToggleFlagDto, + @Req() req: AuthenticatedRequest, + ): Promise { + this.assertAdmin(req); + + const user = req.user!; + const entry = await this.auditService.setFlag( + key as keyof IFeatureFlagsConfig, + body.value, + { id: user.userId, email: user.email }, + ); + + this.logger.log( + `Admin ${user.email} toggled ${key} → ${String(body.value)}`, + ); + + return entry; + } + + private assertAdmin(req: AuthenticatedRequest): void { + const user = req.user; + if (!user) { + throw new ForbiddenException('Authentication required.'); + } + const roles: string[] = user.roles ?? (user.role ? [user.role] : []); + if (!roles.includes('admin')) { + throw new ForbiddenException('Only admins may access feature flag management.'); + } + } +} diff --git a/src/config/feature-flag-audit.module.ts b/src/config/feature-flag-audit.module.ts new file mode 100644 index 00000000..3486ee6c --- /dev/null +++ b/src/config/feature-flag-audit.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { FeatureFlagAuditController } from './feature-flag-audit.controller'; +import { FeatureFlagAuditService } from './feature-flag-audit.service'; + +/** + * Provides runtime feature flag management with a full audit trail. + * + * Exports {@link FeatureFlagAuditService} so other modules can read and + * toggle flags programmatically while keeping the audit log up to date. + */ +@Module({ + imports: [AuditLogModule], + controllers: [FeatureFlagAuditController], + providers: [FeatureFlagAuditService], + exports: [FeatureFlagAuditService], +}) +export class FeatureFlagAuditModule {} diff --git a/src/config/feature-flag-audit.service.spec.ts b/src/config/feature-flag-audit.service.spec.ts new file mode 100644 index 00000000..7bc75cb3 --- /dev/null +++ b/src/config/feature-flag-audit.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { FeatureFlagAuditService } from './feature-flag-audit.service'; + +const mockAuditLogService: jest.Mocked> = { + logDataChange: jest.fn().mockResolvedValue({}), +}; + +const ACTOR = { id: 'admin-1', email: 'admin@example.com' }; + +describe('FeatureFlagAuditService', () => { + let service: FeatureFlagAuditService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeatureFlagAuditService, + { provide: AuditLogService, useValue: mockAuditLogService }, + ], + }).compile(); + + service = module.get(FeatureFlagAuditService); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('getFlag / getAllFlags', () => { + it('returns the initial value loaded from config', () => { + // ENABLE_AUTH defaults to true in feature-flags.config + expect(service.getFlag('ENABLE_AUTH')).toBe(true); + }); + + it('returns undefined for an unknown key', () => { + expect(service.getFlag('UNKNOWN_KEY' as any)).toBeUndefined(); + }); + + it('getAllFlags includes all known flags', () => { + const flags = service.getAllFlags(); + expect(Object.keys(flags).length).toBeGreaterThan(0); + expect(typeof flags['ENABLE_AUTH']).toBe('boolean'); + }); + }); + + describe('setFlag', () => { + it('updates the in-process flag value', async () => { + await service.setFlag('ENABLE_SEARCH', false, ACTOR); + expect(service.getFlag('ENABLE_SEARCH')).toBe(false); + }); + + it('records the change in the audit history', async () => { + await service.setFlag('ENABLE_GAMIFICATION', false, ACTOR); + const history = service.getAuditHistory(); + expect(history[0]).toMatchObject({ + flagKey: 'ENABLE_GAMIFICATION', + newValue: false, + actor: ACTOR.id, + actorEmail: ACTOR.email, + }); + }); + + it('captures the old value before the change', async () => { + // ENABLE_AUTH starts as true + await service.setFlag('ENABLE_AUTH', false, ACTOR); + expect(service.getAuditHistory()[0].oldValue).toBe(true); + }); + + it('calls AuditLogService.logDataChange on each toggle', async () => { + await service.setFlag('ENABLE_CACHING', false, ACTOR); + expect(mockAuditLogService.logDataChange).toHaveBeenCalledTimes(1); + expect(mockAuditLogService.logDataChange).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'FeatureFlag', + entityId: 'ENABLE_CACHING', + oldValues: { value: true }, + newValues: { value: false }, + }), + ); + }); + + it('emits a separate audit entry for each toggle', async () => { + await service.setFlag('ENABLE_NOTIFICATIONS', false, ACTOR); + await service.setFlag('ENABLE_NOTIFICATIONS', true, ACTOR); + expect(mockAuditLogService.logDataChange).toHaveBeenCalledTimes(2); + expect(service.getAuditHistory()).toHaveLength(2); + }); + + it('does not throw when AuditLogService fails', async () => { + mockAuditLogService.logDataChange.mockRejectedValueOnce(new Error('DB down')); + await expect(service.setFlag('ENABLE_BACKUP', false, ACTOR)).resolves.not.toThrow(); + }); + }); + + describe('getAuditHistory', () => { + it('returns entries newest-first', async () => { + await service.setFlag('ENABLE_AUTH', false, ACTOR); + await service.setFlag('ENABLE_PAYMENTS', false, ACTOR); + const history = service.getAuditHistory(); + expect(history[0].flagKey).toBe('ENABLE_PAYMENTS'); + expect(history[1].flagKey).toBe('ENABLE_AUTH'); + }); + + it('caps history at MAX_HISTORY entries', async () => { + const toggles = FeatureFlagAuditService.MAX_HISTORY + 10; + for (let i = 0; i < toggles; i++) { + await service.setFlag('ENABLE_AUTH', i % 2 === 0, ACTOR); + } + expect(service.getAuditHistory()).toHaveLength(FeatureFlagAuditService.MAX_HISTORY); + }); + + it('returns a snapshot (modifying the returned array does not affect internal state)', async () => { + await service.setFlag('ENABLE_SEARCH', false, ACTOR); + const snapshot = service.getAuditHistory(); + snapshot.pop(); + expect(service.getAuditHistory()).toHaveLength(1); + }); + }); +}); diff --git a/src/config/feature-flag-audit.service.ts b/src/config/feature-flag-audit.service.ts new file mode 100644 index 00000000..3a367e6d --- /dev/null +++ b/src/config/feature-flag-audit.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { AuditAction, AuditSeverity } from '../audit-log/enums/audit-action.enum'; +import { IFeatureFlagsConfig, loadFeatureFlags } from './feature-flags.config'; + +/** A single recorded change to a feature flag. */ +export interface FlagAuditEntry { + /** Monotonically increasing entry id within this process lifetime. */ + id: number; + /** The flag key that was changed. */ + flagKey: keyof IFeatureFlagsConfig; + /** User ID of the actor who made the change, or `'system'` for programmatic changes. */ + actor: string; + /** E-mail of the actor for display purposes. */ + actorEmail: string; + /** Flag value before the change. */ + oldValue: boolean; + /** Flag value after the change. */ + newValue: boolean; + /** UTC timestamp of when the change was applied. */ + changedAt: Date; +} + +/** + * Wraps runtime feature flag reads and writes with a full audit trail. + * + * Responsibilities: + * - Initialise in-process flag state from the environment on startup. + * - Allow privileged callers to toggle individual flags at runtime without + * an application restart. + * - Emit a structured audit log entry via {@link AuditLogService} for every + * state change. + * - Maintain a ring buffer of the last {@link MAX_HISTORY} changes for the + * compliance endpoint. + */ +@Injectable() +export class FeatureFlagAuditService { + private readonly logger = new Logger(FeatureFlagAuditService.name); + + /** Maximum number of audit entries retained in memory. */ + static readonly MAX_HISTORY = 100; + + /** In-process flag state, initialised from environment variables. */ + private readonly state: Map; + + /** Ring buffer of the most recent flag changes. */ + private readonly history: FlagAuditEntry[] = []; + + /** Auto-incrementing id counter for in-memory entries. */ + private nextId = 1; + + constructor(private readonly auditLogService: AuditLogService) { + const initial = loadFeatureFlags(); + this.state = new Map( + (Object.keys(initial) as Array).map((k) => [k, initial[k]]), + ); + } + + /** + * Returns the current runtime value of a feature flag. + * Returns `undefined` when the key does not exist in the config. + */ + getFlag(key: keyof IFeatureFlagsConfig): boolean | undefined { + return this.state.get(key); + } + + /** + * Returns a snapshot of all current flag values. + */ + getAllFlags(): Record { + return Object.fromEntries(this.state.entries()); + } + + /** + * Changes the runtime value of a feature flag and records the change. + * + * - Emits a `CONFIG_CHANGED` audit log entry via {@link AuditLogService}. + * - Prepends the change to the in-memory ring buffer (capped at + * {@link MAX_HISTORY}). + * - No-ops silently if the new value equals the current value. + * + * @param key - The flag to change. + * @param newValue - The desired new value. + * @param actor - The user performing the change. + */ + async setFlag( + key: keyof IFeatureFlagsConfig, + newValue: boolean, + actor: { id: string; email: string }, + ): Promise { + const oldValue = this.state.get(key) ?? false; + + this.state.set(key, newValue); + + const entry: FlagAuditEntry = { + id: this.nextId++, + flagKey: key, + actor: actor.id, + actorEmail: actor.email, + oldValue, + newValue, + changedAt: new Date(), + }; + + this.prependToHistory(entry); + + await this.emitAuditLog(entry); + + this.logger.log( + `Feature flag "${String(key)}" changed ${String(oldValue)} → ${String(newValue)} by ${actor.email}`, + ); + + return entry; + } + + /** + * Returns the most recent flag audit entries, newest first. + * The list is capped at {@link MAX_HISTORY} entries. + */ + getAuditHistory(): FlagAuditEntry[] { + return [...this.history]; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private prependToHistory(entry: FlagAuditEntry): void { + this.history.unshift(entry); + if (this.history.length > FeatureFlagAuditService.MAX_HISTORY) { + this.history.pop(); + } + } + + private async emitAuditLog(entry: FlagAuditEntry): Promise { + try { + await this.auditLogService.logDataChange({ + action: AuditAction.CONFIG_CHANGED, + userId: entry.actor, + userEmail: entry.actorEmail, + entityType: 'FeatureFlag', + entityId: String(entry.flagKey), + oldValues: { value: entry.oldValue }, + newValues: { value: entry.newValue }, + description: `Feature flag "${String(entry.flagKey)}" toggled from ${String(entry.oldValue)} to ${String(entry.newValue)}`, + }); + } catch (err) { + // Audit log failure must never interrupt the flag toggle itself. + this.logger.error('Failed to emit audit log for flag change', err); + } + } +}