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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions src/auth/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

constructor(configService: ConfigService) {
this.adminWallets = new Set(parseAdminWallets(configService.get<string>('ADMIN_WALLETS')));
}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
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;
}
}
24 changes: 24 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
return {
...config,
ADMIN_WALLETS: parseAdminWallets(config.ADMIN_WALLETS).join(','),
};
}
4 changes: 3 additions & 1 deletion src/modules/admin/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 {}
24 changes: 24 additions & 0 deletions test/unit/config/env.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
59 changes: 59 additions & 0 deletions test/unit/modules/auth/admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading