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
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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: [
Expand Down
104 changes: 104 additions & 0 deletions src/config/feature-flag-audit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Logger,
Param,
Patch,
Req,
UseGuards,

Check warning on line 10 in src/config/feature-flag-audit.controller.ts

View workflow job for this annotation

GitHub Actions / validate

'UseGuards' is defined but never used. Allowed unused vars must match /^_/u
} 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<string, boolean> {
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<FlagAuditEntry> {
this.assertAdmin(req);

const user = req.user!;
const entry = await this.auditService.setFlag(

Check failure on line 81 in src/config/feature-flag-audit.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······key·as·keyof·IFeatureFlagsConfig,⏎······body.value,⏎······{·id:·user.userId,·email:·user.email·}` with `key·as·keyof·IFeatureFlagsConfig,·body.value,·{⏎······id:·user.userId,⏎······email:·user.email`
key as keyof IFeatureFlagsConfig,
body.value,
{ id: user.userId, email: user.email },
);

Check failure on line 85 in src/config/feature-flag-audit.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `}`

this.logger.log(

Check failure on line 87 in src/config/feature-flag-audit.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······`Admin·${user.email}·toggled·${key}·→·${String(body.value)}`,⏎····` with ``Admin·${user.email}·toggled·${key}·→·${String(body.value)}``
`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.');
}
}
}
18 changes: 18 additions & 0 deletions src/config/feature-flag-audit.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
117 changes: 117 additions & 0 deletions src/config/feature-flag-audit.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<AuditLogService, 'logDataChange'>> = {
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);
});
});
});
Loading
Loading