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:
@@ -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;
|
||||
+25
-3
@@ -166,6 +166,27 @@ model RolePermission {
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
// Plattform-Administratoren (Betreiber). Getrennter Store OHNE tenant_id, eigener
|
||||
// Login (/platform/login), TOTP-MFA-Pflicht + Recovery-Codes. Kein Zugriff auf
|
||||
// Mandanten-Fachdaten (nur Betrieb: Mandanten, Module, Lebenszyklus). §Phase-1-Härtung.
|
||||
model PlatformAdmin {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
name String
|
||||
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
||||
mfaSecret String? @map("mfa_secret")
|
||||
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
||||
recoveryCodes Json @default("[]") @map("recovery_codes") // SHA-256-Hashes der Recovery-Codes
|
||||
failedLogins Int @default(0) @map("failed_logins")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("platform_admins")
|
||||
}
|
||||
|
||||
// ── Assets & BIA (ein Modul, SPEC §4.1) ─────────────────────────────────────
|
||||
|
||||
enum AssetType {
|
||||
@@ -692,17 +713,18 @@ model MaturityAssessment {
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
// Nullable: Plattform-Ereignisse (scope=platform, z. B. Superadmin-Login) haben keinen Mandantenbezug.
|
||||
tenantId String? @map("tenant_id")
|
||||
scope String @default("tenant") // tenant | platform
|
||||
actorId String? @map("actor_id")
|
||||
action String // create | update | delete | login | export | …
|
||||
action String // create | update | delete | login | denied | export | …
|
||||
entity String // z. B. asset, risk, user
|
||||
entityId String? @map("entity_id")
|
||||
before Json?
|
||||
after Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId, entity, entityId])
|
||||
@@index([tenantId, createdAt])
|
||||
|
||||
+10
-3
@@ -528,8 +528,15 @@ async function main() {
|
||||
const mc = await importManaged(prisma, tenant.id);
|
||||
console.log(`✔ Verwaltete Register: Krypto ${mc.crypto}, Klassifizierung ${mc.classes}×${mc.aspects}, Risikomatrix (${mc.damage} Schadensdim.), Handbuch ${mc.handbook} Themen`);
|
||||
|
||||
// 11. Admin-Konsole: Mandanten-Einstellungen, Module, Superadmin
|
||||
await prisma.user.updateMany({ where: { tenantId: tenant.id, email: "admin@demo.example" }, data: { isPlatformAdmin: true } });
|
||||
// 11. Admin-Konsole: Mandanten-Einstellungen, Module, Plattform-Admin (getrennter Store)
|
||||
// Demo-Plattform-Admin: gleiche Adresse wie der Mandanten-Admin, damit /login (Mandant)
|
||||
// und /platform/login (Betrieb) mit denselben Demo-Zugangsdaten funktionieren. MFA wird
|
||||
// beim ersten Plattform-Login eingerichtet.
|
||||
await prisma.platformAdmin.upsert({
|
||||
where: { email: "admin@demo.example" },
|
||||
update: { name: "Anna Admin", status: "ACTIVE" },
|
||||
create: { email: "admin@demo.example", name: "Anna Admin", passwordHash: await hash("Demo1234!"), status: "ACTIVE" },
|
||||
});
|
||||
await prisma.tenantSettings.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
update: {},
|
||||
@@ -554,7 +561,7 @@ async function main() {
|
||||
create: { tenantId: tenant.id, moduleKey: key, enabled: true },
|
||||
});
|
||||
}
|
||||
console.log(`✔ Admin-Konsole: Einstellungen, ${MODULE_KEYS.length} Module aktiv, admin@demo.example = Superadmin`);
|
||||
console.log(`✔ Admin-Konsole: Einstellungen, ${MODULE_KEYS.length} Module aktiv; Plattform-Admin admin@demo.example (getrennter Login /platform/login, MFA beim ersten Login)`);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user