The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.5 KiB
PL/PgSQL
192 lines
7.5 KiB
PL/PgSQL
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
|
|
-- eigener Sitzungskontext.
|
|
--
|
|
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
|
|
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
|
|
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
|
|
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
|
|
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
|
|
--
|
|
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
|
|
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
|
|
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
|
|
-- bewegt, lässt sich nicht testen.
|
|
|
|
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
|
|
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
|
|
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
|
|
--
|
|
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
|
|
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
|
|
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
|
|
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
|
|
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
|
|
create or replace function app_current_user_id()
|
|
returns uuid
|
|
language plpgsql
|
|
stable
|
|
security definer
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
declare
|
|
v_id uuid;
|
|
begin
|
|
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
|
|
-- fehlt die Variable, kommt null statt eines Fehlers.
|
|
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
|
if v_id is not null then
|
|
return v_id;
|
|
end if;
|
|
|
|
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
|
|
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
|
|
-- auth.users.
|
|
begin
|
|
execute 'select auth.uid()' into v_id;
|
|
exception
|
|
when undefined_function or invalid_schema_name or undefined_table then
|
|
v_id := null;
|
|
end;
|
|
return v_id;
|
|
end;
|
|
$$;
|
|
|
|
comment on function app_current_user_id() is
|
|
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
|
|
|
|
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
|
|
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
|
|
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
|
|
-- liefe dort also nicht. Genau das soll sie aber.
|
|
do $$
|
|
declare r text;
|
|
begin
|
|
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
|
if exists (select 1 from pg_roles where rolname = r) then
|
|
execute format('grant execute on function app_current_user_id() to %I', r);
|
|
end if;
|
|
end loop;
|
|
end;
|
|
$$;
|
|
|
|
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
|
|
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
|
|
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
|
|
create table if not exists app_users (
|
|
id uuid primary key default gen_random_uuid(),
|
|
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
|
|
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
|
|
external_id text not null unique,
|
|
email text not null,
|
|
full_name text,
|
|
created_at timestamptz not null default now(),
|
|
last_seen_at timestamptz
|
|
);
|
|
|
|
comment on table app_users is
|
|
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
|
|
|
|
create index if not exists app_users_email_idx on app_users (lower(email));
|
|
|
|
alter table app_users enable row level security;
|
|
|
|
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
|
|
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
|
|
drop policy if exists "app_users_select_own" on app_users;
|
|
create policy "app_users_select_own" on app_users
|
|
for select using (id = app_current_user_id() or is_hr_user());
|
|
|
|
do $$
|
|
begin
|
|
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
execute 'grant select on table app_users to anon';
|
|
end if;
|
|
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
execute 'grant select on table app_users to authenticated';
|
|
end if;
|
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
execute 'grant all on table app_users to service_role';
|
|
end if;
|
|
end;
|
|
$$;
|
|
|
|
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
|
|
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
|
|
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
|
|
-- weiterhin is_hr_user() auf und merken davon nichts.
|
|
create or replace function is_hr_user()
|
|
returns boolean
|
|
language sql
|
|
security definer
|
|
set search_path = public, pg_temp
|
|
stable
|
|
as $$
|
|
select exists (
|
|
select 1 from profiles p
|
|
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
|
|
);
|
|
$$;
|
|
|
|
create or replace function current_hr_user_id()
|
|
returns uuid
|
|
language sql
|
|
security definer
|
|
set search_path = public, pg_temp
|
|
stable
|
|
as $$
|
|
select p.id from profiles p
|
|
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
|
|
$$;
|
|
|
|
create or replace function current_actor_name()
|
|
returns text
|
|
language sql
|
|
stable
|
|
set search_path = public, pg_temp
|
|
as $$
|
|
select coalesce(p.full_name, p.email, 'Unbekannt')
|
|
from profiles p where p.id = app_current_user_id();
|
|
$$;
|
|
|
|
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
|
|
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
|
|
drop policy if exists "profiles_select_own" on profiles;
|
|
create policy "profiles_select_own" on profiles
|
|
for select using (app_current_user_id() = id);
|
|
|
|
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
|
|
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
|
|
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
|
create policy "hire_drafts_owner" on hire_drafts
|
|
for all
|
|
using (created_by = app_current_user_id() and is_hr_user())
|
|
with check (created_by = app_current_user_id() and is_hr_user());
|
|
|
|
drop policy if exists "saved_reports_owner" on saved_reports;
|
|
create policy "saved_reports_owner" on saved_reports
|
|
for all
|
|
using (created_by = app_current_user_id() and is_hr_user())
|
|
with check (created_by = app_current_user_id() and is_hr_user());
|
|
|
|
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
|
|
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
|
|
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
|
|
-- Datenbank öffnet.
|
|
do $$
|
|
begin
|
|
perform set_config('app.user_id', '', true);
|
|
if is_hr_user() then
|
|
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
|
|
end if;
|
|
|
|
perform set_config('app.user_id', gen_random_uuid()::text, true);
|
|
if is_hr_user() then
|
|
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
|
|
end if;
|
|
|
|
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
|
|
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
|
|
perform set_config('app.user_id', '', true);
|
|
end;
|
|
$$;
|