Skip to content

Commit 3e29900

Browse files
authored
Merge pull request #19 from simonabler/19-feat-admin-stats-api-key-auth
feat(admin): secure /admin/stats + /admin/usage with industrial API key
2 parents ca7eea2 + fbed347 commit 3e29900

18 files changed

Lines changed: 744 additions & 159 deletions

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# ── Database ──────────────────────────────────────────────────────────────────
2+
TYPEORM_DB=./data/simonapi.sqlite # SQLite path (dev) or ignored when Postgres env set
3+
4+
# ── Signpack ──────────────────────────────────────────────────────────────────
5+
DATA_DIR=/data # Host path mounted into container (see docker-compose.yml)
6+
FILE_MAX_BYTES=26214400 # 25 MB default
7+
PURGE_CRON="0 * * * *" # hourly purge of expired signpacks
8+
9+
# ── Admin ─────────────────────────────────────────────────────────────────────
10+
# Static key protecting /admin/stats and /admin/usage.
11+
# Must be long and random — e.g.: openssl rand -hex 32
12+
# Send as: x-api-key: <value> when calling admin endpoints.
13+
ADMIN_KEY=changeme-generate-with-openssl-rand-hex-32

apps/server/src/app/api-key/api-key-admin.controller.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,37 @@ import {
33
Controller,
44
Delete,
55
Get,
6-
Headers,
76
HttpCode,
87
HttpStatus,
98
Param,
109
Post,
11-
UnauthorizedException,
1210
} from '@nestjs/common';
13-
import { ApiExcludeController } from '@nestjs/swagger';
11+
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
12+
import { RequiresAdminKey } from './api-key.decorator';
13+
import { SkipAnomalyGuard } from '../metrics/anomaly.guard';
1414
import { ApiKeyService } from './api-key.service';
1515
import { ApiKeyTier } from './entities/api-key.entity';
1616

