Shuffle/supabase/migrations/20261102212208_auth_registration_rules.sql
cschlaefke 9f36cd0c33
All checks were successful
CI / flutter (pull_request) Successful in 1m3s
CI / web-build (pull_request) Successful in 54s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 34s
feat(db): reject U+200B in display names
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:51:56 +01:00

132 lines
5.9 KiB
PL/PgSQL

-- KTO-01 Registrierung: strengere Regeln beim Anlegen des Profils (Spec #1, § 3.2).
-- Nur additiv: bestehende Migrationen bleiben unverändert.
--
-- 1. display_name: kein Fallback auf das E-Mail-Präfix, kein stilles Kürzen; Länge 1-30 Codepoints
-- nach trim_ws (Dart-kompatibel); U+200B (Zero Width Space) ist nirgends erlaubt. Verstoß -> Registrierung scheitert mit 'display_name_invalid'.
-- 2. terms_version: Version der zugestimmten Rechtstexte (Nachweis); Pflicht für neue Konten.
-- 3. locale: Standard und Trigger-Fallback 'en' (nur 'de'/'en' erlaubt).
-- 4. Sync-Trigger: profiles.locale -> auth.users.raw_user_meta_data.locale (Mailsprache).
-- Trim wie Dart String.trim(): btrim() entfernt nur U+0020, Tab, Zeilenumbruch, NBSP usw. blieben
-- stehen. Explizite Liste (Unicode White_Space + BOM, identisch zur Dart-Doku):
-- U+0009-000D, 0020, 0085, 00A0, 1680, 2000-200A, 2028, 2029, 202F, 205F, 3000, FEFF.
-- Bewusst kein [[:space:]] (locale-abhängig). U+200B (Zero Width Space) ist kein Leerraum und
-- wird hier nicht getrimmt (wie in Dart); im Anzeigenamen ist er stattdessen verboten (siehe
-- profiles_display_name_no_zwsp).
-- immutable + strict (NULL -> NULL), search_path leer, kein security definer. Execute bleibt für
-- public: Der Check-Constraint unten wird mit den Rechten der schreibenden Rolle ausgewertet
-- (authenticated beim Profil-Update); die Funktion ist rein und greift auf keine Daten zu.
create or replace function public.trim_ws(value text)
returns text
language sql
immutable
strict
parallel safe
set search_path = ''
as $$
select pg_catalog.regexp_replace(
value,
'^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+'
|| '|' ||
'[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+$',
'',
'g'
);
$$;
-- Anzeigename: Leerraum zählt nicht. char_length zählt Zeichen (Codepoints), nicht Bytes.
-- Der Constraint wird direkt (ohne NOT VALID) angelegt: Er prüft sofort den Bestand und bricht die
-- Migration ab, falls ein vorhandener (Entwicklungs-)Name nur aus Leerraum besteht.
alter table public.profiles
add constraint profiles_display_name_not_blank
check (char_length(public.trim_ws(display_name)) between 1 and 30);
-- U+200B (Zero Width Space) ist im Anzeigenamen verboten, egal an welcher Stelle (Entscheidung
-- Chris): Er ist unsichtbar und ermöglicht scheinbar leere oder verwechselbare Namen. U+200C/U+200D
-- (ZWNJ/ZWJ) bleiben erlaubt (ZWJ steckt in Emoji-Sequenzen), U+FEFF wird bereits getrimmt.
-- Eigener Constraint mit klarem Namen; gilt auch für spätere Änderungen (KTO-02).
-- chr(8203) = U+200B; strpos statt Regex, damit nichts locale-abhängig ist.
alter table public.profiles
add constraint profiles_display_name_no_zwsp
check (strpos(display_name, chr(8203)) = 0);
-- Version der Rechtstexte. Nullable nur wegen vorhandener Entwicklungszeilen; für neue Konten
-- erzwingt handle_new_user() einen Wert. Die Spalte steht bewusst NICHT im Spalten-Grant
-- "update" von 20261102100100_profiles.sql: der Nutzer kann sie nicht ändern.
alter table public.profiles
add column terms_version text
check (terms_version is null or char_length(terms_version) between 1 and 50);
-- Ohne Angabe gilt Englisch (QUE-05); der Client sendet immer die aktive Sprache.
alter table public.profiles alter column locale set default 'en';
-- Profil beim Registrieren anlegen (ersetzt die Funktion aus 20261102100100_profiles.sql).
-- security definer nötig: Der Trigger läuft im Kontext von auth.users (Rolle supabase_auth_admin),
-- die Rolle hat keine Rechte auf public.profiles (RLS, keine Insert-Policy für Clients).
-- search_path ist leer, alle Objekte sind schema-qualifiziert.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb);
name text := case when jsonb_typeof(meta -> 'display_name') = 'string'
then public.trim_ws(meta ->> 'display_name') end;
version text := case when jsonb_typeof(meta -> 'terms_version') = 'string'
then public.trim_ws(meta ->> 'terms_version') end;
begin
if coalesce(meta ->> 'age_confirmed', '') <> 'true' then
raise exception 'age_confirmation_required';
end if;
if coalesce(meta ->> 'terms_accepted', '') <> 'true' then
raise exception 'terms_acceptance_required';
end if;
if version is null or char_length(version) not between 1 and 50 then
raise exception 'terms_version_required';
end if;
if name is null or char_length(name) not between 1 and 30 or strpos(name, chr(8203)) > 0 then
raise exception 'display_name_invalid';
end if;
insert into public.profiles
(id, display_name, locale, terms_version, terms_accepted_at, age_confirmed_at)
values (
new.id,
name,
case when meta ->> 'locale' in ('de', 'en') then meta ->> 'locale' else 'en' end,
version,
now(),
now()
);
return new;
end;
$$;
-- Mailsprache folgt dem Profil: GoTrue-Vorlagen sehen nur user_metadata (.Data.locale).
-- security definer nötig: Schreiben auf auth.users ist dem Client verwehrt. Der Trigger
-- ändert ausschließlich die Zeile des eigenen Nutzers (where id = new.id) und nur den Schlüssel
-- 'locale'. search_path ist leer.
create or replace function public.sync_profile_locale_to_auth()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
update auth.users
set raw_user_meta_data =
coalesce(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('locale', new.locale)
where id = new.id;
return new;
end;
$$;
revoke all on function public.sync_profile_locale_to_auth() from public, anon, authenticated;
create trigger sync_locale_to_auth
after update of locale on public.profiles
for each row
when (old.locale is distinct from new.locale)
execute function public.sync_profile_locale_to_auth();