Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
19 KiB
Markdown
422 lines
19 KiB
Markdown
# Datenkatalog
|
||
|
||
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
|
||
Datenbank am **13.08.2026**.
|
||
|
||
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
|
||
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
|
||
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
|
||
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
|
||
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
|
||
kommt.
|
||
|
||
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
|
||
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
|
||
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
|
||
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
|
||
|
||
| | |
|
||
|---|---|
|
||
| Tabellen | 15 |
|
||
| Spalten | 142 |
|
||
| Aufzählungstypen | 10 |
|
||
| Sichten (Views) | 0 |
|
||
| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) |
|
||
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
|
||
|
||
---
|
||
|
||
## Wie das Modell gedacht ist
|
||
|
||
Drei Entscheidungen erklären fast jede Eigenheit weiter unten.
|
||
|
||
**Person und Planstelle sind zwei Dinge.** Eine Person (`employees`) hat
|
||
keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (`om_positions`),
|
||
und die Planstelle hängt in einer Organisationseinheit (`org_units`). Wo
|
||
jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei
|
||
Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle
|
||
ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass
|
||
die Stelle verschwindet.
|
||
|
||
**Zuordnungen haben ein Von und ein Bis.** `position_assignments`,
|
||
`om_positions` und `org_units` tragen alle `valid_from` / `valid_to` als
|
||
halboffenes Intervall: `valid_from` gehört dazu, `valid_to` nicht mehr. Eine
|
||
Zuordnung, die heute endet, hat `valid_to = heute` und gilt heute schon nicht
|
||
mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der
|
||
Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats
|
||
kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
|
||
|
||
**Geschichte wird geschrieben, nicht überschrieben.** `employee_history` und
|
||
`audit_log` haben keine Update- und keine Delete-Policy — RLS lässt nur
|
||
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
|
||
geänderte Zeile.
|
||
|
||
Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine
|
||
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
|
||
zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die
|
||
Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an
|
||
ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den
|
||
Vorgang, dort verschwindet nichts.
|
||
|
||
Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der
|
||
SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und
|
||
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
|
||
`is_chief` entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt,
|
||
findet sich wieder; wer nicht, verliert nichts.
|
||
|
||
```
|
||
org_units ──parent_id──┐ (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
|
||
│ │
|
||
└──────────────────┘
|
||
▲
|
||
│ org_unit_id
|
||
om_positions ──job_id──► jobs
|
||
▲
|
||
│ position_id
|
||
position_assignments ──employee_id──► employees ──location_id──► locations
|
||
▲
|
||
├── employee_history (Ereignisse)
|
||
├── employee_dependents (Angehörige)
|
||
├── employee_notes (HR-Notizen)
|
||
├── pending_org_changes (wirkt später)
|
||
└── audit_log (wer/wann/was)
|
||
|
||
app_users ──1:1──► profiles (Anmeldung ist nicht Berechtigung)
|
||
```
|
||
|
||
---
|
||
|
||
## Personen
|
||
|
||
### `employees` — 46 Spalten
|
||
|
||
Die Person selbst: Stammdaten, Vertrag, Status. **Nicht** die Organisation —
|
||
die kommt über die Planstelle.
|
||
|
||
| Spalte | Typ | Null | Vorgabe | Bedeutung |
|
||
|---|---|---|---|---|
|
||
| `id` | uuid | – | `gen_random_uuid()` | Schlüssel |
|
||
| `personnel_number` | int4 | – | – | **Eindeutig.** Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen |
|
||
| `first_name`, `last_name` | text | – | – | |
|
||
| `gender` | `gender_type` | – | – | `m` / `w` |
|
||
| `birth_date` | date | – | – | |
|
||
| `sv_nummer` | text | ja | – | Österreichische SV-Nummer; gegen Prüfziffer *und* Geburtsdatum geprüft (Trigger `fn_validate_employee_svnr`) |
|
||
| `nationality` | text | – | `'Österreich'` | |
|
||
| `address`, `postal_code`, `city`, `address_country` | text | ja | – | Wohnanschrift |
|
||
| `email` | text | ja | – | **Private** E-Mail. Freiwillig, aber eindeutig, wenn angegeben |
|
||
| `phone` | text | ja | – | **Private** Telefonnummer. Freiwillig |
|
||
| `job_title` | text | – | – | Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle |
|
||
| `location_id` | uuid | – | – | → `locations` |
|
||
| `employment_type` | `employment_type` | – | `Vollzeit` | |
|
||
| `weekly_hours` | numeric | – | `38.5` | An `employment_type` gekoppelt, siehe Regeln |
|
||
| `contract_type` | `contract_type` | – | `unbefristet` | |
|
||
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
|
||
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
|
||
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
|
||
| `paygrade` | `paygrade_type` | – | `B` | A–F |
|
||
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
|
||
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
|
||
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
|
||
| `entry_date` | date | – | – | |
|
||
| `exit_date`, `exit_reason` | date/text | ja | – | |
|
||
| `karenz_start_date`, `karenz_return_date` | date | ja | – | Laufende Langzeitabwesenheit |
|
||
| `absence_type` | text | ja | – | Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte |
|
||
| `is_betriebsrat`, `is_laterale_fuehrung`, `is_c_level` | bool | – | `false` | |
|
||
| `has_dienstwagen` | bool | – | `false` | |
|
||
| `dienstwagen_art` | text | ja | – | `Verbrenner` / `Elektro`; nur zusammen mit `has_dienstwagen` |
|
||
| `emergency_contact_name`, `_phone`, `_relation` | text | ja | – | Daten einer dritten Person, nur für den Notfall erhoben |
|
||
| `title_prefix`, `title_suffix` | text[] | – | `{}` | Akademische Grade, gegen feste Listen geprüft |
|
||
| `avatar_color` | text | ja | – | Darstellung |
|
||
| `monthly_salary_gross` | numeric | ja | – | **Stillgelegt.** Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind |
|
||
| `created_at`, `updated_at` | timestamptz | – | `now()` | `updated_at` per Trigger |
|
||
|
||
**Regeln, die die Datenbank durchsetzt:**
|
||
|
||
- *Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als
|
||
38,5.* Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern
|
||
(`chk_weekly_hours`).
|
||
- *Befristet ohne Enddatum gibt es nicht* (`chk_befristet_end`).
|
||
- *Austritt nicht vor Eintritt*, *Rückkehr nicht vor Eintritt*
|
||
(`chk_exit_after_entry`, `chk_karenz_return_after_entry`).
|
||
- *Dienstwagen und Antriebsart gehören zusammen* — beides oder keins
|
||
(`chk_dienstwagen_art`). Die Bedingung nennt den Fall ohne Wagen
|
||
ausdrücklich, weil `art in (…)` bei `null` weder wahr noch falsch ergibt
|
||
und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
|
||
- *Notfallkontakt: Name und Telefon gemeinsam oder gar nicht*, und keiner der
|
||
beiden leer (`chk_emergency_contact`).
|
||
- *Arbeitstage nur aus Mo–So und mindestens einer* (`chk_work_days_valid`).
|
||
- *Titel nur aus den bekannten Listen* — 10 vorangestellte, 12 nachgestellte
|
||
(`chk_title_prefix_valid`, `chk_title_suffix_valid`).
|
||
- *Abwesenheitsart nur aus den 13 bekannten* (`chk_absence_type`).
|
||
|
||
### `employee_history` — die Zeitleiste
|
||
|
||
Eine Zeile je Ereignis: `employee_id`, `event_date`, `event_type`
|
||
(Aufzählung, 11 Werte), `description`. Ein Trigger verhindert Ereignisse vor
|
||
dem Eintrittsdatum (`fn_check_history_not_before_entry`). Nur einfügen und
|
||
lesen — kein Ändern, kein Löschen.
|
||
|
||
### `employee_dependents` — Angehörige
|
||
|
||
`first_name`, `last_name`, `relationship` (Ehepartner:in / Lebenspartner:in /
|
||
Kind / Sonstige), `birth_date`, optional `sv_nummer`. Ändern heisst löschen
|
||
und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person
|
||
verschwinden sie mit (`on delete cascade`).
|
||
|
||
### `employee_notes` — HR-Notizen
|
||
|
||
`category` (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage /
|
||
Lob / Anerkennung), `note_text`, optional `due_date` für die Wiedervorlage,
|
||
dazu `done` / `done_at` / `done_by`.
|
||
|
||
Bewusst **nicht** auf die verfassende Person eingeschränkt: jede aktive
|
||
HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames
|
||
Postfach, kein privates.
|
||
|
||
---
|
||
|
||
## Organisation
|
||
|
||
### `org_units` — Einheiten (SAP-Objekttyp O)
|
||
|
||
`org_number` (eindeutig), `name`, `parent_id` (rekursiv), `unit_type`
|
||
(Gesellschaft / Bereich / Abteilung / Team), `valid_from` / `valid_to`.
|
||
|
||
`unit_type` ist ein Etikett für die Anzeige, keine Struktur — die Struktur
|
||
ist `parent_id`. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die
|
||
Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird
|
||
(`chk_org_unit_not_own_parent`); tiefere Zyklen fängt sie nicht ab.
|
||
|
||
### `jobs` — Tätigkeiten (Objekttyp C)
|
||
|
||
`code` und `title`, beide eindeutig. Ein schlanker Katalog: die Planstelle
|
||
verweist darauf, statt den Titel abzuschreiben.
|
||
|
||
### `om_positions` — Planstellen (Objekttyp S)
|
||
|
||
`position_number` (eindeutig), `org_unit_id`, `job_id`, `is_chief`,
|
||
`valid_from` / `valid_to`.
|
||
|
||
Die Nummern haben die Form `6` + sieben Ziffern, weil
|
||
`next_position_number()` sie so erzeugt — erzwungen wird das Format aber
|
||
nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer
|
||
einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie
|
||
dann (sie sucht ihr Maximum nur unter `^6[0-9]{7}$`).
|
||
|
||
`is_chief` markiert die Leitungsstelle einer Einheit — daraus entsteht die
|
||
Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine
|
||
Planstelle lässt sich nur besetzen, solange sie gültig ist.
|
||
|
||
### `position_assignments` — Besetzungen (Verknüpfung A008)
|
||
|
||
`position_id`, `employee_id`, `valid_from` / `valid_to`. Diese Tabelle
|
||
beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.
|
||
|
||
### `locations` — Standorte
|
||
|
||
`name` (eindeutig) und `country`, beschränkt auf Österreich, Deutschland,
|
||
Tschechien und Slowenien.
|
||
|
||
---
|
||
|
||
## Ablauf und Nachweis
|
||
|
||
### `pending_org_changes` — was später wirkt
|
||
|
||
`change_type` (transfer / promotion / karenz_start / karenz_return /
|
||
contract_change / reorg / dependent_add / dependent_remove),
|
||
`effective_date`, `payload` (JSONB), `status` (pending / applied /
|
||
cancelled).
|
||
|
||
Der Weg ist `pending` → `applied`. Die Auswahl filtert immer auf `pending`,
|
||
ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von
|
||
`apply_due_pending_changes()`.
|
||
|
||
### `audit_log` — wer, wann, was, an wem
|
||
|
||
`occurred_at`, `actor_user_id` + `actor_name`, `action`, `target_label`,
|
||
`target_employee_id`, `details`, und `changes` als JSONB in der Form
|
||
`[{feld, vorher, nachher}]` — daher die aufklappbare Detailansicht in der
|
||
Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist `changes`
|
||
null.
|
||
|
||
Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben
|
||
Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener
|
||
Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung
|
||
ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht
|
||
und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.
|
||
|
||
Dass `actor_name` als Text mitgeschrieben wird und nicht nur die Kennung: der
|
||
Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
|
||
|
||
---
|
||
|
||
## Zugang
|
||
|
||
### `app_users`
|
||
|
||
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
|
||
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
|
||
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.
|
||
|
||
### `profiles`
|
||
|
||
Eine Zeile je Konto, gleicher Schlüssel wie `app_users`. `role` ist per
|
||
Prüfbedingung auf den einen Wert `'hr'` festgenagelt, `is_active` steht
|
||
anfangs auf `false`.
|
||
|
||
**Anmelden können heisst nichts.** Wer sich mit dem Firmenkonto anmeldet,
|
||
bekommt eine `app_users`-Zeile und kommt trotzdem an keine einzige
|
||
Personalzeile, solange `profiles.is_active` nicht gesetzt ist. Die Freigabe
|
||
ist ein bewusster zweiter Schritt.
|
||
|
||
### `hire_drafts`, `saved_reports`
|
||
|
||
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
|
||
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
|
||
eingeschränkt.
|
||
|
||
---
|
||
|
||
## Aufzählungstypen
|
||
|
||
| Typ | Werte |
|
||
|---|---|
|
||
| `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten |
|
||
| `employment_type` | Vollzeit, Teilzeit |
|
||
| `contract_type` | unbefristet, befristet |
|
||
| `worker_type` | Angestellte:r, Arbeiter:in |
|
||
| `collective_agreement` | Handel, Süßwaren |
|
||
| `paygrade_type` | A, B, C, D, E, F |
|
||
| `source_type` | Intern, Extern |
|
||
| `gender_type` | m, w |
|
||
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
|
||
| `history_event_type` | Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr |
|
||
|
||
`Karenz` heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte
|
||
Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt
|
||
dem neuen Begriff.
|
||
|
||
Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte
|
||
gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen,
|
||
Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine
|
||
Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur
|
||
erweitern.
|
||
|
||
---
|
||
|
||
## Die SQL-Schnittstelle
|
||
|
||
Änderungen laufen nicht über `insert`/`update` aus der Anwendung, sondern
|
||
über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben
|
||
Transaktion mit.
|
||
|
||
**Personal:** `hire_employee`, `rehire_employee`, `terminate_employee`,
|
||
`transfer_employee`, `promote_employee`, `change_employee_data`,
|
||
`start_karenz`, `adjust_karenz_return`, `record_karenz_return`
|
||
|
||
**Planstellen:** `create_position`, `update_position`, `delete_position`,
|
||
`next_position_number`
|
||
|
||
**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder
|
||
Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein
|
||
späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile.
|
||
`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher"
|
||
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
|
||
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
|
||
`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit
|
||
`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung
|
||
Beschriftung → Spalte und Typ.
|
||
|
||
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
|
||
`add_employee_note`, `complete_employee_note`
|
||
|
||
**Auswertung:** `om_reporting_lines(p_as_of)` — löst zum Stichtag auf, wer an
|
||
wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
|
||
`formal_manager_id`)
|
||
|
||
**Zugang und Nachweis:** `is_hr_user`, `app_current_user_id`,
|
||
`app_upsert_user`, `current_actor_name`, `app_aenderung`,
|
||
`app_aenderungsfelder`
|
||
|
||
**Automatik:** `apply_due_pending_changes` (täglich), `rls_auto_enable`
|
||
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
|
||
die vier `fn_*`-Trigger, `is_valid_svnr`
|
||
|
||
Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
|
||
Eigentümerin statt der aufrufenden Person: `is_hr_user`,
|
||
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`,
|
||
`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es
|
||
sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten
|
||
sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja
|
||
nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben,
|
||
wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide
|
||
prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile.
|
||
|
||
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
|
||
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
|
||
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
|
||
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
|
||
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
|
||
|
||
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
|
||
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
|
||
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
|
||
private Adresse und freiwillig.
|
||
|
||
---
|
||
|
||
## Zugriffsschutz
|
||
|
||
Auf **jeder** der 15 Tabellen ist Row Level Security aktiv, zusammen 21
|
||
Policies. Fast alle prüfen dasselbe: `is_hr_user()` — also `profiles.role =
|
||
'hr'` **und** `is_active`. Nachgereicht wird das nicht: der Ereignis-Trigger
|
||
`ensure_rls` schaltet RLS bei jeder neu angelegten Tabelle sofort ein.
|
||
|
||
Die Prüfung hängt an einer Sitzungsvariablen (`app.user_id`), die
|
||
`withUser()` als erste Anweisung jeder Transaktion setzt — transaktionslokal,
|
||
damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus
|
||
dem Pool mit fremder Kennung läuft.
|
||
|
||
Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung
|
||
lief ohne Sitzungskontext, und **jede** Tabelle lieferte null Zeilen — bei
|
||
vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler,
|
||
sondern nichts. Die Anwendung verbindet sich ausserdem als `alpenwerk_app` —
|
||
eine Rolle ohne `BYPASSRLS` und ohne Superuser-Recht; es gibt also keinen Weg
|
||
daran vorbei, auch nicht versehentlich.
|
||
|
||
Wo das Muster abweicht:
|
||
|
||
- `audit_log` und `employee_history` haben je zwei Policies — lesen und
|
||
einfügen, getrennt, und kein Ändern oder Löschen. Das ist die
|
||
Unveränderlichkeit, technisch durchgesetzt.
|
||
- `profiles` hat vier, weil dort auch die Freischaltung anderer Konten
|
||
passiert — und eine davon lässt jede Person die *eigene* Zeile lesen, auch
|
||
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
|
||
hineinkommt.
|
||
- `app_users` ebenso: die eigene Zeile oder HR.
|
||
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
|
||
anfragenden Person gehört.
|
||
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
|
||
Male dasselbe.
|
||
|
||
---
|
||
|
||
## Was hier nicht steht
|
||
|
||
**Gehalt.** `monthly_salary_gross` ist stillgelegt und wird von keiner
|
||
Funktion mehr angefasst. Gehaltsdaten leben in Loga.
|
||
|
||
**Zeitwirtschaft.** Kommen und Gehen, Urlaubskonten, Krankenstände als
|
||
Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte
|
||
Langzeitabwesenheit, weil sie die Führungslinie verschiebt.
|
||
|
||
**Bewerbungen.** Eine offene Planstelle ist hier eine Planstelle ohne
|
||
Besetzung, mehr nicht.
|
||
|
||
---
|
||
|
||
*Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen
|
||
Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu
|
||
aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.*
|