Skip to content

Commit b60c364

Browse files
authored
Merge pull request #145 from Chisom92/feat/51-admin-operational-api
51 admin operational api
2 parents 723961c + e8da9d2 commit b60c364

19 files changed

Lines changed: 460 additions & 15 deletions

backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ JWT_SECRET=super-secret-jwt-key-change-in-prod
4040
JWT_EXPIRES_IN=7d
4141
ADMIN_TOKEN=admin-token-for-cli
4242

43+
# CORS
44+
# Comma-separated list of allowed origins for the public API
45+
CORS_ORIGINS=http://localhost:3001
46+
# Comma-separated list of allowed origins for the admin UI (separate from public)
47+
ADMIN_CORS_ORIGINS=http://localhost:3002
48+
4349
# Staff Auth (required in non-production)
4450
DEFAULT_ADMIN_EMAIL=admin@niffyinsure.com
4551
DEFAULT_ADMIN_PASSWORD=

backend/prisma/schema.prisma

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,33 @@ enum VoteType {
129129
REJECT
130130
}
131131

132+
/// Immutable operational audit trail written by admin endpoints.
133+
/// Rows must never be updated or deleted — append-only.
134+
model AdminAuditLog {
135+
id String @id @default(uuid())
136+
actor String /// wallet address or staff email of the operator
137+
action String /// e.g. "reindex", "pause", "feature_flag_update"
138+
payload Json /// full request payload for forensic replay
139+
ipAddress String?
140+
createdAt DateTime @default(now())
141+
142+
@@index([actor])
143+
@@index([action])
144+
@@index([createdAt])
145+
@@map("admin_audit_logs")
146+
}
147+
148+
/// Runtime feature flags toggled by admin operators.
149+
model FeatureFlag {
150+
key String @id
151+
enabled Boolean @default(false)
152+
description String?
153+
updatedBy String
154+
updatedAt DateTime @updatedAt
155+
156+
@@map("feature_flags")
157+
}
158+
132159
/// Off-chain metadata for allowlisted SEP-41 asset contracts.
133160
/// Populated by the indexer when it observes AssetAdded events.
134161
/// Used by the frontend and API to display correct symbol/decimals.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ForbiddenException } from '@nestjs/common';
3+
import { AdminController } from './admin.controller';
4+
import { AdminService } from './admin.service';
5+
import { AuditService } from './audit.service';
6+
import { AdminRoleGuard } from './guards/admin-role.guard';
7+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
8+
9+
const mockAdminService = { enqueueReindex: jest.fn(), setFeatureFlag: jest.fn(), getFeatureFlags: jest.fn() };
10+
const mockAuditService = { write: jest.fn(), findAll: jest.fn() };
11+
12+
const adminReq = (role = 'admin') => ({ user: { walletAddress: 'GADMIN', role }, ip: '127.0.0.1' });
13+
14+
describe('AdminController', () => {
15+
let controller: AdminController;
16+
17+
beforeEach(async () => {
18+
jest.clearAllMocks();
19+
const module: TestingModule = await Test.createTestingModule({
20+
controllers: [AdminController],
21+
providers: [
22+
{ provide: AdminService, useValue: mockAdminService },
23+
{ provide: AuditService, useValue: mockAuditService },
24+
],
25+
})
26+
.overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true })
27+
.overrideGuard(AdminRoleGuard).useValue({ canActivate: (ctx: any) => {
28+
const role = ctx.switchToHttp().getRequest().user?.role;
29+
if (role !== 'admin') throw new ForbiddenException('Admin role required');
30+
return true;
31+
}})
32+
.compile();
33+
34+
controller = module.get(AdminController);
35+
});
36+
37+
describe('POST /admin/reindex', () => {
38+
it('enqueues job and writes audit row', async () => {
39+
mockAdminService.enqueueReindex.mockResolvedValue('job-123');
40+
const result = await controller.reindex({ fromLedger: 500 }, adminReq() as any);
41+
expect(result).toEqual({ jobId: 'job-123', fromLedger: 500, status: 'queued' });
42+
expect(mockAdminService.enqueueReindex).toHaveBeenCalledWith(500);
43+
expect(mockAuditService.write).toHaveBeenCalledWith(
44+
expect.objectContaining({ actor: 'GADMIN', action: 'reindex', payload: expect.objectContaining({ fromLedger: 500 }) }),
45+
);
46+
});
47+
});
48+
49+
describe('GET /admin/audits', () => {
50+
it('returns paginated audit logs', async () => {
51+
mockAuditService.findAll.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
52+
const result = await controller.getAudits({ page: 1, limit: 20 });
53+
expect(mockAuditService.findAll).toHaveBeenCalledWith(1, 20, undefined);
54+
expect(result.total).toBe(0);
55+
});
56+
});
57+
58+
describe('PATCH /admin/feature-flags/:key', () => {
59+
it('updates flag and writes audit row', async () => {
60+
const flag = { key: 'claims_enabled', enabled: false, updatedBy: 'GADMIN' };
61+
mockAdminService.setFeatureFlag.mockResolvedValue(flag);
62+
const result = await controller.setFeatureFlag('claims_enabled', { enabled: false }, adminReq() as any);
63+
expect(result).toEqual(flag);
64+
expect(mockAuditService.write).toHaveBeenCalledWith(
65+
expect.objectContaining({ action: 'feature_flag_update', payload: expect.objectContaining({ key: 'claims_enabled', enabled: false }) }),
66+
);
67+
});
68+
});
69+
70+
describe('Role guard — non-admin access denied', () => {
71+
it('throws ForbiddenException for support_readonly on reindex', async () => {
72+
const guard = new AdminRoleGuard();
73+
const ctx = {
74+
switchToHttp: () => ({ getRequest: () => ({ user: { role: 'support_readonly' } }) }),
75+
} as any;
76+
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
77+
});
78+
79+
it('throws ForbiddenException when no user present', async () => {
80+
const guard = new AdminRoleGuard();
81+
const ctx = {
82+
switchToHttp: () => ({ getRequest: () => ({}) }),
83+
} as any;
84+
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
85+
});
86+
87+
it('allows admin role through', () => {
88+
const guard = new AdminRoleGuard();
89+
const ctx = {
90+
switchToHttp: () => ({ getRequest: () => ({ user: { role: 'admin' } }) }),
91+
} as any;
92+
expect(guard.canActivate(ctx)).toBe(true);
93+
});
94+
});
95+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Patch,
6+
Body,
7+
Param,
8+
Query,
9+
UseGuards,
10+
Req,
11+
HttpCode,
12+
HttpStatus,
13+
} from '@nestjs/common';
14+
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
15+
import { Request } from 'express';
16+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
17+
import { AdminRoleGuard } from './guards/admin-role.guard';
18+
import { AdminService } from './admin.service';
19+
import { AuditService } from './audit.service';
20+
import { ReindexDto } from './dto/reindex.dto';
21+
import { AuditQueryDto } from './dto/audit-query.dto';
22+
import { FeatureFlagDto } from './dto/feature-flag.dto';
23+
24+
@ApiTags('admin')
25+
@ApiBearerAuth('JWT-auth')
26+
@UseGuards(JwtAuthGuard, AdminRoleGuard)
27+
@Controller('admin')
28+
export class AdminController {
29+
constructor(
30+
private readonly adminService: AdminService,
31+
private readonly auditService: AuditService,
32+
) {}
33+
34+
/**
35+
* POST /admin/reindex
36+
*
37+
* Enqueues an async reindex job starting from the given ledger sequence.
38+
* Returns a jobId so operators can track progress via the queue dashboard.
39+
*
40+
* Requires: admin role + valid JWT.
41+
* Writes an immutable audit row with actor and full payload.
42+
*/
43+
@Post('reindex')
44+
@HttpCode(HttpStatus.ACCEPTED)
45+
@ApiOperation({ summary: 'Enqueue a ledger reindex job from a given ledger' })
46+
async reindex(@Body() dto: ReindexDto, @Req() req: Request) {
47+
const actor = (req.user as any)?.walletAddress ?? 'unknown';
48+
const jobId = await this.adminService.enqueueReindex(dto.fromLedger);
49+
await this.auditService.write({
50+
actor,
51+
action: 'reindex',
52+
payload: { fromLedger: dto.fromLedger, jobId },
53+
ipAddress: req.ip,
54+
});
55+
return { jobId, fromLedger: dto.fromLedger, status: 'queued' };
56+
}
57+
58+
/**
59+
* GET /admin/audits
60+
*
61+
* Paginated read of the immutable admin audit log.
62+
* Requires: admin role + valid JWT.
63+
*/
64+
@Get('audits')
65+
@ApiOperation({ summary: 'Paginated admin audit log' })
66+
async getAudits(@Query() query: AuditQueryDto) {
67+
return this.auditService.findAll(query.page, query.limit, query.action);
68+
}
69+
70+
/**
71+
* GET /admin/feature-flags
72+
*
73+
* Lists all feature flags and their current state.
74+
*/
75+
@Get('feature-flags')
76+
@ApiOperation({ summary: 'List all feature flags' })
77+
async listFeatureFlags() {
78+
return this.adminService.getFeatureFlags();
79+
}
80+
81+
/**
82+
* PATCH /admin/feature-flags/:key
83+
*
84+
* Toggles a feature flag on or off.
85+
* Writes an immutable audit row with actor and full payload.
86+
*
87+
* Legal note: disabling flags that gate user-facing activity (e.g. claim
88+
* filing, policy creation) constitutes a staff-initiated pause of user
89+
* operations. Such actions must be authorised by a designated compliance
90+
* officer and are subject to applicable insurance-regulation obligations.
91+
* The audit row created here serves as the immutable record of that action.
92+
*/
93+
@Patch('feature-flags/:key')
94+
@ApiOperation({ summary: 'Set a feature flag value' })
95+
async setFeatureFlag(
96+
@Param('key') key: string,
97+
@Body() dto: FeatureFlagDto,
98+
@Req() req: Request,
99+
) {
100+
const actor = (req.user as any)?.walletAddress ?? 'unknown';
101+
const flag = await this.adminService.setFeatureFlag(key, dto.enabled, dto.description, actor);
102+
await this.auditService.write({
103+
actor,
104+
action: 'feature_flag_update',
105+
payload: { key, enabled: dto.enabled, description: dto.description },
106+
ipAddress: req.ip,
107+
});
108+
return flag;
109+
}
110+
}

