diff --git a/docs/operations.md b/docs/operations.md index 3a55edb..03f3ac7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,6 +21,12 @@ The DB is **shared** (edifice + hal). Each repo holds only its own migration fil hand-typed-timestamp bug; always use `migration new`. - **`execute_sql` (MCP) is read/debug only — never DDL.** - Any inline MCP `ALTER` must be mirrored as a committed migration file in the **same session**. +- **Verifying the `auth.users` provisioning trigger** (`provision_workspace_membership()`): run + the 6 numbered blocks in `scripts/verify_provisioning_trigger.sql` via `execute_sql`, one at a + time. Each is a self-contained, rolled-back, self-asserting scenario (reports `PASS`/`FAIL`, + zero residue) — pure DML, so it complies with the read/debug-only rule above. Run this after + any change to `provision_workspace_membership()`, `trg_provision_workspace_membership`, + `workspace_members`, or `halcrm_workspaces.company_id`. --- diff --git a/scripts/verify_provisioning_trigger.sql b/scripts/verify_provisioning_trigger.sql new file mode 100644 index 0000000..c1e0187 --- /dev/null +++ b/scripts/verify_provisioning_trigger.sql @@ -0,0 +1,287 @@ +-- Verification checklist for provision_workspace_membership() — the SECURITY DEFINER +-- trigger on auth.users that auto-provisions workspace_members rows on invite (#63). +-- Run with: paste each numbered BEGIN … ROLLBACK block below into the Supabase MCP +-- execute_sql tool against zgkvbjqlvebttbnkklpo (or the dashboard SQL editor), one +-- block at a time. Each block is self-contained, self-asserting (RAISE NOTICE 'PASS …' +-- on success / RAISE EXCEPTION 'FAIL …' on a broken invariant) and rolls itself back — +-- zero residue on prod regardless of outcome. A RAISE EXCEPTION already aborts the +-- transaction, so the trailing ROLLBACK is what runs on the PASS path. +-- +-- Covers one failure mode this trigger has already hit in prod — the 42P17 "WHEN cannot +-- reference OLD" class (guard now lives in the function body, fixed in commit f3920ab) — +-- plus the is_default partial-unique-index collision (latent per issue #61, never +-- reproduced; hardened via the #61 UNIQUE constraint) and the three non-firing / +-- idempotency paths. Does NOT cover #62 (the malformed-company_id cast guard / WHEN-clause +-- regex) — no scenario here exercises a non-UUID company_id string. +-- +-- Fixtures are synthetic and greppable, never real users (Yani, Steeve, Renaud): +-- user_id 00000000-0000-4000-8000-00000000000{1..6} +-- email verify-s{1..6}@test.hal.internal +-- Real company fixtures — each must resolve to exactly one halcrm_workspaces row +-- (halcrm_workspaces.company_id is UNIQUE since 20260722202028): +-- blue-green 26a1de94-133c-4c8c-a67c-75071c0e68ab +-- ic-ingenieurs-conseils 8323074d-9f38-4d6a-8867-0bb5eb249114 +-- +-- instance_id uses the standard Supabase default 00000000-0000-0000-0000-000000000000. +-- If the auth.users INSERTs fail loud with an FK / NOT NULL error, check this project's +-- actual value first: SELECT DISTINCT instance_id FROM auth.users LIMIT 5; + + +-- ============================================================================ +-- Scenario 1: Fresh INSERT with company_id present (happy path). +-- Expect: exactly one workspace_members row (blue-green, ...0001, member, is_default=true). +-- ============================================================================ +BEGIN; + +INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) +VALUES ('00000000-0000-4000-8000-000000000001', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s1@test.hal.internal', + jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab')); + +DO $$ +DECLARE + v_count int; + v_default boolean; + v_role text; +BEGIN + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000001'; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL scenario 1: expected 1 membership, got %', v_count; + END IF; + + SELECT is_default, role INTO v_default, v_role + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000001' + AND workspace_slug = 'blue-green'; + IF v_default IS NOT TRUE OR v_role <> 'member' THEN + RAISE EXCEPTION 'FAIL scenario 1: expected (blue-green, member, is_default=true), got (%, is_default=%)', + v_role, v_default; + END IF; + + RAISE NOTICE 'PASS scenario 1: fresh INSERT provisioned blue-green (member, is_default=true)'; +END $$; + +ROLLBACK; + + +-- ============================================================================ +-- Scenario 2: company_id set only on a later UPDATE (the real auth-gateway 2-step path). +-- Step 1 INSERT with empty meta must NOT fire (WHEN gates on a non-null company_id); +-- step 2 UPDATE that first sets company_id must provision exactly one ic membership. +-- ============================================================================ +BEGIN; + +INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) +VALUES ('00000000-0000-4000-8000-000000000002', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s2@test.hal.internal', + '{}'::jsonb); + +DO $$ +DECLARE + v_count int; +BEGIN + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000002'; + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL scenario 2 (step 1): empty-meta INSERT should provision nothing, got %', v_count; + END IF; +END $$; + +UPDATE auth.users + SET raw_app_meta_data = jsonb_build_object('company_id', '8323074d-9f38-4d6a-8867-0bb5eb249114') + WHERE id = '00000000-0000-4000-8000-000000000002'; + +DO $$ +DECLARE + v_count int; +BEGIN + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000002' + AND workspace_slug = 'ic-ingenieurs-conseils'; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL scenario 2 (step 2): expected 1 ic-ingenieurs-conseils membership, got %', v_count; + END IF; + + RAISE NOTICE 'PASS scenario 2: UPDATE-after-INSERT provisioned ic-ingenieurs-conseils membership'; +END $$; + +ROLLBACK; + + +-- ============================================================================ +-- Scenario 3: is_default guard when the user already holds a default elsewhere. +-- The user must exist in auth.users before a workspace_members row can reference it +-- (FK), and provisioning must not fire before the pre-existing default is in place — +-- so the user is created with empty meta (no fire), the ic default is seeded, then +-- company_id is set via UPDATE. The new blue-green row must land is_default=false, and +-- the user must still hold exactly one is_default=true row (no unique-index violation). +-- ============================================================================ +BEGIN; + +INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) +VALUES ('00000000-0000-4000-8000-000000000003', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s3@test.hal.internal', + '{}'::jsonb); + +INSERT INTO workspace_members (workspace_slug, user_id, role, is_default) +VALUES ('ic-ingenieurs-conseils', '00000000-0000-4000-8000-000000000003', 'member', true); + +UPDATE auth.users + SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab') + WHERE id = '00000000-0000-4000-8000-000000000003'; + +DO $$ +DECLARE + v_new_default boolean; + v_default_count int; +BEGIN + SELECT is_default INTO v_new_default + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000003' + AND workspace_slug = 'blue-green'; + IF v_new_default IS DISTINCT FROM false THEN + RAISE EXCEPTION 'FAIL scenario 3: new blue-green row should be is_default=false, got %', v_new_default; + END IF; + + SELECT count(*) INTO v_default_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000003' + AND is_default; + IF v_default_count <> 1 THEN + RAISE EXCEPTION 'FAIL scenario 3: expected exactly one is_default=true row, got %', v_default_count; + END IF; + + RAISE NOTICE 'PASS scenario 3: is_default guard held — new row is_default=false, one default total'; +END $$; + +ROLLBACK; + + +-- ============================================================================ +-- Scenario 4: company_id resolves to no halcrm_workspaces row (finding M3). +-- The function must NOT abort the auth.users write and must create no membership; it +-- emits a RAISE WARNING instead. The synthetic company_id is a fresh gen_random_uuid() +-- (always canonical-dashed, so it passes the trigger's WHEN regex) that is asserted to +-- match nothing. Whole scenario lives in a DO block because the orphan id is dynamic. +-- Expect: alongside the PASS notice, a "WARNING: provision_workspace_membership: no +-- halcrm_workspaces row for company_id …" line in the execute_sql output. +-- ============================================================================ +BEGIN; + +DO $$ +DECLARE + v_orphan_company_id uuid := gen_random_uuid(); + v_count int; +BEGIN + IF EXISTS (SELECT 1 FROM halcrm_workspaces WHERE company_id = v_orphan_company_id) THEN + RAISE EXCEPTION 'FAIL scenario 4: generated orphan company_id % collided with a real workspace — rerun', v_orphan_company_id; + END IF; + + INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) + VALUES ('00000000-0000-4000-8000-000000000004', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s4@test.hal.internal', + jsonb_build_object('company_id', v_orphan_company_id::text)); + + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000004'; + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL scenario 4: orphan company_id should provision nothing, got % membership(s)', v_count; + END IF; + + RAISE NOTICE 'PASS scenario 4: orphan company_id did not abort the write and created no membership (expect a WARNING above)'; +END $$; + +ROLLBACK; + + +-- ============================================================================ +-- Scenario 5: idempotent re-run — the function re-fires against an already-existing +-- (workspace_slug, user_id) membership and must silently no-op (ON CONFLICT DO NOTHING), +-- no error, no duplicate. Clearing company_id to '{}' does not fire (WHEN needs non-null), +-- so setting it back is a genuine NULL→value change that re-invokes the function. +-- ============================================================================ +BEGIN; + +INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) +VALUES ('00000000-0000-4000-8000-000000000005', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s5@test.hal.internal', + '{}'::jsonb); + +UPDATE auth.users + SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab') + WHERE id = '00000000-0000-4000-8000-000000000005'; + +UPDATE auth.users + SET raw_app_meta_data = '{}'::jsonb + WHERE id = '00000000-0000-4000-8000-000000000005'; + +UPDATE auth.users + SET raw_app_meta_data = jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab') + WHERE id = '00000000-0000-4000-8000-000000000005'; + +DO $$ +DECLARE + v_count int; +BEGIN + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000005'; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL scenario 5: idempotent re-run should leave exactly 1 membership, got %', v_count; + END IF; + + RAISE NOTICE 'PASS scenario 5: re-firing against an existing membership no-oped silently (1 row, no duplicate)'; +END $$; + +ROLLBACK; + + +-- ============================================================================ +-- Scenario 6: a routine last_sign_in_at UPDATE must NOT re-invoke the trigger. +-- The trigger is AFTER INSERT OR UPDATE OF raw_app_meta_data, so touching any other +-- column does not even fire it. Provision one row at INSERT, then update only +-- last_sign_in_at; the membership count must stay exactly 1. +-- ============================================================================ +BEGIN; + +INSERT INTO auth.users (id, instance_id, aud, role, email, raw_app_meta_data) +VALUES ('00000000-0000-4000-8000-000000000006', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'verify-s6@test.hal.internal', + jsonb_build_object('company_id', '26a1de94-133c-4c8c-a67c-75071c0e68ab')); + +UPDATE auth.users + SET last_sign_in_at = now() + WHERE id = '00000000-0000-4000-8000-000000000006'; + +DO $$ +DECLARE + v_count int; +BEGIN + SELECT count(*) INTO v_count + FROM workspace_members + WHERE user_id = '00000000-0000-4000-8000-000000000006'; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL scenario 6: last_sign_in_at UPDATE must not re-provision, expected 1 membership, got %', v_count; + END IF; + + RAISE NOTICE 'PASS scenario 6: last_sign_in_at UPDATE did not re-invoke the trigger (1 row, unchanged)'; +END $$; + +ROLLBACK;