feat(kalender): Datenmodell für Kalenderkonten und Termine
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a6bcaf8982
commit
79e6c0d556
@@ -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;
|
||||
@@ -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';
|
||||
@@ -34,6 +34,7 @@ model Benutzer {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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:<iv>:<marke>:<inhalt>", 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
|
||||
}
|
||||
Reference in New Issue
Block a user