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

@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
interface RequestWithUser {
user?: AuthenticatedUser;
apiKey?: ApiKeyPrincipal;
}
/**
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
// API keys pass only routes that name a permission the key carries -
// never @AnyAuthenticatedUser routes, which are meant for people.
if (request.apiKey) {
const scopes: readonly string[] = request.apiKey.scopes;
return Boolean(
requiredPermissions?.some((permission) => scopes.includes(permission)),
);
}
if (requiredPermissions && requiredPermissions.length > 0) {
if (!user) {
return false;