Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
-- Anmeldung, Gruppen und Rollen.
|
||||
--
|
||||
-- Die Reihenfolge ist wichtig: `QrCode.groupId` ist verpflichtend, es gibt
|
||||
-- aber möglicherweise schon Codes. Deshalb wird die Spalte erst ohne
|
||||
-- Einschränkung angelegt, dann mit der Standardgruppe gefüllt und erst
|
||||
-- danach auf NOT NULL gesetzt. Ohne diesen Zwischenschritt scheitert die
|
||||
-- Migration auf jeder Installation, die bereits benutzt wurde.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"failedLogins" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMP(3),
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Group" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Membership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Membership_groupId_idx" ON "Membership"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Membership_userId_groupId_key" ON "Membership"("userId", "groupId");
|
||||
|
||||
-- Standardgruppe anlegen. Sie ist auch bei ausgeschalteter Anmeldung die
|
||||
-- Heimat aller Codes, damit es keinen Sonderfall "Code ohne Gruppe" gibt.
|
||||
INSERT INTO "Group" ("id", "name", "description", "createdAt", "updatedAt")
|
||||
VALUES (
|
||||
'grp_standard',
|
||||
'Standard',
|
||||
'Beim ersten Start angelegt. Enthält alle Codes, die vor Einführung der Benutzerverwaltung entstanden sind.',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- AlterTable: erst nullable anlegen …
|
||||
ALTER TABLE "QrCode" ADD COLUMN "createdById" TEXT,
|
||||
ADD COLUMN "groupId" TEXT;
|
||||
|
||||
-- … bestehende Codes der Standardgruppe zuordnen …
|
||||
UPDATE "QrCode" SET "groupId" = 'grp_standard' WHERE "groupId" IS NULL;
|
||||
|
||||
-- … und erst jetzt verpflichtend machen.
|
||||
ALTER TABLE "QrCode" ALTER COLUMN "groupId" SET NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QrCode_groupId_idx" ON "QrCode"("groupId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QrCode" ADD CONSTRAINT "QrCode_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+110
-17
@@ -10,6 +10,90 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
||||
///
|
||||
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
||||
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
||||
/// erste Gruppe erstellt.
|
||||
enum Role {
|
||||
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
||||
ADMIN
|
||||
/// Darf Codes anlegen, ändern und löschen.
|
||||
EDITOR
|
||||
/// Darf nur lesen und herunterladen.
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||
passwordHash String
|
||||
/// Darf Gruppen, Benutzer und Domains verwalten.
|
||||
isInstanceAdmin Boolean @default(false)
|
||||
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
||||
/// werden beim nächsten Zugriff verworfen.
|
||||
isActive Boolean @default(true)
|
||||
failedLogins Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
memberships Membership[]
|
||||
createdCodes QrCode[] @relation("QrCodeCreator")
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
||||
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
||||
/// deaktiviert wird.
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
||||
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
||||
tokenHash String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships Membership[]
|
||||
codes QrCode[]
|
||||
}
|
||||
|
||||
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
||||
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
||||
model Membership {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
groupId String
|
||||
role Role
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, groupId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
/// Freigabe eines QR-Typs durch die Verwaltung.
|
||||
///
|
||||
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
||||
@@ -26,24 +110,33 @@ model QrTypeSetting {
|
||||
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
||||
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
||||
///
|
||||
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
|
||||
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
|
||||
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||
model QrCode {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
fgColor String @default("#000000")
|
||||
bgColor String @default("#FFFFFF")
|
||||
size Int @default(512)
|
||||
ecLevel String @default("M")
|
||||
margin Int @default(4)
|
||||
scans Int @default(0)
|
||||
lastScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
type String
|
||||
label String?
|
||||
data Json
|
||||
fgColor String @default("#000000")
|
||||
bgColor String @default("#FFFFFF")
|
||||
size Int @default(512)
|
||||
ecLevel String @default("M")
|
||||
margin Int @default(4)
|
||||
scans Int @default(0)
|
||||
lastScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
||||
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
||||
groupId String
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
||||
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
||||
createdById String?
|
||||
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user