Skip to content
Merged
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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('*');
}
Expand Down
68 changes: 68 additions & 0 deletions src/auth/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
47 changes: 47 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
3 changes: 2 additions & 1 deletion src/modules/admin/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
131 changes: 131 additions & 0 deletions test/unit/modules/auth/admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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' }),
}),
);
});
});
});
Loading