backend/src/admin/admin.module.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { Module } from '@nestjs/common';
2+
import { AdminController } from './admin.controller';
3+
import { AdminService } from './admin.service';
4+
import { AuditService } from './audit.service';
5+
import { PrismaModule } from '../prisma/prisma.module';
6+
import { AuthModule } from '../auth/auth.module';
27

38
@Module({
4-
// controllers: [AdminController],
5-
// providers: [AdminService],
6-
// exports: [AdminService],
9+
imports: [PrismaModule, AuthModule],
10+
controllers: [AdminController],
11+
providers: [AdminService, AuditService],
12+
exports: [AuditService],
713
})
814
export class AdminModule {}
9-

backend/src/admin/admin.service.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { PrismaService } from '../prisma/prisma.service';
3+
import { Queue } from 'bullmq';
4+
import { getBullMQConnection } from '../redis/client';
5+
6+
@Injectable()
7+
export class AdminService {
8+
private readonly logger = new Logger(AdminService.name);
9+
private reindexQueue: Queue;
10+
11+
constructor(private readonly prisma: PrismaService) {
12+
this.reindexQueue = new Queue('reindex', {
13+
connection: getBullMQConnection(),
14+
defaultJobOptions: {
15+
attempts: 3,
16+
backoff: { type: 'exponential', delay: 2_000 },
17+
removeOnComplete: { count: 50 },
18+
removeOnFail: { count: 100 },
19+
},
20+
});
21+
}
22+
23+
async enqueueReindex(fromLedger: number): Promise<string> {
24+
const job = await this.reindexQueue.add('reindex', { fromLedger }, { jobId: `reindex-${fromLedger}-${Date.now()}` });
25+
this.logger.log(`Reindex job enqueued: ${job.id} from ledger ${fromLedger}`);
26+
return job.id!;
27+
}
28+
29+
async setFeatureFlag(key: string, enabled: boolean, description: string | undefined, actor: string) {
30+
return this.prisma.featureFlag.upsert({
31+
where: { key },
32+
create: { key, enabled, description, updatedBy: actor },
33+
update: { enabled, description, updatedBy: actor },
34+
});
35+
}
36+
37+
async getFeatureFlags() {
38+
return this.prisma.featureFlag.findMany({ orderBy: { key: 'asc' } });
39+
}
40+
}

