Skip to content

Commit e54645f

Browse files
committed
feat: add feature flag audit log for compliance traceability (#858)
Introduce FeatureFlagAuditService that wraps runtime feature flag reads and writes with a full audit trail. - Initialises in-process flag state from environment variables at startup - setFlag() updates the runtime value and emits a CONFIG_CHANGED entry via AuditLogService (actor, old value, new value, timestamp) - Maintains a ring buffer of the last 100 changes (MAX_HISTORY) - Audit log failure is caught and logged so it never interrupts a toggle - Expose GET /feature-flags/audit (admin-only) returning the change history - Expose GET /feature-flags for a current-state snapshot - Expose PATCH /feature-flags/:key for runtime toggling with audit trail - Register FeatureFlagAuditModule in AppModule - 12 unit tests covering reads, writes, audit emission, failure isolation, ring-buffer capping, and snapshot immutability
1 parent 142fcbd commit e54645f

5 files changed

Lines changed: 393 additions & 0 deletions

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { CachingModule } from './caching/caching.module';
3232
import { CoursesModule } from './courses/courses.module';
3333
import { AuthModule } from './auth/auth.module';
3434
import { CohortsModule } from './cohorts/cohorts.module';
35+
import { FeatureFlagAuditModule } from './config/feature-flag-audit.module';
3536

3637
const featureFlags = loadFeatureFlags();
3738

@@ -67,6 +68,9 @@ const featureFlags = loadFeatureFlags();
6768
// ✅ courses module with enrollment and prerequisite enforcement
6869
CoursesModule,
6970
CohortsModule,
71+
72+
// Feature flag audit trail and admin management endpoints
73+
FeatureFlagAuditModule,
7074
],
7175
controllers: [AppController],
7276
providers: [
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
Body,
3+
Controller,
4+
ForbiddenException,
5+
Get,
6+
Logger,
7+
Param,
8+
Patch,
9+
Req,
10+
UseGuards,
11+
} from '@nestjs/common';
12+
import { Request } from 'express';
13+
import { IFeatureFlagsConfig } from './feature-flags.config';
14+
import { FlagAuditEntry, FeatureFlagAuditService } from './feature-flag-audit.service';
15+
16+
interface AuthenticatedRequest extends Request {
17+
user?: {
18+
userId: string;
19+
email: string;
20+
role?: string;
21+
roles?: string[];
22+
};
23+
}
24+
25+
interface ToggleFlagDto {
26+
value: boolean;
27+
}
28+
29+
/**
30+
* Admin-only endpoints for querying and toggling feature flags with audit trails.
31+
*
32+
* All routes require an authenticated admin user. Role enforcement is done
33+
* inside the handler so that it works regardless of which auth guard is in use.
34+
*/
35+
@Controller('feature-flags')
36+
export class FeatureFlagAuditController {
37+
private readonly logger = new Logger(FeatureFlagAuditController.name);
38+
39+
constructor(private readonly auditService: FeatureFlagAuditService) {}
40+
41+
/**
42+
* GET /feature-flags/audit
43+
*
44+
* Returns the last 100 feature flag state changes for compliance review.
45+
* Access restricted to admin users.
46+
*/
47+
@Get('audit')
48+
getAuditLog(@Req() req: AuthenticatedRequest): FlagAuditEntry[] {
49+
this.assertAdmin(req);
50+
return this.auditService.getAuditHistory();
51+
}
52+
53+
/**
54+
* GET /feature-flags
55+
*
56+
* Returns a snapshot of all current flag values.
57+
*/
58+
@Get()
59+
getFlags(@Req() req: AuthenticatedRequest): Record<string, boolean> {
60+
this.assertAdmin(req);
61+
return this.auditService.getAllFlags();
62+
}
63+
64+
/**
65+
* PATCH /feature-flags/:key
66+
*
67+
* Toggles a single flag at runtime and records the change in the audit log.
68+
*
69+
* @param key - Flag key from {@link IFeatureFlagsConfig} (e.g. `ENABLE_AUTH`).
70+
* @param body - `{ value: boolean }` — desired new state.
71+
*/
72+
@Patch(':key')
73+
async toggleFlag(
74+
@Param('key') key: string,
75+
@Body() body: ToggleFlagDto,
76+
@Req() req: AuthenticatedRequest,
77+
): Promise<FlagAuditEntry> {
78+
this.assertAdmin(req);
79+
80+
const user = req.user!;
81+
const entry = await this.auditService.setFlag(
82+
key as keyof IFeatureFlagsConfig,
83+
body.value,
84+
{ id: user.userId, email: user.email },
85+
);
86+
87+
this.logger.log(
88+
`Admin ${user.email} toggled ${key}${String(body.value)}`,
89+
);
90+
91+
return entry;
92+
}
93+
94+
private assertAdmin(req: AuthenticatedRequest): void {
95+
const user = req.user;
96+
if (!user) {
97+
throw new ForbiddenException('Authentication required.');
98+
}
99+
const roles: string[] = user.roles ?? (user.role ? [user.role] : []);
100+
if (!roles.includes('admin')) {
101+
throw new ForbiddenException('Only admins may access feature flag management.');
102+
}
103+
}
104+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuditLogModule } from '../audit-log/audit-log.module';
3+
import { FeatureFlagAuditController } from './feature-flag-audit.controller';
4+
import { FeatureFlagAuditService } from './feature-flag-audit.service';
5+
6+
/**
7+
* Provides runtime feature flag management with a full audit trail.
8+
*
9+
* Exports {@link FeatureFlagAuditService} so other modules can read and
10+
* toggle flags programmatically while keeping the audit log up to date.
11+
*/
12+
@Module({
13+
imports: [AuditLogModule],
14+
controllers: [FeatureFlagAuditController],
15+
providers: [FeatureFlagAuditService],
16+
exports: [FeatureFlagAuditService],
17+
})
18+
export class FeatureFlagAuditModule {}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AuditLogService } from '../audit-log/audit-log.service';
3+
import { FeatureFlagAuditService } from './feature-flag-audit.service';
4+
5+
const mockAuditLogService: jest.Mocked<Pick<AuditLogService, 'logDataChange'>> = {
6+
logDataChange: jest.fn().mockResolvedValue({}),
7+
};
8+
9+
const ACTOR = { id: 'admin-1', email: 'admin@example.com' };
10+
11+
describe('FeatureFlagAuditService', () => {
12+
let service: FeatureFlagAuditService;
13+
14+
beforeEach(async () => {
15+
const module: TestingModule = await Test.createTestingModule({
16+
providers: [
17+
FeatureFlagAuditService,
18+
{ provide: AuditLogService, useValue: mockAuditLogService },
19+
],
20+
}).compile();
21+
22+
service = module.get(FeatureFlagAuditService);
23+
});
24+
25+
afterEach(() => jest.clearAllMocks());
26+
27+
describe('getFlag / getAllFlags', () => {
28+
it('returns the initial value loaded from config', () => {
29+
// ENABLE_AUTH defaults to true in feature-flags.config
30+
expect(service.getFlag('ENABLE_AUTH')).toBe(true);
31+
});
32+
33+
it('returns undefined for an unknown key', () => {
34+
expect(service.getFlag('UNKNOWN_KEY' as any)).toBeUndefined();
35+
});
36+
37+
it('getAllFlags includes all known flags', () => {
38+
const flags = service.getAllFlags();
39+
expect(Object.keys(flags).length).toBeGreaterThan(0);
40+
expect(typeof flags['ENABLE_AUTH']).toBe('boolean');
41+
});
42+
});
43+
44+
describe('setFlag', () => {
45+
it('updates the in-process flag value', async () => {
46+
await service.setFlag('ENABLE_SEARCH', false, ACTOR);
47+
expect(service.getFlag('ENABLE_SEARCH')).toBe(false);
48+
});
49+
50+
it('records the change in the audit history', async () => {
51+
await service.setFlag('ENABLE_GAMIFICATION', false, ACTOR);
52+
const history = service.getAuditHistory();
53+
expect(history[0]).toMatchObject({
54+
flagKey: 'ENABLE_GAMIFICATION',
55+
newValue: false,
56+
actor: ACTOR.id,
57+
actorEmail: ACTOR.email,
58+
});
59+
});
60+
61+
it('captures the old value before the change', async () => {
62+
// ENABLE_AUTH starts as true
63+
await service.setFlag('ENABLE_AUTH', false, ACTOR);
64+
expect(service.getAuditHistory()[0].oldValue).toBe(true);
65+
});
66+
67+
it('calls AuditLogService.logDataChange on each toggle', async () => {
68+
await service.setFlag('ENABLE_CACHING', false, ACTOR);
69+
expect(mockAuditLogService.logDataChange).toHaveBeenCalledTimes(1);
70+
expect(mockAuditLogService.logDataChange).toHaveBeenCalledWith(
71+
expect.objectContaining({
72+
entityType: 'FeatureFlag',
73+
entityId: 'ENABLE_CACHING',
74+
oldValues: { value: true },
75+
newValues: { value: false },
76+
}),
77+
);
78+
});
79+
80+
it('emits a separate audit entry for each toggle', async () => {
81+
await service.setFlag('ENABLE_NOTIFICATIONS', false, ACTOR);
82+
await service.setFlag('ENABLE_NOTIFICATIONS', true, ACTOR);
83+
expect(mockAuditLogService.logDataChange).toHaveBeenCalledTimes(2);
84+
expect(service.getAuditHistory()).toHaveLength(2);
85+
});
86+
87+
it('does not throw when AuditLogService fails', async () => {
88+
mockAuditLogService.logDataChange.mockRejectedValueOnce(new Error('DB down'));
89+
await expect(service.setFlag('ENABLE_BACKUP', false, ACTOR)).resolves.not.toThrow();
90+
});
91+
});
92+
93+
describe('getAuditHistory', () => {
94+
it('returns entries newest-first', async () => {
95+
await service.setFlag('ENABLE_AUTH', false, ACTOR);
96+
await service.setFlag('ENABLE_PAYMENTS', false, ACTOR);
97+
const history = service.getAuditHistory();
98+
expect(history[0].flagKey).toBe('ENABLE_PAYMENTS');
99+
expect(history[1].flagKey).toBe('ENABLE_AUTH');
100+
});
101+
102+
it('caps history at MAX_HISTORY entries', async () => {
103+
const toggles = FeatureFlagAuditService.MAX_HISTORY + 10;
104+
for (let i = 0; i < toggles; i++) {
105+
await service.setFlag('ENABLE_AUTH', i % 2 === 0, ACTOR);
106+
}
107+
expect(service.getAuditHistory()).toHaveLength(FeatureFlagAuditService.MAX_HISTORY);
108+
});
109+
110+
it('returns a snapshot (modifying the returned array does not affect internal state)', async () => {
111+
await service.setFlag('ENABLE_SEARCH', false, ACTOR);
112+
const snapshot = service.getAuditHistory();
113+
snapshot.pop();
114+
expect(service.getAuditHistory()).toHaveLength(1);
115+
});
116+
});
117+
});

0 commit comments

Comments
 (0)