Projekt-Ref, Entra-Client- und Tenant-ID standen im Klartext in der
SSO-Anleitung. Geheimnisse sind das nicht — ohne Schlüssel gibt eine
Projekt-URL nichts her, und RLS greift unabhängig davon. Sie zeigen aber auf
die laufende Umgebung, und dieses Repository wandert weiter als sie: es geht
gleich auf einen eigenen Git-Server und später an den Kunden.
Jetzt Platzhalter; die Werte gehören in die Übergabedokumentation. In der
Historie stehen sie weiterhin — das sauber zu entfernen hiesse, die Historie
neu zu schreiben, und das passiert nicht nebenbei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Anmeldung läuft über das Firmenkonto. Supabase Auth bleibt dabei die
Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz. Genau deshalb
ist der Eingriff klein: auth.uid() liefert weiterhin eine UUID, profiles.id
trägt weiterhin role und is_active, und damit bleiben is_hr_user() und alle
58 RLS-Policies unverändert gültig. Die Sicherheitsgrenze wandert nicht in
den Anwendungscode.
Der Passwort-Pfad ist weg, nicht deaktiviert. Ein zweiter Anmeldeweg neben dem
Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, bedingten
Zugriff, Sperrung beim Austritt.
Dazu die Rückweg-Route /auth/callback, die den PKCE-Code gegen eine Sitzung
tauscht, und eine Ausnahme im Proxy: ohne sie leitet der Gate den Code nach
/login um, weil es die Sitzung ja erst danach gibt, und die Anmeldung kommt
nie zustande. Ob jemand HR-Zugriff hat, entscheidet weiterhin nicht die Route,
sondern profiles.role/is_active und darunter die Policies.
Zwei Werkzeuge für die Umstellung:
- relink-profile.ts hängt eine bestehende profiles-Zeile auf die
Entra-Identität um. Ein Passwort-Konto und das Entra-Konto derselben
Person sind für Supabase zwei Benutzer mit verschiedenen IDs; ohne das
zeigt die profiles-Zeile nach der ersten SSO-Anmeldung ins Leere und man
sperrt sich aus. Die Fremdschlüssel auf auth.users wandern mit, sonst
stünde in der Historie eine Kennung ohne Konto dahinter.
- entra-claims.ts zeigt, was der Anbieter tatsächlich mitgeschickt hat.
Die geplante Freischaltung über eine Entra-Gruppe hängt daran, wie der
Anspruch heisst und aussieht, und das unterscheidet sich je nach
Tokenkonfiguration des Mandanten. Der Trigger wird erst danach gebaut,
sonst wäre er geraten.
Beim Auswerten der Gruppe später gilt: die Quelle ist auth.identities.
identity_data, nie raw_user_meta_data. Letzteres beschreibt die angemeldete
Person über updateUser() selbst — läse die Freischaltung von dort, könnte sich
jede:r Angemeldete HR-Rechte eintragen. Steht so in docs/entra-sso.md.
Die Anmeldeseite war eine Box im leeren Rosa. Jetzt zweispaltig: links eine
Markenfläche, rechts die Anmeldung; unter 1024px fällt die Fläche weg und die
Wortmarke rückt über die Karte. Die Microsoft-Schaltfläche ist bewusst nicht
mehr in der Hausfarbe — magenta las sich als Aktion *innerhalb* dieser
Anwendung, während sie auf eine fremde Anmeldeseite springt. Weiss mit
grauem Rand ist Microsofts eigene Vorgabe und das Muster, das man
wiedererkennt. Dazu ein Wartezustand für den Sprung und eine Fehlermeldung,
die erklärt, was zu tun ist, statt nur "Kein HR-Zugriff" zu behaupten.
Nachgemessen im laufenden Server statt geschätzt: 656/624 auf 1280px,
Markenfläche in brand-700, Schaltfläche 45px hoch, kein Querlauf auf 375px.
Typecheck, Lint, Build und 182 Tests sind grün.
The mapping table was declared ON COMMIT DROP. In the Supabase SQL editor
the transaction boundaries are not ours to assume, and a mapping table that
vanished between the two inserts would leave positions without assignments
and be miserable to diagnose. It is now dropped explicitly once both inserts
have run.
docs/azure-migration.md is the design for the Azure move, for review before
any code changes.
Its main finding corrects what I said when I laid out the options: I claimed
that dropping Supabase would push the security boundary into application
code. It does not. auth.uid() appears 70 times, but only one of them matters
— inside is_hr_user(), which all 58 policies call. Swapping the source of
the user id there leaves every policy valid, so the database stays the
boundary.
The risk moves elsewhere, and the design says so plainly: the user id
arrives via set_config(..., true), which is transaction-local. Outside a
transaction it sticks to the pooled connection, and the next request on that
connection runs as the previous user. So the plan makes that structurally
impossible — a single access function that owns the transaction, a lint rule
against importing the pool anywhere else, a database role without BYPASSRLS
so a missing context returns nothing rather than everything, and a test that
sends two requests over one pooled connection to prove the second cannot see
the first.
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
written by a trigger on `employees` rather than inside each RPC: ~70
`update employees` statements spread over fifteen migrations mean per-call
bookkeeping would miss paths today and again with every future RPC. A
partial unique index enforces the one-open-interval invariant the trigger
relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
entry/exit/karenz, past placement from the new history, future placement
projected from pending_org_changes. Placements predating the migration are
backfilled with today's values and flagged as such in the UI, since
employee_history only ever stored free text and cannot be reconstructed.
Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
(db.max_rows); employee_history is already past it at ~800 staff. Every
whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
serial straight off getTime(), so a Date built at local midnight lands on
the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
are formatted without a Date round-trip. The dashboard's YTD window was
built by round-tripping a local Date through toISOString(), which shifted
it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
casts, so an unknown value reached column headers as `undefined` and the
Content-Disposition filename. Parsed against the label maps now, with the
filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
the whole node and the expand control stops responding.
UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
content off-screen with no mobile navigation at all. The sidebar is now a
drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
roles called out, expand control moved to the bottom edge carrying the
child count.
- Pagination is windowed; it previously rendered one link per page (54 for
the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
person, reusing the chart's existing search-match highlighting.
Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.