Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)

Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines
Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung
für den sicheren Betrieb beim ersten echten Kunden.

Store & Migration
- Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret,
  Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin.
- AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform).
- Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen
  (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt.

Getrennter Login + MFA
- Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und
  eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId.
- TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR +
  Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes.
- Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen,
  Fehlversuche und Sperren (scope=platform).

Autorisierung / Trennung
- Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt
  Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin.
- Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die
  Plattform-Session statt des abgelösten Flags.
- provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform-
  Admin (admin@demo.example) im getrennten Store an.

Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes →
/admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf
/platform/login umgeleitet. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:55:12 +02:00
co-authored by Claude Opus 4.8
parent 8348764c99
commit 8e4040d1da
22 changed files with 1009 additions and 54 deletions
@@ -0,0 +1,46 @@
-- DropForeignKey
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_tenant_id_fkey";
-- AlterTable
ALTER TABLE "audit_logs" ALTER COLUMN "tenant_id" DROP NOT NULL;
-- CreateTable
CREATE TABLE "platform_admins" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"mfa_secret" TEXT,
"mfa_enrolled_at" TIMESTAMP(3),
"recovery_codes" JSONB NOT NULL DEFAULT '[]',
"failed_logins" INTEGER NOT NULL DEFAULT 0,
"locked_until" TIMESTAMP(3),
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "platform_admins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "platform_admins_email_key" ON "platform_admins"("email");
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Datenmigration (Phase-1-Härtung Paket 2): bestehende isPlatformAdmin-Nutzer in den
-- getrennten PlatformAdmin-Store übernehmen (gleicher Argon2-Hash → Login sofort möglich)
-- und das Mandanten-Flag zurücksetzen. MFA wird beim ersten Plattform-Login eingerichtet.
INSERT INTO "platform_admins" ("id", "email", "password_hash", "name", "status", "recovery_codes", "failed_logins", "created_at", "updated_at")
SELECT gen_random_uuid()::text, lower("email"), "password_hash", "name", 'ACTIVE', '[]'::jsonb, 0, now(), now()
FROM "users"
WHERE "is_platform_admin" = true
ON CONFLICT ("email") DO NOTHING;
UPDATE "users" SET "is_platform_admin" = false WHERE "is_platform_admin" = true;
-- platform_admins ist plattformweit (kein tenant_id) → RLS bewusst NICHT aktiviert.
-- Zugriff für die eingeschränkte App-Rolle (Härtungs-Iteration) explizit gewähren.
GRANT SELECT, INSERT, UPDATE, DELETE ON "platform_admins" TO isms_app;