From 27e72e397d6a83e7579bbd893f3d7229490eef5b Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Wed, 22 Jul 2026 15:43:35 +0100 Subject: [PATCH 1/2] feat: protect admin endpoints with allowlist --- .env.example | 4 ++ context/progress-tracker.md | 8 +++ src/app.module.ts | 3 +- src/auth/guards/admin.guard.ts | 38 ++++++++++++++ src/config/env.ts | 24 +++++++++ src/modules/admin/admin.module.ts | 3 +- src/modules/admin/audit.controller.ts | 4 +- test/unit/config/env.spec.ts | 24 +++++++++ test/unit/modules/auth/admin.guard.spec.ts | 59 ++++++++++++++++++++++ 9 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 src/auth/guards/admin.guard.ts create mode 100644 test/unit/config/env.spec.ts create mode 100644 test/unit/modules/auth/admin.guard.spec.ts diff --git a/.env.example b/.env.example index 377ed0c..1c7e411 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ JWT_ACCESS_EXPIRATION=15m JWT_REFRESH_EXPIRATION=7d NONCE_EXPIRATION=300 +# Comma-separated Stellar public keys allowed to use admin endpoints. +# Empty or unset denies all admin access by design. +ADMIN_WALLETS= + # Redis REDIS_URL=redis://localhost:6379 REDIS_DB=0 diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 3a5dd01..9c2aa3e 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,14 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-22 + +- Added a fail-closed `ADMIN_WALLETS` allowlist validated as Stellar public + keys at startup, plus a shared `AdminGuard` for privileged API endpoints. + The audit-log controller now requires both a valid JWT and an allowlisted + wallet; self-selected user roles remain unchanged and cannot grant admin + access. + ## 2026-07-19 - Wired `LiquidityContractClient` (restored under `src/blockchain/contracts/liquidity-contract.client.ts`) into `LiquidityService` constructor. diff --git a/src/app.module.ts b/src/app.module.ts index 65d297a..085409d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,10 +29,11 @@ import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.mod import { AdminModule } from './modules/admin/admin.module'; import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware'; import { StateReconciliationModule } from './jobs/state-reconciliation/state-reconciliation.module'; +import { validateEnvironment } from './config/env'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ isGlobal: true, validate: validateEnvironment }), SentryModule.forRoot(), ScheduleModule.forRoot(), LoggerModule, diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..54f9a9a --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { parseAdminWallets } from '../../config/env'; + +interface AuthenticatedRequest { + user?: { wallet?: string }; +} + +@Injectable() +export class AdminGuard implements CanActivate { + private readonly logger = new Logger(AdminGuard.name); + private readonly adminWallets: ReadonlySet; + + constructor(configService: ConfigService) { + this.adminWallets = new Set(parseAdminWallets(configService.get('ADMIN_WALLETS'))); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const wallet = request.user?.wallet; + + if (!wallet || !this.adminWallets.has(wallet)) { + this.logger.warn(`Admin access denied for wallet ${wallet ?? 'unknown'}`); + throw new ForbiddenException({ + code: 'AUTH_ADMIN_FORBIDDEN', + message: 'Admin access required.', + }); + } + + return true; + } +} diff --git a/src/config/env.ts b/src/config/env.ts index e69de29..0d13398 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -0,0 +1,24 @@ +import { StrKey } from 'stellar-sdk'; + +export function parseAdminWallets(value: unknown): string[] { + if (typeof value !== 'string' || value.trim() === '') return []; + + const wallets = value + .split(',') + .map((wallet) => wallet.trim()) + .filter((wallet) => wallet.length > 0); + + const invalidWallet = wallets.find((wallet) => !StrKey.isValidEd25519PublicKey(wallet)); + if (invalidWallet) { + throw new Error(`ADMIN_WALLETS contains an invalid Stellar public key: ${invalidWallet}`); + } + + return [...new Set(wallets)]; +} + +export function validateEnvironment(config: Record): Record { + return { + ...config, + ADMIN_WALLETS: parseAdminWallets(config.ADMIN_WALLETS).join(','), + }; +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 566323e..05d16d2 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; import { SupabaseService } from '../../database/supabase.client'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @Module({ controllers: [AuditController], - providers: [AuditService, SupabaseService], + providers: [AuditService, SupabaseService, AdminGuard], exports: [AuditService], }) export class AdminModule {} diff --git a/src/modules/admin/audit.controller.ts b/src/modules/admin/audit.controller.ts index 71f7c46..dc99ecb 100644 --- a/src/modules/admin/audit.controller.ts +++ b/src/modules/admin/audit.controller.ts @@ -6,10 +6,11 @@ import { AuditLogListResponseDto } from './dto/audit-log-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { AuditAction } from '../../common/decorators/audit-action.decorator'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @ApiTags('admin') @Controller('admin') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, AdminGuard) @ApiBearerAuth() export class AuditController { constructor(private readonly auditService: AuditService) {} @@ -35,6 +36,7 @@ export class AuditController { type: AuditLogListResponseDto, }) @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden - wallet is not an administrator' }) async getAuditLogs(@Query() query: AuditLogQueryDto) { return this.auditService.findMany(query); } diff --git a/test/unit/config/env.spec.ts b/test/unit/config/env.spec.ts new file mode 100644 index 0000000..4e65213 --- /dev/null +++ b/test/unit/config/env.spec.ts @@ -0,0 +1,24 @@ +jest.unmock('stellar-sdk'); + +import { Keypair } from 'stellar-sdk'; +import { parseAdminWallets, validateEnvironment } from '../../../src/config/env'; + +describe('environment validation', () => { + it('normalizes and de-duplicates valid admin wallets', () => { + const first = Keypair.random().publicKey(); + const second = Keypair.random().publicKey(); + + expect(parseAdminWallets(` ${first},${second},${first} `)).toEqual([first, second]); + }); + + it('treats an empty or missing allowlist as deny-all configuration', () => { + expect(parseAdminWallets(undefined)).toEqual([]); + expect(validateEnvironment({ ADMIN_WALLETS: ' ' }).ADMIN_WALLETS).toBe(''); + }); + + it('fails startup validation for an invalid Stellar public key', () => { + expect(() => validateEnvironment({ ADMIN_WALLETS: 'not-a-stellar-wallet' })).toThrow( + 'ADMIN_WALLETS contains an invalid Stellar public key', + ); + }); +}); diff --git a/test/unit/modules/auth/admin.guard.spec.ts b/test/unit/modules/auth/admin.guard.spec.ts new file mode 100644 index 0000000..edc1ce0 --- /dev/null +++ b/test/unit/modules/auth/admin.guard.spec.ts @@ -0,0 +1,59 @@ +jest.unmock('stellar-sdk'); + +import { ExecutionContext, ForbiddenException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Keypair } from 'stellar-sdk'; +import { AdminGuard } from '../../../../src/auth/guards/admin.guard'; + +function contextFor(wallet?: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user: wallet ? { wallet } : undefined }), + }), + } as ExecutionContext; +} + +describe('AdminGuard', () => { + const allowedWallet = Keypair.random().publicKey(); + const otherWallet = Keypair.random().publicKey(); + + it('allows an authenticated wallet on the allowlist', () => { + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(guard.canActivate(contextFor(allowedWallet))).toBe(true); + }); + + it('returns a generic 403 for an authenticated wallet not on the allowlist', () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(() => guard.canActivate(contextFor(otherWallet))).toThrow(ForbiddenException); + expect(() => guard.canActivate(contextFor(otherWallet))).toThrow( + expect.objectContaining({ + response: { + code: 'AUTH_ADMIN_FORBIDDEN', + message: 'Admin access required.', + }, + }), + ); + expect(warn).toHaveBeenCalledWith(`Admin access denied for wallet ${otherWallet}`); + }); + + it.each([undefined, ''])('denies everyone when ADMIN_WALLETS is %p', (value) => { + const guard = new AdminGuard(new ConfigService({ ADMIN_WALLETS: value })); + + expect(() => guard.canActivate(contextFor(allowedWallet))).toThrow(ForbiddenException); + }); + + it('fails closed when no authenticated user is attached to the request', () => { + const guard = new AdminGuard( + new ConfigService({ ADMIN_WALLETS: allowedWallet }), + ); + + expect(() => guard.canActivate(contextFor())).toThrow(ForbiddenException); + }); +}); From dc3c1150e5560beaad50a5e76a325a29dae426b2 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Wed, 22 Jul 2026 15:46:02 +0100 Subject: [PATCH 2/2] refactor: export shared admin guard --- src/modules/admin/admin.module.ts | 3 +-- src/modules/auth/auth.module.ts | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 05d16d2..566323e 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; import { SupabaseService } from '../../database/supabase.client'; -import { AdminGuard } from '../../auth/guards/admin.guard'; @Module({ controllers: [AuditController], - providers: [AuditService, SupabaseService, AdminGuard], + providers: [AuditService, SupabaseService], exports: [AuditService], }) export class AdminModule {} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 77ddd3b..32513e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -9,6 +9,7 @@ import { ApiKeyGuard } from '../../auth/guards/api-key.guard'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository } from '../../database/repositories/users.repository'; import { getJwtConfig } from '../../config/jwt.config'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @Module({ imports: [ @@ -20,7 +21,7 @@ import { getJwtConfig } from '../../config/jwt.config'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], - exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule], + providers: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, SupabaseService, ConfigService, UsersRepository], + exports: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, PassportModule], }) export class AuthModule {}