From 38c458221c88ad66afcf581170875a85117f5c48 Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Mon, 27 Jul 2026 13:15:00 +0100 Subject: [PATCH] feat: add admin wallet allowlist guard for privileged endpoints - Add ADMIN_WALLETS env var with comma-separated Stellar addresses - Validate entries at startup (fail-fast on invalid addresses) - Create AdminGuard that checks wallet against allowlist - Apply AdminGuard to audit controller (GET /admin/audit-logs) - Empty/unset ADMIN_WALLETS denies all admin access by design - Generic 403 response does not leak allowlist contents - Add unit tests (8 tests) for all guard scenarios - Audit all controllers: no other endpoints require admin protection --- .env.example | 4 + src/app.module.ts | 9 +- src/auth/guards/admin.guard.ts | 68 +++++++++++ src/config/env.ts | 47 ++++++++ src/modules/admin/audit.controller.ts | 3 +- test/unit/modules/auth/admin.guard.spec.ts | 131 +++++++++++++++++++++ 6 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/auth/guards/admin.guard.ts create mode 100644 test/unit/modules/auth/admin.guard.spec.ts diff --git a/.env.example b/.env.example index 377ed0c..6f87915 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,10 @@ INDEXER_INTERVAL=30 TX_STATUS_INTERVAL=15 REMINDER_CRON=0 9 * * * +# Admin — comma-separated Stellar addresses allowed to access admin endpoints. +# An empty value denies ALL admin access by design (do not leave blank in production). +ADMIN_WALLETS= + # Sentry SENTRY_DSN= SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/src/app.module.ts b/src/app.module.ts index 65d297a..7dc9e20 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule, OnModuleInit } from '@nestjs/common'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; @@ -29,6 +29,7 @@ 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 { validateAdminWallets } from './config/env'; @Module({ imports: [ @@ -78,7 +79,11 @@ import { StateReconciliationModule } from './jobs/state-reconciliation/state-rec }, ], }) -export class AppModule implements NestModule { +export class AppModule implements NestModule, OnModuleInit { + onModuleInit(): void { + validateAdminWallets(); + } + configure(consumer: MiddlewareConsumer): void { consumer.apply(CorrelationIdMiddleware).forRoutes('*'); } diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..3b7d105 --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,68 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { getAdminWallets } from '../../config/env'; + +/** + * Guard that restricts access to wallets listed in the ADMIN_WALLETS + * environment variable. Must be applied AFTER JwtAuthGuard so that + * req.user is populated. + * + * Usage: + * @UseGuards(JwtAuthGuard, AdminGuard) + * + * Design notes: + * - Admin status is derived from the allowlist only; there is no "admin" + * role in USER_ROLES, so it cannot be self-assigned via POST /users/me/role. + * - An unset or empty ADMIN_WALLETS denies all admin access rather than + * granting it — a config mistake cannot silently open the gate. + * - The 403 response body is generic and does not leak the allowlist + * contents or its size. + */ +@Injectable() +export class AdminGuard implements CanActivate { + private readonly logger = new Logger(AdminGuard.name); + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ + user?: { wallet: string; role?: string | null }; + }>(); + + const wallet = request.user?.wallet; + + if (!wallet) { + throw new ForbiddenException({ + code: 'AUTH_WALLET_MISSING', + message: 'Forbidden.', + }); + } + + const allowlist = getAdminWallets(); + + if (allowlist.length === 0) { + this.logger.warn( + `Admin access denied: ADMIN_WALLETS is unset or empty (wallet: ${wallet})`, + ); + throw new ForbiddenException({ + code: 'ADMIN_ACCESS_DENIED', + message: 'Forbidden.', + }); + } + + if (!allowlist.includes(wallet)) { + this.logger.warn( + `Admin access denied: wallet not in allowlist (wallet: ${wallet})`, + ); + throw new ForbiddenException({ + code: 'ADMIN_ACCESS_DENIED', + message: 'Forbidden.', + }); + } + + return true; + } +} diff --git a/src/config/env.ts b/src/config/env.ts index e69de29..f4a1e60 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -0,0 +1,47 @@ +import { StrKey } from 'stellar-sdk'; + +/** + * Parsed and validated list of admin wallet addresses. + * Populated at module init from the ADMIN_WALLETS environment variable. + * + * An empty or unset value deliberately denies all admin access — + * this turns a missing config into the most restrictive posture. + */ +let adminWallets: readonly string[] = []; + +/** + * Returns the frozen list of admin wallet addresses. + */ +export function getAdminWallets(): readonly string[] { + return adminWallets; +} + +/** + * Parses the ADMIN_WALLETS environment variable, validates every entry + * as a well-formed Stellar Ed25519 public key, and fails fast if any + * entry is invalid. + * + * Must be called once during application bootstrap (AppModule.onModuleInit + * or ConfigModule.forRoot lifecycle) before any guard reads the list. + * + * @throws {Error} immediately if any allowlist entry is not a valid + * Stellar public key — the message identifies the bad entry. + */ +export function validateAdminWallets(): void { + const raw = process.env.ADMIN_WALLETS ?? ''; + const entries = raw + .split(',') + .map((e) => e.trim()) + .filter((e) => e.length > 0); + + for (const entry of entries) { + if (!StrKey.isValidEd25519PublicKey(entry)) { + throw new Error( + `ADMIN_WALLETS contains an invalid Stellar address: "${entry}". ` + + 'Each entry must be a well-formed Stellar Ed25519 public key (starts with G, 56 characters).', + ); + } + } + + adminWallets = Object.freeze([...entries]); +} diff --git a/src/modules/admin/audit.controller.ts b/src/modules/admin/audit.controller.ts index 71f7c46..a415ee2 100644 --- a/src/modules/admin/audit.controller.ts +++ b/src/modules/admin/audit.controller.ts @@ -4,12 +4,13 @@ import { AuditService } from './audit.service'; import { AuditLogQueryDto } from './dto/audit-log-query.dto'; import { AuditLogListResponseDto } from './dto/audit-log-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AdminGuard } from '../../auth/guards/admin.guard'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { AuditAction } from '../../common/decorators/audit-action.decorator'; @ApiTags('admin') @Controller('admin') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, AdminGuard) @ApiBearerAuth() export class AuditController { constructor(private readonly auditService: AuditService) {} 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..d56ed8e --- /dev/null +++ b/test/unit/modules/auth/admin.guard.spec.ts @@ -0,0 +1,131 @@ +import { ForbiddenException, Logger } from '@nestjs/common'; +import { AdminGuard } from '../../../../src/auth/guards/admin.guard'; +import * as env from '../../../../src/config/env'; + +const ALLOWED_WALLET = 'GAQWQJJBC2D5YCR6WUFFZSL6DIFJ5CA4774QB6QWPRNHSUUVRNQ2BHXJ'; +const OTHER_WALLET = 'GBXH6BL5Z7R5Y6RJSJRMJQH4YZVMYTCX2L4X2L4X2L4X2L4X2L4X2L4X'; + +describe('AdminGuard', () => { + let guard: AdminGuard; + let warnSpy: jest.SpyInstance; + + function createContext(user?: { wallet: string }) { + const request = { user }; + return { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(request), + }), + } as never; + } + + beforeEach(() => { + guard = new AdminGuard(); + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('allowlisted wallet', () => { + it('should return true for a wallet on the allowlist', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([ALLOWED_WALLET]); + const ctx = createContext({ wallet: ALLOWED_WALLET }); + + expect(guard.canActivate(ctx)).toBe(true); + }); + }); + + describe('non-allowlisted wallet', () => { + it('should throw ForbiddenException for a wallet not on the allowlist', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([ALLOWED_WALLET]); + const ctx = createContext({ wallet: OTHER_WALLET }); + + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + expect(() => guard.canActivate(ctx)).toThrow( + expect.objectContaining({ + response: expect.objectContaining({ code: 'ADMIN_ACCESS_DENIED' }), + }), + ); + }); + + it('should not disclose allowlist contents in the response body', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([ALLOWED_WALLET]); + const ctx = createContext({ wallet: OTHER_WALLET }); + + try { + guard.canActivate(ctx); + fail('Should have thrown'); + } catch (e) { + const body = (e as ForbiddenException).getResponse() as Record; + const bodyStr = JSON.stringify(body); + expect(bodyStr).not.toContain(ALLOWED_WALLET); + expect(bodyStr).not.toContain('wallets'); + expect(bodyStr).not.toContain('allowlist'); + } + }); + + it('should log the denied wallet address', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([ALLOWED_WALLET]); + const ctx = createContext({ wallet: OTHER_WALLET }); + + try { + guard.canActivate(ctx); + fail('Should have thrown'); + } catch { + expect(warnSpy).toHaveBeenCalledWith( + `Admin access denied: wallet not in allowlist (wallet: ${OTHER_WALLET})`, + ); + } + }); + }); + + describe('empty or unset ADMIN_WALLETS', () => { + it('should deny everyone when ADMIN_WALLETS is empty', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([]); + const ctx = createContext({ wallet: ALLOWED_WALLET }); + + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + expect(() => guard.canActivate(ctx)).toThrow( + expect.objectContaining({ + response: expect.objectContaining({ code: 'ADMIN_ACCESS_DENIED' }), + }), + ); + }); + + it('should deny everyone when ADMIN_WALLETS is unset (empty array)', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([]); + const ctx = createContext({ wallet: OTHER_WALLET }); + + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('should log the denial when allowlist is empty', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([]); + const ctx = createContext({ wallet: ALLOWED_WALLET }); + + try { + guard.canActivate(ctx); + fail('Should have thrown'); + } catch { + expect(warnSpy).toHaveBeenCalledWith( + `Admin access denied: ADMIN_WALLETS is unset or empty (wallet: ${ALLOWED_WALLET})`, + ); + } + }); + }); + + describe('unauthenticated request (no user)', () => { + it('should throw ForbiddenException when req.user is undefined', () => { + jest.spyOn(env, 'getAdminWallets').mockReturnValue([ALLOWED_WALLET]); + const ctx = createContext(undefined); + + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + expect(() => guard.canActivate(ctx)).toThrow( + expect.objectContaining({ + response: expect.objectContaining({ code: 'AUTH_WALLET_MISSING' }), + }), + ); + }); + }); +});