feat: revocable read-only API keys for machine access

Integrations such as the Wabenhain shop sync no longer need a service
account with a password (and without MFA). An admin creates a key under
Verwaltung > Automatisierung > API-Schlüssel.

- Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is
  shown exactly once. Optional expiry, "last used" timestamp, revocation
  takes effect immediately; create/revoke are audit-logged without the key.
- A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard
  admits it only to routes whose permission is among its scopes and never
  to user-only routes. Scopes come from a short allowlist (shop-sync:read).
- Managing keys requires the new permission api-keys:manage.
- Keys are excluded from backups on purpose; a restore means issuing a new
  key.

Additive migration (new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 01:24:17 +02:00
1 parent ede86c062a
commit 445fb3b94b
21 files changed
+848 -5

No files matched your search

@@ -0,0 +1,42 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import {
ApiKeySummary,
ApiKeysService,
CreatedApiKey,
} from './api-keys.service';
@ApiTags('api-keys')
@ApiBearerAuth()
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeys: ApiKeysService) {}
@RequirePermission('api-keys:manage')
@Get()
findAll(): Promise<ApiKeySummary[]> {
return this.apiKeys.findAll();
}
@RequirePermission('api-keys:manage')
@Post()
create(
@Body() dto: CreateApiKeyDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<CreatedApiKey> {
return this.apiKeys.create(dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Post(':id/revoke')
revoke(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ApiKeySummary> {
return this.apiKeys.revoke(id, actor.id);
}
}