diff --git a/backend-nest/src/common/guards/jwt-auth.guard.ts b/backend-nest/src/common/guards/jwt-auth.guard.ts index 0eaf3d6..8387fea 100644 --- a/backend-nest/src/common/guards/jwt-auth.guard.ts +++ b/backend-nest/src/common/guards/jwt-auth.guard.ts @@ -1,20 +1,68 @@ -import { Injectable, ExecutionContext } from '@nestjs/common'; +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { ApiKeysService } from '../../modules/api-keys/api-keys.service'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { - constructor(private reflector: Reflector) { + constructor( + private reflector: Reflector, + private readonly apiKeysService: ApiKeysService, + ) { super(); } - canActivate(context: ExecutionContext) { + async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); 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; } } diff --git a/backend-nest/src/common/guards/permissions.guard.ts b/backend-nest/src/common/guards/permissions.guard.ts index 878edc0..e417c9c 100644 --- a/backend-nest/src/common/guards/permissions.guard.ts +++ b/backend-nest/src/common/guards/permissions.guard.ts @@ -19,6 +19,11 @@ export class PermissionsGuard implements CanActivate { // Super admins bypass all permission checks 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 const permissions = user.permissions as Record | undefined; if (!permissions) return false; diff --git a/backend-nest/src/modules/api-keys/api-keys.module.ts b/backend-nest/src/modules/api-keys/api-keys.module.ts index 5f4e986..199b6b5 100644 --- a/backend-nest/src/modules/api-keys/api-keys.module.ts +++ b/backend-nest/src/modules/api-keys/api-keys.module.ts @@ -1,12 +1,13 @@ import { Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApiKey } from '../../entities/api-key.entity'; +import { License } from '../../entities/license.entity'; import { ApiKeysController } from './api-keys.controller'; import { ApiKeysService } from './api-keys.service'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([ApiKey])], + imports: [TypeOrmModule.forFeature([ApiKey, License])], controllers: [ApiKeysController], providers: [ApiKeysService], exports: [ApiKeysService], diff --git a/backend-nest/src/modules/api-keys/api-keys.service.ts b/backend-nest/src/modules/api-keys/api-keys.service.ts index dc5d8ac..3357c27 100644 --- a/backend-nest/src/modules/api-keys/api-keys.service.ts +++ b/backend-nest/src/modules/api-keys/api-keys.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as crypto from 'crypto'; 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. */ export interface CreatedApiKey { @@ -32,6 +33,8 @@ export class ApiKeysService { constructor( @InjectRepository(ApiKey) private readonly apiKeyRepo: Repository, + @InjectRepository(License) + private readonly licenseRepo: Repository, ) {} /** @@ -124,13 +127,18 @@ export class ApiKeysService { } /** - * Validate a raw API key string. - * Called by a future ApiKeyAuthGuard — not exposed via HTTP. + * Validate a raw API key string. Called by JwtAuthGuard. * - * Hashes the raw key, looks up an ACTIVE row, touches last_used_at, - * and returns { license_id } on success or null on failure. + * Hashes the raw key, looks up an ACTIVE row, touches last_used_at, resolves + * 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 key = await this.apiKeyRepo.findOne({ @@ -145,6 +153,11 @@ export class ApiKeysService { // Update last_used_at without loading the full row again. 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 }; } } diff --git a/frontend/src/views/marketing/RoadmapView.vue b/frontend/src/views/marketing/RoadmapView.vue index 458ed58..d50ff5a 100644 --- a/frontend/src/views/marketing/RoadmapView.vue +++ b/frontend/src/views/marketing/RoadmapView.vue @@ -67,7 +67,7 @@ const groups: RoadmapGroup[] = [ 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.', 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: 'API key management per license', note: 'Shipped — create, list, revoke with hashed storage' }, ],