backend/src/admin/audit.service.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { PrismaService } from '../prisma/prisma.service';
3+
4+
export interface AuditMeta {
5+
actor: string;
6+
action: string;
7+
payload: Record<string, unknown>;
8+
ipAddress?: string;
9+
}
10+
11+
@Injectable()
12+
export class AuditService {
13+
constructor(private readonly prisma: PrismaService) {}
14+
15+
async write(meta: AuditMeta): Promise<void> {
16+
await this.prisma.adminAuditLog.create({ data: meta });
17+
}
18+
19+
async findAll(page: number, limit: number, action?: string) {
20+
const where = action ? { action } : {};
21+
const [items, total] = await Promise.all([
22+
this.prisma.adminAuditLog.findMany({
23+
where,
24+
orderBy: { createdAt: 'desc' },
25+
skip: (page - 1) * limit,
26+
take: limit,
27+
}),
28+
this.prisma.adminAuditLog.count({ where }),
29+
]);
30+
return { items, total, page, limit };
31+
}
32+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { IsOptional, IsInt, Min, Max, IsString } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { ApiPropertyOptional } from '@nestjs/swagger';
4+
5+
export class AuditQueryDto {
6+
@ApiPropertyOptional({ default: 1 })
7+
@IsOptional()
8+
@Type(() => Number)
9+
@IsInt()
10+
@Min(1)
11+
page: number = 1;
12+
13+
@ApiPropertyOptional({ default: 20, maximum: 100 })
14+
@IsOptional()
15+
@Type(() => Number)
16+
@IsInt()
17+
@Min(1)
18+
@Max(100)
19+
limit: number = 20;
20+
21+
@ApiPropertyOptional({ description: 'Filter by action type' })
22+
@IsOptional()
23+
@IsString()
24+
action?: string;
25+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { IsBoolean, IsOptional, IsString } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class FeatureFlagDto {
5+
@ApiProperty()
6+
@IsBoolean()
7+
enabled!: boolean;
8+
9+
@ApiPropertyOptional()
10+
@IsOptional()
11+
@IsString()
12+
description?: string;
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { IsInt, Min } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class ReindexDto {
5+
@ApiProperty({ description: 'Ledger sequence to reindex from', minimum: 0 })
6+
@IsInt()
7+
@Min(0)
8+
fromLedger!: number;
9+
}

0 commit comments

Comments
 (0)