feat(api): per-license API key management + roadmap sync
API keys (roadmap: 'API key management per license'):
- migration 023_api_keys; ApiKey entity; ApiKeysModule (@Global, exports
ApiKeysService) wired into app.module.
- Service: create (corr_<prefix>_<secret>, returns plaintext once, stores
sha256 hash + prefix), list (no hash), revoke, and validateKey(rawKey) ->
{ license_id } for a future API-key auth guard. Controller license-scoped +
RBAC (apikeys.view/manage).
Roadmap: moved the shipped multi-game items (multi-instance host runtime,
per-game wipe + event scheduling) into a 'Phase 2 — Multi-game runtime' shipped
group; Dune/Conan/Soulmask Formulae stay in-progress.
Backend tsc + frontend build green. Migration applies on a fresh DB (Saturday host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ import { RaidableBasesModule } from './modules/raidablebases/raidablebases.modul
|
||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
import { InstancesModule } from './modules/instances/instances.module';
|
||||
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -137,6 +138,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
EarlyAccessModule,
|
||||
FleetModule,
|
||||
InstancesModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
37
backend-nest/src/entities/api-key.entity.ts
Normal file
37
backend-nest/src/entities/api-key.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('api_keys')
|
||||
@Index(['key_hash'])
|
||||
@Index(['license_id'])
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
license_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
/** First 8 chars of the random token — shown in UI so users can identify keys. */
|
||||
@Column({ type: 'varchar', length: 16 })
|
||||
key_prefix: string;
|
||||
|
||||
/** SHA-256 hex digest of the full plaintext key. Never returned to clients. */
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
key_hash: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_used_at: Date | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'NOW()' })
|
||||
created_at: Date;
|
||||
|
||||
@ManyToOne(() => License, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'license_id' })
|
||||
license: License;
|
||||
}
|
||||
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
55
backend-nest/src/modules/api-keys/api-keys.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
@ApiTags('api-keys')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({
|
||||
summary: 'Create an API key',
|
||||
description:
|
||||
'Issues a new API key for this license. The full plaintext key is returned ONCE — store it securely; it cannot be retrieved again.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Key created — plaintext key returned once.' })
|
||||
async create(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
) {
|
||||
return this.apiKeysService.create(licenseId, dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('apikeys.view')
|
||||
@ApiOperation({ summary: 'List API keys', description: 'Returns all keys (active and revoked) for this license. Key hashes are never returned.' })
|
||||
@ApiResponse({ status: 200, description: 'Key list.' })
|
||||
async list(@CurrentTenant() licenseId: string) {
|
||||
return this.apiKeysService.list(licenseId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('apikeys.manage')
|
||||
@ApiOperation({ summary: 'Revoke an API key', description: 'Soft-deletes the key (is_active = false). The row is retained for audit purposes.' })
|
||||
@ApiResponse({ status: 200, description: 'Key revoked.' })
|
||||
@ApiResponse({ status: 404, description: 'Key not found in this license.' })
|
||||
async revoke(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.apiKeysService.revoke(licenseId, id);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
14
backend-nest/src/modules/api-keys/api-keys.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKey])],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
150
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
150
backend-nest/src/modules/api-keys/api-keys.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import { ApiKey } from '../../entities/api-key.entity';
|
||||
|
||||
/** Shape returned to the caller on creation — the ONLY time the plaintext key is exposed. */
|
||||
export interface CreatedApiKey {
|
||||
/** Full plaintext key — show once, store nowhere. */
|
||||
plaintext_key: string;
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Safe list view — no hash, no plaintext. */
|
||||
export interface ApiKeyListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
last_used_at: Date | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly logger = new Logger(ApiKeysService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApiKey)
|
||||
private readonly apiKeyRepo: Repository<ApiKey>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issue a new API key for the given license.
|
||||
*
|
||||
* Key format: `corr_<prefix8>_<secret32>`
|
||||
* where prefix and secret are URL-safe base64url random bytes.
|
||||
*
|
||||
* Returns the full plaintext key ONCE alongside the saved row.
|
||||
* The hash is never returned to the caller.
|
||||
*/
|
||||
async create(licenseId: string, name: string): Promise<CreatedApiKey> {
|
||||
const prefixBytes = crypto.randomBytes(6); // 8 base64url chars
|
||||
const secretBytes = crypto.randomBytes(24); // 32 base64url chars
|
||||
|
||||
const prefix = prefixBytes.toString('base64url');
|
||||
const secret = secretBytes.toString('base64url');
|
||||
const plaintextKey = `corr_${prefix}_${secret}`;
|
||||
|
||||
const keyHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(plaintextKey)
|
||||
.digest('hex');
|
||||
|
||||
const entity = this.apiKeyRepo.create({
|
||||
license_id: licenseId,
|
||||
name,
|
||||
key_prefix: prefix,
|
||||
key_hash: keyHash,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const saved = await this.apiKeyRepo.save(entity);
|
||||
|
||||
this.logger.log(
|
||||
`API key created: id=${saved.id} prefix=${prefix} license=${licenseId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
plaintext_key: plaintextKey,
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
key_prefix: saved.key_prefix,
|
||||
is_active: saved.is_active,
|
||||
created_at: saved.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys (active and revoked) for a license.
|
||||
* The key_hash is intentionally excluded.
|
||||
*/
|
||||
async list(licenseId: string): Promise<ApiKeyListItem[]> {
|
||||
const rows = await this.apiKeyRepo.find({
|
||||
where: { license_id: licenseId },
|
||||
order: { created_at: 'DESC' },
|
||||
select: ['id', 'name', 'key_prefix', 'last_used_at', 'is_active', 'created_at'],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
key_prefix: r.key_prefix,
|
||||
last_used_at: r.last_used_at,
|
||||
is_active: r.is_active,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a key.
|
||||
* Returns the updated row or throws NotFoundException if the key
|
||||
* doesn't exist within this license.
|
||||
*/
|
||||
async revoke(licenseId: string, id: string): Promise<{ id: string; is_active: boolean }> {
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { id, license_id: licenseId },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
throw new NotFoundException(`API key ${id} not found`);
|
||||
}
|
||||
|
||||
key.is_active = false;
|
||||
await this.apiKeyRepo.save(key);
|
||||
|
||||
this.logger.log(`API key revoked: id=${id} license=${licenseId}`);
|
||||
|
||||
return { id: key.id, is_active: key.is_active };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw API key string.
|
||||
* Called by a future ApiKeyAuthGuard — not exposed via HTTP.
|
||||
*
|
||||
* Hashes the raw key, looks up an ACTIVE row, touches last_used_at,
|
||||
* and returns { license_id } on success or null on failure.
|
||||
*/
|
||||
async validateKey(rawKey: string): Promise<{ license_id: string } | null> {
|
||||
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
|
||||
const key = await this.apiKeyRepo.findOne({
|
||||
where: { key_hash: keyHash, is_active: true },
|
||||
select: ['id', 'license_id'],
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
10
backend-nest/src/modules/api-keys/dto/create-api-key.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@ApiProperty({ description: 'Human-readable label for this key', maxLength: 100 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
17
backend/migrations/023_api_keys.sql
Normal file
17
backend/migrations/023_api_keys.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Per-license API key management
|
||||
-- Each row represents one issued key: the plaintext is shown once at creation
|
||||
-- and never stored; only the SHA-256 hex digest is persisted.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(128) NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_license ON api_keys(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||
@@ -41,16 +41,24 @@ const groups: RoadmapGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'Multi-game expansion',
|
||||
status: 'shipped',
|
||||
label: 'Phase 2 — Multi-game runtime',
|
||||
description:
|
||||
're-Agent and the control plane are being extended with per-game Formulae. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model with game-specific operational logic.',
|
||||
're-Agent multi-instance support and the per-game scheduling engine are live. One agent process now manages N game server instances on the same host, and the auto-wiper and event scheduler fire per-game on their own cadence.',
|
||||
items: [
|
||||
{ text: 'Dune: Awakening Formula', note: 'Deep Desert wipe scheduling, battlegroup lifecycle' },
|
||||
{ text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' },
|
||||
{ text: 'Per-game wipe and event scheduling', note: 'Auto-wiper and event scheduler both fire per-game instance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'Multi-game expansion — game Formulae',
|
||||
description:
|
||||
'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.',
|
||||
items: [
|
||||
{ text: 'Dune: Awakening Formula', note: 'Battlegroup lifecycle shipped; Deep Desert wipe scheduling in progress' },
|
||||
{ text: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
|
||||
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' },
|
||||
{ text: 'Multi-instance host runtime', note: 'One re-Agent managing N game processes on the same machine' },
|
||||
{ text: 'Per-game wipe and event scheduling' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user