feat(auth): API-key authentication — corr_ bearer key acts as license owner
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Failing after 31s
CI / integration (push) Has been skipped

Closes the 'Public REST API' last mile: external callers authenticate with a
per-license API key instead of a JWT. Additive and zero-regression:

- JwtAuthGuard: a corr_-prefixed bearer token (or X-API-Key header) is
  validated via ApiKeysService.validateKey and sets request.user shaped like a
  JWT user, scoped to the key's license. JWTs are eyJ... and never collide with
  the corr_ prefix, so the existing JWT path is byte-for-byte unchanged.
- API-key calls act AS the license owner: validateKey now resolves
  license.owner_user_id so sub is a real UUID — any @CurrentUser/created_by FK
  insert works and attributes correctly. (ApiKeysModule gains the License repo.)
- PermissionsGuard: is_api_key principals get full access to their own license
  (always tenant-scoped). Future: scoped/read-only keys.

Backend tsc green. Untested at runtime (no local DB) — needs a curl smoke test
on Saturday's fresh stack before the roadmap item flips to shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-12 02:26:59 -04:00
parent 4d455918f5
commit 5b323137e0
5 changed files with 79 additions and 12 deletions

View File

@@ -1,20 +1,68 @@
import { Injectable, ExecutionContext } from '@nestjs/common'; import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ApiKeysService } from '../../modules/api-keys/api-keys.service';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { constructor(
private reflector: Reflector,
private readonly apiKeysService: ApiKeysService,
) {
super(); super();
} }
canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (isPublic) return true; if (isPublic) return true;
return super.canActivate(context);
// Additive API-key auth: a `corr_`-prefixed bearer token (or X-API-Key
// header) authenticates programmatically AS the license owner. JWTs are
// `eyJ...` and never collide with the `corr_` prefix, so the standard JWT
// path below is left completely untouched — zero login regression risk.
const request = context.switchToHttp().getRequest();
const rawKey = this.extractApiKey(request);
if (rawKey) {
const result = await this.apiKeysService.validateKey(rawKey);
if (!result) {
throw new UnauthorizedException('Invalid or revoked API key');
}
// Shape the principal like a JWT user so @CurrentTenant / @CurrentUser and
// the permission layer behave identically. is_api_key grants full access
// to THIS license (see PermissionsGuard) — a key is full programmatic
// access to your own license, always tenant-scoped by license_id.
request.user = {
sub: result.user_id ?? undefined,
license_id: result.license_id,
is_super_admin: false,
is_api_key: true,
permissions: {},
};
return true;
}
return (await super.canActivate(context)) as boolean;
}
/** Pull a `corr_`-prefixed key from `Authorization: Bearer` or `X-API-Key`. */
private extractApiKey(request: any): string | null {
const auth = request.headers?.authorization;
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
const token = auth.slice(7).trim();
if (token.startsWith('corr_')) return token;
}
const headerKey = request.headers?.['x-api-key'];
if (typeof headerKey === 'string' && headerKey.startsWith('corr_')) {
return headerKey.trim();
}
return null;
} }
} }

View File

@@ -19,6 +19,11 @@ export class PermissionsGuard implements CanActivate {
// Super admins bypass all permission checks // Super admins bypass all permission checks
if (user.is_super_admin) return true; if (user.is_super_admin) return true;
// API keys are full programmatic access to their own license (always
// tenant-scoped by license_id via @CurrentTenant). Granted here rather than
// enumerating every permission. Future: scoped/read-only keys.
if (user.is_api_key) return true;
// Check permissions JSONB from role // Check permissions JSONB from role
const permissions = user.permissions as Record<string, boolean> | undefined; const permissions = user.permissions as Record<string, boolean> | undefined;
if (!permissions) return false; if (!permissions) return false;

View File

@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKey } from '../../entities/api-key.entity'; import { ApiKey } from '../../entities/api-key.entity';
import { License } from '../../entities/license.entity';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
@Global() @Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ApiKey])], imports: [TypeOrmModule.forFeature([ApiKey, License])],
controllers: [ApiKeysController], controllers: [ApiKeysController],
providers: [ApiKeysService], providers: [ApiKeysService],
exports: [ApiKeysService], exports: [ApiKeysService],

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { ApiKey } from '../../entities/api-key.entity'; import { ApiKey } from '../../entities/api-key.entity';
import { License } from '../../entities/license.entity';
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */ /** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
export interface CreatedApiKey { export interface CreatedApiKey {
@@ -32,6 +33,8 @@ export class ApiKeysService {
constructor( constructor(
@InjectRepository(ApiKey) @InjectRepository(ApiKey)
private readonly apiKeyRepo: Repository<ApiKey>, private readonly apiKeyRepo: Repository<ApiKey>,
@InjectRepository(License)
private readonly licenseRepo: Repository<License>,
) {} ) {}
/** /**
@@ -124,13 +127,18 @@ export class ApiKeysService {
} }
/** /**
* Validate a raw API key string. * Validate a raw API key string. Called by JwtAuthGuard.
* Called by a future ApiKeyAuthGuard — not exposed via HTTP.
* *
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at, * Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves
* and returns { license_id } on success or null on failure. * the license owner (so the guard can attribute the call to a real user UUID),
* and returns { license_id, user_id } on success or null on failure.
*
* user_id is the license owner — API-key calls act AS the owner, so any
* created_by / @CurrentUser FK insert gets a valid UUID and correct attribution.
*/ */
async validateKey(rawKey: string): Promise<{ license_id: string } | null> { async validateKey(
rawKey: string,
): Promise<{ license_id: string; user_id: string | null } | null> {
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
const key = await this.apiKeyRepo.findOne({ const key = await this.apiKeyRepo.findOne({
@@ -145,6 +153,11 @@ export class ApiKeysService {
// Update last_used_at without loading the full row again. // Update last_used_at without loading the full row again.
await this.apiKeyRepo.update(key.id, { last_used_at: new Date() }); await this.apiKeyRepo.update(key.id, { last_used_at: new Date() });
return { license_id: key.license_id }; const license = await this.licenseRepo.findOne({
where: { id: key.license_id },
select: ['id', 'owner_user_id'],
});
return { license_id: key.license_id, user_id: license?.owner_user_id ?? null };
} }
} }

View File

@@ -67,7 +67,7 @@ const groups: RoadmapGroup[] = [
description: description:
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.', 'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
items: [ items: [
{ text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access in progress' }, { text: 'Public REST API for server management', note: 'REST API live with OpenAPI docs; key-authenticated external access wired (corr_ bearer key acts as the license owner)' },
{ text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' }, { text: 'Webhook events (wipe completed, server down, player banned)', note: 'Shipped — HMAC-SHA256 signed delivery, SSRF-guarded' },
{ text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' }, { text: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' },
], ],