diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3dc252a..6bb60e4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,7 +97,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`, eigene, von der SSO-App-Registrierung getrennte Zugangsdaten `M365_CALENDAR_TENANT_ID`/`_CLIENT_ID`/`_CLIENT_SECRET`, ausschließlich Env-Variablen) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. +9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. ## 7. Kubernetes diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 91a0e88..f013d5b 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -43,25 +43,22 @@ BACKUP_RETENTION_COUNT=14 # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI # muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen. +# +# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation +# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig. +# Dafuer in der App-Registrierung zusaetzlich die Application-Permission +# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird +# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten, +# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden +# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe +# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert; +# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID +# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden. OIDC_ISSUER_URL="https://login.microsoftonline.com//v2.0" OIDC_CLIENT_ID="change-me-entra-client-id" OIDC_CLIENT_SECRET="change-me-entra-client-secret" OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" -# M365-Kalender-Synchronisation (Modul Kalender). Eintraege (Aufgaben, -# Durchsichten, Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) -# werden einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365- -# Gruppe uebertragen. Eigene, separate App-Registrierung in Azure/Entra -# (NICHT dieselbe wie OIDC_CLIENT_ID oben) mit Application-Permission -# "Calendars.ReadWrite" (Admin-Consent noetig), Client-Credentials-Flow. -# Ohne alle drei Werte bleibt die Synchronisation deaktiviert. Zusaetzlich -# muss die Synchronisation UND die jeweilige Gruppen-ID in der GUI -# (Verwaltung > Automatisierung > Kalender-Sync) aktiviert/gesetzt werden - -# diese drei Env-Variablen allein reichen nicht. -M365_CALENDAR_TENANT_ID="change-me-m365-tenant-id" -M365_CALENDAR_CLIENT_ID="change-me-m365-client-id" -M365_CALENDAR_CLIENT_SECRET="change-me-m365-client-secret" - # Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das # gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde. # Nur fuer lokale Entwicklung/Debugging explizit aktivieren. diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts index deb93f9..8fe5603 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts @@ -17,30 +17,36 @@ describe('GraphClientService', () => { service = new GraphClientService(); fetchMock = jest.fn(); global.fetch = fetchMock; - process.env.M365_CALENDAR_TENANT_ID = 'tenant-1'; - process.env.M365_CALENDAR_CLIENT_ID = 'client-1'; - process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1'; + process.env.OIDC_ISSUER_URL = + 'https://login.microsoftonline.com/tenant-1/v2.0'; + process.env.OIDC_CLIENT_ID = 'client-1'; + process.env.OIDC_CLIENT_SECRET = 'secret-1'; }); afterEach(() => { - delete process.env.M365_CALENDAR_TENANT_ID; - delete process.env.M365_CALENDAR_CLIENT_ID; - delete process.env.M365_CALENDAR_CLIENT_SECRET; + delete process.env.OIDC_ISSUER_URL; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; }); describe('isConfigured', () => { - it('returns true when all three env vars are set', () => { + it('returns true when all three SSO env vars are set', () => { expect(service.isConfigured()).toBe(true); }); it('returns false when a required env var is missing', () => { - delete process.env.M365_CALENDAR_CLIENT_SECRET; + delete process.env.OIDC_CLIENT_SECRET; + expect(service.isConfigured()).toBe(false); + }); + + it('returns false when the issuer URL has no tenant segment', () => { + process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/'; expect(service.isConfigured()).toBe(false); }); }); describe('createEvent', () => { - it('fetches a token, then posts an all-day event and returns the new id', async () => { + it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', async () => { fetchMock .mockResolvedValueOnce( jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), @@ -54,10 +60,15 @@ describe('GraphClientService', () => { expect(result).toBe('graph-event-1'); expect(fetchMock).toHaveBeenCalledTimes(2); - const [tokenUrl] = fetchMock.mock.calls[0] as [string]; + const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [ + string, + { body: URLSearchParams }, + ]; expect(tokenUrl).toBe( 'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token', ); + expect(tokenInit.body.get('client_id')).toBe('client-1'); + expect(tokenInit.body.get('client_secret')).toBe('secret-1'); const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [ string, { headers: Record; body: string }, diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts index b8fc30e..4e76c27 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts @@ -27,15 +27,34 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +// Reuses the same Entra app registration as interactive SSO login +// (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET) for the app-only +// Graph client-credentials flow, instead of a second, separate app +// registration - one enterprise application covers both, as long as it +// also has the "Calendars.ReadWrite" Application permission (admin- +// consented) alongside its delegated login scopes. OIDC_ISSUER_URL is +// "https://login.microsoftonline.com//v2.0" - the tenant id is +// its first path segment. +function tenantIdFromIssuerUrl(issuerUrl: string): string | null { + try { + const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean); + return segments[0] ?? null; + } catch { + return null; + } +} + @Injectable() export class GraphClientService { private cachedToken: CachedToken | null = null; isConfigured(): boolean { + const issuerUrl = process.env.OIDC_ISSUER_URL; return Boolean( - process.env.M365_CALENDAR_TENANT_ID && - process.env.M365_CALENDAR_CLIENT_ID && - process.env.M365_CALENDAR_CLIENT_SECRET, + issuerUrl && + tenantIdFromIssuerUrl(issuerUrl) && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET, ); } @@ -44,12 +63,13 @@ export class GraphClientService { return this.cachedToken.accessToken; } - const tenantId = process.env.M365_CALENDAR_TENANT_ID; - const clientId = process.env.M365_CALENDAR_CLIENT_ID; - const clientSecret = process.env.M365_CALENDAR_CLIENT_SECRET; + const issuerUrl = process.env.OIDC_ISSUER_URL; + const clientId = process.env.OIDC_CLIENT_ID; + const clientSecret = process.env.OIDC_CLIENT_SECRET; + const tenantId = issuerUrl ? tenantIdFromIssuerUrl(issuerUrl) : null; if (!tenantId || !clientId || !clientSecret) { throw new Error( - 'M365-Kalender-Synchronisation ist nicht konfiguriert (Env-Variablen fehlen)', + 'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)', ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx index d79372e..c2f2353 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx @@ -74,9 +74,9 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {

Überträgt Kalendereinträge einseitig (ApiCore → M365) in den Kalender einer - Microsoft-365-Gruppe. Benötigt eine eigene App-Registrierung in Azure/Entra - (Umgebungsvariablen, siehe .env.example) mit Application-Berechtigung - „Calendars.ReadWrite“ und Admin-Consent. + Microsoft-365-Gruppe. Nutzt dieselbe Azure/Entra-App-Registrierung wie die + Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die + Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.

Lädt…

) : !status?.configured ? (

- Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als - Umgebungsvariable. + Nicht konfiguriert - die SSO-Umgebungsvariablen (OIDC_ISSUER_URL/OIDC_CLIENT_ID/ + OIDC_CLIENT_SECRET) fehlen, oder die App-Registrierung hat noch keine + Application-Berechtigung „Calendars.ReadWrite“.

) : !canRunNow ? (

diff --git a/deploy/k8s/backend/secret.example.yaml b/deploy/k8s/backend/secret.example.yaml index 6acbaa1..c627abb 100644 --- a/deploy/k8s/backend/secret.example.yaml +++ b/deploy/k8s/backend/secret.example.yaml @@ -16,12 +16,9 @@ stringData: JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long" JWT_REFRESH_SECRET: "change-me-refresh-secret" # Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml - # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. + # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die + # M365-Kalender-Synchronisation wiederverwendet - dafuer in der + # App-Registrierung zusaetzlich die Application-Permission + # "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig). OIDC_CLIENT_ID: "change-me-entra-client-id" OIDC_CLIENT_SECRET: "change-me-entra-client-secret" - # Separate App-Registrierung fuer die M365-Kalender-Synchronisation - # (Application-Permission "Calendars.ReadWrite", Admin-Consent noetig) - - # NICHT dieselbe wie OIDC_CLIENT_ID/-SECRET oben. - M365_CALENDAR_TENANT_ID: "change-me-m365-tenant-id" - M365_CALENDAR_CLIENT_ID: "change-me-m365-client-id" - M365_CALENDAR_CLIENT_SECRET: "change-me-m365-client-secret"