Let an entry be taken back, along with what it did

HR can now delete a history entry, but only where deleting one is an
honest thing to do — and deleting it also undoes it.

The rule they asked for is the interesting part: the last valid change
wins. Deleting an entry walks its fields one at a time. If a later entry
touched the same field, the current value stays — that later change is
the one in force. Otherwise the field goes back to what the deleted
entry recorded as its "before". So the middle of three entries can be
removed without an old value overwriting a newer one.

Four kinds of entry refuse to be deleted, each saying why in the place
the button would have been. Eintritt anchors the timeline. Transfers,
promotions, absences and exits moved positions and status — they have
proper operations for that, and guessing backwards is how you corrupt an
org chart. Anything not yet effective hangs off a planned change, and
that link is not trustworthy: there is no key between a history row and
its pending row, only a person and a date, and the data already has an
Eintritt and a Vertragsänderung sharing one. Matching on the date would
eventually cancel a change nobody meant. And entries from before the
history carried values have nothing to fall back to.

Confirmation is not "are you sure" — that question gets a reflex yes by
the third time. The dialog says what will be different afterwards: which
field goes back to which value, and which one stays because something
later claimed it.

employee_history keeps its append-only policies; delete_history_entry is
SECURITY DEFINER and checks the permission itself in its first line. The
audit log keeps the deletion with the values that were removed, and the
audit log genuinely cannot be edited.

The rule lives twice — in SQL and in lib/history.ts. The database is the
authority; the copy exists so the UI can hide a button that would fail
and print the reason instead. Rehearsed against real data in a
rolled-back transaction first: the later change held, the untouched
field reverted, all four refusals fired.

Also corrected in the data catalogue: I had written that
require_hr_admin was called by nothing. It guards all sixteen mutating
functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:56:48 +02:00
parent 5f50cb97f3
commit 6297288c13
10 changed files with 696 additions and 24 deletions

View File

@@ -21,7 +21,7 @@ kommt.
| Spalten | 142 |
| Aufzählungstypen | 10 |
| Sichten (Views) | 0 |
| Eigene SQL-Funktionen | 35 (plus 31 aus der Erweiterung `pg_trgm`) |
| Eigene SQL-Funktionen | 36 (plus 31 aus der Erweiterung `pg_trgm`) |
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
---
@@ -51,6 +51,12 @@ kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
geänderte Zeile.
Eine Ausnahme gibt es, und sie ist eng: `delete_history_entry` nimmt eine
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
zurück. Die Policies bleiben dabei unangetastet — die Funktion läuft als
`SECURITY DEFINER` an ihnen vorbei und prüft 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
@@ -311,6 +317,12 @@ Transaktion mit.
**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. Der
einzige Weg an der fehlenden `delete`-Policy vorbei, deshalb `SECURITY
DEFINER` und mit `require_hr_admin()` davor.
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
`add_employee_note`, `complete_employee_note`
@@ -326,19 +338,25 @@ wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
die vier `fn_*`-Trigger, `is_valid_svnr`
Vier Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
Fünf 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`. 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.
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes` und
`delete_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
fünfte muss löschen können, wo es absichtlich keine `delete`-Policy gibt —
und prüft die Berechtigung deshalb selbst, in ihrer ersten Zeile.
**Übrig geblieben:** `generate_company_email` und `is_hr_admin` /
`require_hr_admin` stehen noch in der Datenbank, werden aber von nichts mehr
gerufen. Die E-Mail-Erzeugung stammt aus der Zeit, als eine Firmenadresse
automatisch vergeben wurde; heute ist `employees.email` die private Adresse
und freiwillig. Die Admin-Prüfungen stammen aus einem Rollenmodell, das es
nicht mehr gibt.
**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.
---