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,24 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"scopes" TEXT[],
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
-- CreateIndex
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+24
View File
@@ -111,10 +111,34 @@ model User {
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@index([roleId])
}
// Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines
// Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des
// Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt.
// Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren
// Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts).
model ApiKey {
id String @id @default(cuid())
name String
// Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der
// Liste, ohne den Schlüssel selbst zu speichern.
prefix String
keyHash String @unique
scopes String[]
createdByUserId String?
createdByUser User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
@@index([revokedAt])
}
model RefreshToken {
id String @id @default(cuid())
userId String