Let a second person be activated at all

profiles.id referenced auth.users. Sign-in goes through Auth.js now and
creates nothing there, so a new colleague could sign in, receive an
app_users row, and then be impossible to authorise: the profiles row needed
to grant HR access could not be inserted. She would see "Kein HR-Zugriff"
with no way to change it.

All eight foreign keys in the public schema now point at app_users, walked
from the catalogue rather than written out — their names come from different
migrations and one transcribed wrongly means it silently stays behind. The
delete behaviour is preserved: profiles still cascades from the account,
audit and note fields do not, because an entry must not vanish when an
account is removed.

Every referenced value was already present in app_users, so nothing moved;
only the guarantee changed. A backfill from profiles runs first anyway, for
copies of this database where someone created something in between.

The check at the end does the thing that matters: it creates a second
account with a profile and removes it again. Counting constraints would have
passed while the actual case still failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 12:15:27 +02:00
parent 0d8af7cdf0
commit 27cfd6431f

View File

@@ -0,0 +1,104 @@
-- Die Fremdschlüssel von auth.users auf app_users umhängen.
--
-- Solange profiles.id auf auth.users zeigt, lässt sich **keine zweite Person
-- freischalten**. Die Anmeldung läuft über Auth.js und legt dort nichts mehr
-- an; wer sich neu anmeldet, bekommt eine app_users-Zeile, aber die dazu
-- nötige profiles-Zeile scheitert am Fremdschlüssel. Das Ergebnis wäre
-- „Kein HR-Zugriff" ohne Möglichkeit, es zu ändern.
--
-- Damit ist das hier keine Aufräumarbeit, sondern die Voraussetzung dafür,
-- dass die Anwendung mehr als eine Person bedienen kann.
--
-- Vorher geprüft: jeder referenzierte Wert steht bereits in app_users. Die
-- Umhängung ändert also keine Daten, nur die Zusicherung.
-- ═══ 1. Sicherheitsnetz ══════════════════════════════════════════
-- Was in app_users fehlt, wird aus profiles ergänzt. Im geprüften Bestand
-- ist das leer; die Migration soll aber auch auf einer Kopie laufen, in der
-- jemand zwischenzeitlich etwas angelegt hat.
insert into app_users (id, external_id, email, full_name)
select p.id, 'legacy:' || p.id::text, p.email, p.full_name
from profiles p
where not exists (select 1 from app_users a where a.id = p.id)
on conflict (id) do nothing;
-- ═══ 2. Umhängen ═════════════════════════════════════════════════
-- Über den Katalog statt acht handgeschriebene Anweisungen: die Namen der
-- Zwänge stammen aus verschiedenen Migrationen, und einer davon von Hand
-- falsch abgeschrieben hiesse, dass er stehen bleibt.
do $$
declare
r record;
v_delete text;
begin
for r in
select k.conname, c.relname as tabelle, a.attname as spalte, k.confdeltype
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any(k.conkey)
where k.contype = 'f'
and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
)
loop
-- Das Löschverhalten bleibt, wie es war: profiles hängt kaskadierend am
-- Konto, die Protokoll- und Notizfelder nicht — dort soll ein Eintrag
-- gerade nicht verschwinden, weil ein Konto entfernt wird.
v_delete := case r.confdeltype when 'c' then ' on delete cascade' else '' end;
execute format('alter table %I drop constraint %I', r.tabelle, r.conname);
execute format('alter table %I add constraint %I foreign key (%I) references app_users(id)%s',
r.tabelle, r.conname, r.spalte, v_delete);
raise notice '%.% -> app_users%', r.tabelle, r.spalte, v_delete;
end loop;
end;
$$;
-- ═══ 3. Gegenprobe ═══════════════════════════════════════════════
do $$
declare
v_offen int;
v_neu int;
begin
select count(*) into v_offen
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
);
if v_offen > 0 then
raise exception '% Fremdschlüssel zeigen weiterhin auf auth.users.', v_offen;
end if;
select count(*) into v_neu
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = 'app_users'::regclass;
if v_neu < 8 then
raise exception 'Nur % Fremdschlüssel zeigen auf app_users — erwartet mindestens 8.', v_neu;
end if;
-- Und die eigentliche Frage: lässt sich jetzt eine zweite Person anlegen?
-- Geprüft und wieder entfernt, damit die Migration keine Daten hinterlässt.
declare
v_id uuid := gen_random_uuid();
begin
insert into app_users (id, external_id, email, full_name)
values (v_id, 'probe:' || v_id::text, 'probe@example.invalid', 'Probe');
insert into profiles (id, email, full_name, role, is_active)
values (v_id, 'probe@example.invalid', 'Probe', 'hr', false);
delete from profiles where id = v_id;
delete from app_users where id = v_id;
end;
end;
$$;