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

+17
View File
@@ -0,0 +1,17 @@
import { apiFetch } from "@/lib/auth/api-client";
import { ApiKey, CreateApiKeyInput, CreatedApiKey } from "./types";
export function fetchApiKeys(): Promise<ApiKey[]> {
return apiFetch<ApiKey[]>("/api-keys");
}
export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
return apiFetch<CreatedApiKey>("/api-keys", {
method: "POST",
body: JSON.stringify(input),
});
}
export function revokeApiKey(id: string): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
}
@@ -0,0 +1,29 @@
export type ApiKeyScope = "shop-sync:read";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
};
export interface ApiKey {
id: string;
name: string;
prefix: string;
scopes: ApiKeyScope[];
createdByUserId: string | null;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
}
export interface CreateApiKeyInput {
name: string;
scopes: ApiKeyScope[];
expiresInDays?: number;
}
export interface CreatedApiKey {
apiKey: ApiKey;
/** Shown exactly once - the server keeps only a hash. */
token: string;
}