17-
@ApiExcludeController()
18-
@Controller('_admin/api-keys')
17+
@ApiTags('Admin')
18+
@ApiSecurity('x-api-key')
19+
@SkipAnomalyGuard()
20+
@Controller('admin/api-keys')
1921
export class ApiKeyAdminController {
2022
constructor(private readonly svc: ApiKeyService) {}
2123

22-
private guard(token?: string) {
23-
const expected = process.env.ADMIN_TOKEN;
24-
if (!expected || token !== expected) {
25-
throw new UnauthorizedException('Invalid admin token');
26-
}
27-
}
28-
2924
@Get()
30-
list(@Headers('x-admin-token') token?: string) {
31-
this.guard(token);
25+
@RequiresAdminKey()
26+
@ApiOperation({ summary: 'List all API keys (admin)' })
27+
list() {
3228
return this.svc.list();
3329
}
3430

3531
@Post()
32+
@RequiresAdminKey()
33+
@ApiOperation({ summary: 'Create a new API key (admin)' })
3634
async create(
37-
@Headers('x-admin-token') token: string | undefined,
3835
@Body() body: { label: string; tier: ApiKeyTier; expiresAt?: string },
3936
) {
40-
this.guard(token);
4137
const expires = body.expiresAt ? new Date(body.expiresAt) : undefined;
4238
const { rawKey, entity } = await this.svc.create(body.label, body.tier, expires);
4339
return {
@@ -51,11 +47,9 @@ export class ApiKeyAdminController {
5147

5248
@Delete(':id')
5349
@HttpCode(HttpStatus.NO_CONTENT)
54-
async revoke(
55-
@Headers('x-admin-token') token: string | undefined,
56-
@Param('id') id: string,
57-
) {
58-
this.guard(token);
50+
@RequiresAdminKey()
51+
@ApiOperation({ summary: 'Revoke an API key (admin)' })
52+
async revoke(@Param('id') id: string) {
5953
await this.svc.revoke(id);
6054
}
6155
}

apps/server/src/app/api-key/api-key.decorator.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,16 @@ export const RequiresTier = (minTier: ApiKeyTier) =>
3535
* \@Post('sscc/build')
3636
*/
3737
export const TierRateLimit = () => SetMetadata(TIER_RATE_LIMIT_KEY, true);
38+
39+
/**
40+
* Hard gate: the caller MUST supply the static admin key from ADMIN_KEY env var.
41+
* Completely independent of the tier system and DB — purely env-based.
42+
*
43+
* Use only on internal admin/ops endpoints (/admin/stats, /admin/usage).
44+
*
45+
* @example
46+
* \@RequiresAdminKey()
47+
* \@Get('admin/stats')
48+
*/
49+
export const REQUIRES_ADMIN_KEY = 'requiresAdminKey';
50+
export const RequiresAdminKey = () => SetMetadata(REQUIRES_ADMIN_KEY, true);

apps/server/src/app/api-key/api-key.guard.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { Reflector } from '@nestjs/core';
99
import { ApiKeyTier } from './entities/api-key.entity';
1010
import { ApiKeyService, ResolvedKey } from './api-key.service';
11-
import { REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator';
11+
import { REQUIRES_ADMIN_KEY, REQUIRES_TIER_KEY, TIER_RATE_LIMIT_KEY } from './api-key.decorator';
1212

1313
const TIER_ORDER: ApiKeyTier[] = ['free', 'pro', 'industrial'];
1414

@@ -28,6 +28,37 @@ export class ApiKeyGuard implements CanActivate {
2828
async canActivate(context: ExecutionContext): Promise<boolean> {
2929
const req = context.switchToHttp().getRequest<any>();
3030

31+
// ── @RequiresAdminKey() — env-based admin gate ───────────────────────────
32+
// Compared with constant-time equality to prevent timing attacks.
33+
// The key must be set in the ADMIN_KEY environment variable.
34+
const requiresAdmin = this.reflector.getAllAndOverride<boolean | undefined>(
35+
REQUIRES_ADMIN_KEY,
36+
[context.getHandler(), context.getClass()],
37+
);
38+
if (requiresAdmin) {
39+
const adminKey = process.env['ADMIN_KEY'];
40+
if (!adminKey) {
41+
// Server misconfiguration — ADMIN_KEY not set
42+
throw new ForbiddenException('Admin endpoint not configured (ADMIN_KEY missing)');
43+
}
44+
const provided: string = req.headers['x-api-key'] ?? '';
45+
// Constant-time comparison via Buffer to mitigate timing attacks
46+
const aLen = Buffer.byteLength(adminKey);
47+
const bLen = Buffer.byteLength(provided);
48+
const a = Buffer.alloc(aLen, 0);
49+
const b = Buffer.alloc(aLen, 0);
50+
Buffer.from(adminKey).copy(a);
51+
Buffer.from(provided.slice(0, aLen)).copy(b);
52+
const match = a.equals(b) && aLen === bLen;
53+
if (!match) {
54+
throw new UnauthorizedException({
55+
message: 'Admin key required',
56+
hint: 'Send your ADMIN_KEY as header: x-api-key: <admin-key>',
57+
});
58+
}
59+
return true;
60+
}
61+
3162
// ── Determine mode from metadata ────────────────────────────────────────
3263
const minTier = this.reflector.getAllAndOverride<ApiKeyTier | undefined>(
3364
REQUIRES_TIER_KEY,

apps/server/src/app/app.module.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ import { UsageModule } from './usage/usage.module';
8888
// Authenticated callers get TIER_LIMITS from ApiKeyService (applied
8989
// per-request in UsageInterceptor via overrideRule — no global mutation).
9090
UsageModule.forRoot({
91-
adminToken: process.env.ADMIN_TOKEN,
9291
defaultRule: {
9392
perMinute: 10, // anonymous / free tier fallback
9493
},

apps/server/src/app/metrics/metrics.controller.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,61 @@
1-
import { Controller, Get, HttpCode, Query } from '@nestjs/common';
1+
import { Controller, Delete, Get, HttpCode, Query } from '@nestjs/common';
2+
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
3+
import { RequiresAdminKey } from '../api-key/api-key.decorator';
24
import { MetricsService } from './metrics.service';
35
import { SkipMetrics } from './metrics.decorator';
6+
import { SkipAnomalyGuard } from './anomaly.guard';
47
import { BlocklistService } from './blocklist.service';
58

6-
@Controller()
9+
/**
10+
* Admin-only metrics & security dashboard.
11+
*
12+
* All routes require an `industrial` API key via `x-api-key` header.
13+
* Rate limiting is intentionally skipped — admin tooling must not be
14+
* blocked by its own counters.
15+
*/
16+
@ApiTags('Admin')
17+
@ApiSecurity('x-api-key')
18+
@SkipAnomalyGuard()
19+
@Controller('admin/stats')
720
export class MetricsController {
821
constructor(
922
private readonly metrics: MetricsService,
1023
private readonly blocklist: BlocklistService,
1124
) {}
1225

13-
@Get('_stats')
26+
@Get()
1427
@HttpCode(200)
1528
@SkipMetrics()
29+
@RequiresAdminKey()
30+
@ApiOperation({ summary: 'Request metrics snapshot (admin)' })
1631
getStats() {
1732
return this.metrics.snapshot();
1833
}
1934

20-
@Get('_stats/reset')
35+
@Delete('reset')
2136
@HttpCode(200)
2237
@SkipMetrics()
38+
@RequiresAdminKey()
39+
@ApiOperation({ summary: 'Reset metrics counters (admin)' })
2340
async reset() {
2441
await this.metrics.reset();
2542
return { ok: true };
2643
}
2744

28-
@Get('_stats/security')
45+
@Get('security')
2946
@HttpCode(200)
3047
@SkipMetrics()
48+
@RequiresAdminKey()
49+
@ApiOperation({ summary: 'List currently blocked IPs (admin)' })
3150
security() {
32-
return {
33-
blocked: this.blocklist.list(),
34-
};
51+
return { blocked: this.blocklist.list() };
3552
}
3653

37-
@Get('_stats/security/unban')
54+
@Get('security/unban')
3855
@HttpCode(200)
3956
@SkipMetrics()
57+
@RequiresAdminKey()
58+
@ApiOperation({ summary: 'Unban an IP address (admin)' })
4059
unban(@Query('ip') ip: string) {
4160
if (!ip) return { ok: false, error: 'ip required' };
4261
const ok = this.blocklist.unban(ip);

apps/server/src/app/usage/usage.controller.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
import { Controller, Get, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common';
1+
import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
2+
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
3+
import { RequiresAdminKey } from '../api-key/api-key.decorator';
4+
import { SkipAnomalyGuard } from '../metrics/anomaly.guard';
25
import { UsageService } from './usage.service';
36

4-
@Controller('_usage')
7+
/**
8+
* Rate-limit usage snapshot — admin only.
9+
* Secured via x-api-key (industrial tier), same as /admin/stats.
10+
*/
11+
@ApiTags('Admin')
12+
@ApiSecurity('x-api-key')
13+
@SkipAnomalyGuard()
14+
@Controller('admin/usage')
515
export class UsageController {
616
constructor(private readonly usage: UsageService) {}
717

8-
private authOk(token?: string) {
9-
const adminToken = this.usage.getAdminToken();
10-
return !!adminToken && token === adminToken;
11-
}
12-
1318
@Get('stats')
14-
getStats(@Headers('x-admin-token') token?: string) {
15-
if (!this.authOk(token)) {
16-
return { error: 'unauthorized' };
17-
}
19+
@RequiresAdminKey()
20+
@ApiOperation({ summary: 'Rate-limit counters snapshot (admin)' })
21+
getStats() {
1822
return this.usage.snapshot();
1923
}
2024

2125
@Post('reset')
2226
@HttpCode(HttpStatus.NO_CONTENT)
23-
reset(@Headers('x-admin-token') token?: string) {
24-
if (!this.authOk(token)) return { error: 'unauthorized' };
27+
@RequiresAdminKey()
28+
@ApiOperation({ summary: 'Reset rate-limit counters (admin)' })
29+
reset() {
2530
this.usage.resetAll();
26-
return;
2731
}
2832
}
29-

apps/server/src/app/usage/usage.interceptor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export class UsageInterceptor implements NestInterceptor {
1616

1717
const path = req.originalUrl || req.url;
1818

19+
// Admin routes are authenticated via API key (industrial tier) and must
20+
// never be blocked by their own rate-limit counters.
21+
if (path.startsWith('/admin/')) return next.handle();
22+
1923
// Prefer the resolved key attached by ApiKeyGuard (validated, tier known).
2024
// Fall back to the raw header string, then real client IP, then 'anonymous'.
2125
//

apps/simonapi/src/app/app.routes.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ export const appRoutes: Route[] = [
4545
{
4646
path: 'admin/stats',
4747
loadComponent: () => import('./features/stats/stats-dashboard.component').then(m => m.StatsDashboardComponent),
48-
title: 'API Stats',
48+
title: 'Admin – Stats',
49+
},
50+
{
51+
path: 'admin/api-keys',
52+
loadComponent: () => import('./features/stats/api-keys.component').then(m => m.ApiKeysComponent),
53+
title: 'Admin – API Keys',
4954
},
5055
{
5156
path: 'crypto',

0 commit comments

Comments
 (0)