Files
alpenwerk-hr/db/migrations/20260731090000_app_upsert_user.sql
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
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>
2026-09-07 10:43:22 +02:00

118 lines
4.4 KiB
PL/PgSQL

-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
-- anzulegen — bevor es einen Sitzungskontext gibt.
--
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
--
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
create or replace function app_upsert_user(
p_external_id text,
p_email text,
p_full_name text
)
returns uuid
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
if p_external_id is null or btrim(p_external_id) = '' then
raise exception 'Externe Kennung fehlt.';
end if;
if p_email is null or btrim(p_email) = '' then
raise exception 'E-Mail-Adresse fehlt.';
end if;
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
-- hängen Notizen, Entwürfe und Protokolleinträge.
update app_users
set email = p_email,
full_name = coalesce(p_full_name, full_name),
last_seen_at = now()
where external_id = p_external_id
returning id into v_id;
if v_id is not null then
return v_id;
end if;
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
--
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
select p.id into v_id
from profiles p
where lower(p.email) = lower(p_email)
limit 1;
v_id := coalesce(v_id, gen_random_uuid());
begin
insert into app_users (id, external_id, email, full_name, last_seen_at)
values (v_id, p_external_id, p_email, p_full_name, now());
exception
when unique_violation then
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
-- findet die Zeile, die die erste gerade angelegt hat.
select id into v_id from app_users where external_id = p_external_id;
if v_id is null then
raise;
end if;
end;
return v_id;
end;
$$;
comment on function app_upsert_user(text, text, text) is
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
-- kann dort also nichts anlegen, ändern oder löschen.
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_upsert_user(text, text, text) to %I', r);
end if;
end loop;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
-- weg" zeigt.
do $$
declare
v_first uuid;
v_second uuid;
v_probe text := 'probe-' || gen_random_uuid()::text;
begin
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
if v_first is distinct from v_second then
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
end if;
delete from app_users where id = v_first;
end;
$$;