From 79e6c0d5568c9ee9bc7ebade084fde1e131b450f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:04:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(kalender):=20Datenmodell=20f=C3=BCr=20Kale?= =?UTF-8?q?nderkonten=20und=20Termine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kalenderkonto mit Tokens und Abgleichverweis, Termin als örtlicher Spiegel mit genau so viel, wie die Belegungsrechnung und die Wochenansicht brauchen. Bereich bekommt sein Standard-Kalenderkonto für M4. Die Tokens liegen verschlüsselt (AES-256-GCM). Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst direkten Zugriff auf fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. Der Schlüssel wird aus AUTH_SECRET abgeleitet statt separat konfiguriert: eine weitere Pflichtvariable wäre eine weitere Stelle, an der die Einrichtung scheitert. kalenderId ist nicht optional, sondern trägt den Wert "standard" für den Standardkalender — Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 70 ++++++++++++++ .../migration.sql | 9 ++ prisma/schema.prisma | 94 ++++++++++++++++++- src/server/krypto.ts | 84 +++++++++++++++++ 4 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql create mode 100644 prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql create mode 100644 src/server/krypto.ts diff --git a/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql new file mode 100644 index 0000000..91ce289 --- /dev/null +++ b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE'); + +-- CreateEnum +CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT'); + +-- AlterTable +ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT; + +-- CreateTable +CREATE TABLE "kalenderkonto" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "anbieter" "KalenderAnbieter" NOT NULL, + "anzeigename" TEXT NOT NULL, + "extKontoId" TEXT NOT NULL, + "kalenderId" TEXT, + "zugriffstoken" TEXT, + "aktualisierungstoken" TEXT, + "tokenGueltigBis" TIMESTAMP(3), + "bereiche" TEXT, + "deltaVerweis" TEXT, + "letzterAbgleich" TIMESTAMP(3), + "letzterFehler" TEXT, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "termin" ( + "id" TEXT NOT NULL, + "kalenderkontoId" TEXT NOT NULL, + "extId" TEXT NOT NULL, + "etag" TEXT, + "betreff" TEXT, + "beginn" TIMESTAMP(3) NOT NULL, + "ende" TIMESTAMP(3) NOT NULL, + "ganztaegig" BOOLEAN NOT NULL DEFAULT false, + "zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT', + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "termin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv"); + +-- CreateIndex +CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId"); + +-- CreateIndex +CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn"); + +-- CreateIndex +CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende"); + +-- CreateIndex +CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId"); + +-- AddForeignKey +ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql new file mode 100644 index 0000000..c915eca --- /dev/null +++ b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL, +ALTER COLUMN "kalenderId" SET DEFAULT 'standard'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38e858b..8c3c8d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,9 +31,10 @@ model Benutzer { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - konten Konto[] - passkeys Passkey[] - audit AuditEintrag[] + konten Konto[] + passkeys Passkey[] + audit AuditEintrag[] + kalenderkonten Kalenderkonto[] @@index([aktiv]) @@map("benutzer") @@ -121,6 +122,10 @@ model Bereich { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt + /// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden. + standardKalenderkontoId String? + standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull) + aufgaben Aufgabe[] projekte Projekt[] @@ -252,3 +257,86 @@ model AuditEintrag { @@index([benutzerId]) @@map("audit_eintrag") } + +enum KalenderAnbieter { + GRAPH + GOOGLE +} + +/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr. +enum ZeigtAls { + FREI + GEBUCHT + MIT_VORBEHALT + ABWESEND + WOANDERS + UNBEKANNT +} + +/// Ein verbundener Kalender bei Microsoft Graph oder Google. +model Kalenderkonto { + id String @id @default(cuid()) + benutzerId String + anbieter KalenderAnbieter + /// Frei waehlbare Bezeichnung, z. B. "Firma Outlook". + anzeigename String + /// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen. + extKontoId String + /// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID. + /// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional, + /// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst. + kalenderId String @default("standard") + + /// Verschluesselt abgelegt, siehe src/server/krypto.ts. + zugriffstoken String? + aktualisierungstoken String? + tokenGueltigBis DateTime? + /// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte. + bereiche String? + + /// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten. + deltaVerweis String? + letzterAbgleich DateTime? + /// Klartextmeldung des letzten Fehlschlags, ohne Tokens. + letzterFehler String? + + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + termine Termin[] + standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + + /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. + @@unique([benutzerId, anbieter, extKontoId, kalenderId]) + @@index([benutzerId, aktiv]) + @@map("kalenderkonto") +} + +/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die +/// Belegungsrechnung und die Wochenansicht gebraucht wird. +model Termin { + id String @id @default(cuid()) + kalenderkontoId String + /// Kennung des Termins beim Anbieter. + extId String + /// Fuer bedingte Schreibzugriffe ab M4 (If-Match). + etag String? + + betreff String? + beginn DateTime + ende DateTime + ganztaegig Boolean @default(false) + zeigtAls ZeigtAls @default(GEBUCHT) + + aktualisiertAm DateTime @updatedAt + + kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade) + + @@unique([kalenderkontoId, extId]) + @@index([kalenderkontoId, beginn]) + @@index([beginn, ende]) + @@map("termin") +} diff --git a/src/server/krypto.ts b/src/server/krypto.ts new file mode 100644 index 0000000..b61dd7b --- /dev/null +++ b/src/server/krypto.ts @@ -0,0 +1,84 @@ +import 'server-only' +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' +import { umgebung } from '@/server/umgebung' + +/** + * Verschlüsselung für Zugangs- und Aktualisierungstoken der Kalenderkonten. + * + * Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst + * direkten Zugriff auf fremde Kalender und Postfächer. Das wiegt schwerer als + * bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. + * + * Der Schlüssel wird aus AUTH_SECRET abgeleitet, nicht separat konfiguriert: + * eine zusätzliche Pflichtvariable wäre eine weitere Stelle, an der bei der + * Einrichtung etwas schiefgeht. Wer AUTH_SECRET wechselt, muss die + * Kalenderkonten neu verbinden – das ist gewollt, denn mit dem alten Geheimnis + * sind auch die alten Sitzungen ungültig. + */ + +const ALGORITHMUS = 'aes-256-gcm' +const SCHLUESSEL_LAENGE = 32 +const IV_LAENGE = 12 +const MARKE = 'v1' + +let zwischengespeichert: Buffer | null = null + +function schluessel(): Buffer { + if (zwischengespeichert) return zwischengespeichert + const abgeleitet = hkdfSync( + 'sha256', + Buffer.from(umgebung().AUTH_SECRET, 'utf8'), + Buffer.alloc(0), + Buffer.from('planer:kalender-token', 'utf8'), + SCHLUESSEL_LAENGE, + ) + zwischengespeichert = Buffer.from(abgeleitet) + return zwischengespeichert +} + +/** + * Verschlüsselt einen Text. Das Ergebnis ist selbstbeschreibend: + * "v1:::", alles base64url. + */ +export function verschluesseln(klartext: string): string { + const iv = randomBytes(IV_LAENGE) + const chiffre = createCipheriv(ALGORITHMUS, schluessel(), iv) + const inhalt = Buffer.concat([chiffre.update(klartext, 'utf8'), chiffre.final()]) + const echtheitsmarke = chiffre.getAuthTag() + + return [ + MARKE, + iv.toString('base64url'), + echtheitsmarke.toString('base64url'), + inhalt.toString('base64url'), + ].join(':') +} + +/** + * Entschlüsselt einen Text. Gibt null zurück, wenn er nicht zum Schlüssel + * passt oder verändert wurde – das ist kein Absturzgrund, sondern heißt: + * das Konto muss neu verbunden werden. + */ +export function entschluesseln(gespeichert: string | null): string | null { + if (!gespeichert) return null + + const teile = gespeichert.split(':') + if (teile.length !== 4 || teile[0] !== MARKE) return null + + try { + const iv = Buffer.from(teile[1] ?? '', 'base64url') + const echtheitsmarke = Buffer.from(teile[2] ?? '', 'base64url') + const inhalt = Buffer.from(teile[3] ?? '', 'base64url') + + const dechiffre = createDecipheriv(ALGORITHMUS, schluessel(), iv) + dechiffre.setAuthTag(echtheitsmarke) + return Buffer.concat([dechiffre.update(inhalt), dechiffre.final()]).toString('utf8') + } catch { + return null + } +} + +/** Nur für Tests: erzwingt beim nächsten Aufruf ein erneutes Ableiten. */ +export function schluesselVergessen(): void { + zwischengespeichert = null +}