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 { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
import { FleetModule } from './modules/fleet/fleet.module';
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
import { InstancesModule } from './modules/instances/instances.module';
|
import { InstancesModule } from './modules/instances/instances.module';
|
||||||
|
import { ApiKeysModule } from './modules/api-keys/api-keys.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -137,6 +138,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
EarlyAccessModule,
|
EarlyAccessModule,
|
||||||
FleetModule,
|
FleetModule,
|
||||||
InstancesModule,
|
InstancesModule,
|
||||||
|
ApiKeysModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// 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',
|
status: 'shipped',
|
||||||
label: 'Multi-game expansion',
|
label: 'Phase 2 — Multi-game runtime',
|
||||||
description:
|
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: [
|
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: 'Conan Exiles Formula', note: 'Persistent world management, mod support, purge tracking' },
|
||||||
{ text: 'Soulmask Formula', note: 'Linked-world cluster deployment, port automation' },
|
{ 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