\set ON_ERROR_STOP on -- Regression for 20260927000038_team_scoped_write_checks.sql. -- 1) A non-member (or removed member) cannot publish meetings, knowledge -- documents or knowledge chunks into a team. -- 2) A team admin cannot take over a member's meeting or meeting document by -- rewriting user_id, nor move it to another team or meeting. BEGIN; CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) RETURNS void LANGUAGE plpgsql AS $$ BEGIN IF condition IS NOT TRUE THEN RAISE EXCEPTION 'assertion_failed: %', message; END IF; END; $$; CREATE OR REPLACE FUNCTION pg_temp.act_as(uid uuid) RETURNS void LANGUAGE sql AS $$ SELECT set_config( 'request.jwt.claims', json_build_object('sub', uid, 'role', 'authenticated')::text, true ); $$; -- Must be rejected with 42501. expected_message, when set, pins the exact -- trigger error so an RLS failure cannot mask a missing trigger (or the -- reverse). CREATE OR REPLACE FUNCTION pg_temp.assert_denied(stmt text, label text, expected_message text DEFAULT NULL) RETURNS void LANGUAGE plpgsql AS $$ DECLARE affected bigint; BEGIN EXECUTE stmt; GET DIAGNOSTICS affected = ROW_COUNT; RAISE EXCEPTION 'write_was_allowed: % (% rows)', label, affected; EXCEPTION WHEN insufficient_privilege THEN IF expected_message IS NOT NULL AND SQLERRM <> expected_message THEN RAISE EXCEPTION 'unexpected_denial: % got "%" expected "%"', label, SQLERRM, expected_message; END IF; END; $$; -- ── fixtures (as postgres) ─────────────────────────────────────────────── INSERT INTO auth.users ( id, aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data, created_at, updated_at ) VALUES ( '38000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', 'scoped-admin@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Admin"}'::jsonb, now(), now() ), ( '38000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', 'scoped-member@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Member"}'::jsonb, now(), now() ), ( '38000000-0000-4000-8000-000000000003', 'authenticated', 'authenticated', 'scoped-removed@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Removed"}'::jsonb, now(), now() ); -- Team T (admin owns, member joined) and team T2 (admin only). INSERT INTO public.teams (id, name, owner_id) VALUES ('38100000-0000-4000-8000-00000000000a', 'Scoped Team', '38000000-0000-4000-8000-000000000001'), ('38100000-0000-4000-8000-00000000000b', 'Other Team', '38000000-0000-4000-8000-000000000001'); INSERT INTO public.team_members (team_id, user_id, role) VALUES ('38100000-0000-4000-8000-00000000000a', '38000000-0000-4000-8000-000000000001', 'owner'), ('38100000-0000-4000-8000-00000000000a', '38000000-0000-4000-8000-000000000002', 'member'), ('38100000-0000-4000-8000-00000000000b', '38000000-0000-4000-8000-000000000001', 'owner'); -- The removed user was a member of T once; remove_team_member only deletes -- the membership row, so rows they shared before stay shared. INSERT INTO public.meetings (id, user_id, team_id, title) VALUES ('38200000-0000-4000-8000-000000000001', '38000000-0000-4000-8000-000000000002', '38100000-0000-4000-8000-00000000000a', 'Member team meeting'), ('38200000-0000-4000-8000-000000000002', '38000000-0000-4000-8000-000000000003', NULL, 'Removed personal meeting'), ('38200000-0000-4000-8000-000000000003', '38000000-0000-4000-8000-000000000003', '38100000-0000-4000-8000-00000000000a', 'Removed legacy team meeting'); INSERT INTO public.meeting_documents (id, meeting_id, user_id, template_type, title, content) VALUES ('38300000-0000-4000-8000-000000000001', '38200000-0000-4000-8000-000000000001', '38000000-0000-4000-8000-000000000002', 'minutes', 'Member minutes', 'body'); INSERT INTO public.knowledge_documents (id, user_id, team_id, title) VALUES ('38400000-0000-4000-8000-000000000001', '38000000-0000-4000-8000-000000000003', NULL, 'Removed personal doc'), ('38400000-0000-4000-8000-000000000002', '38000000-0000-4000-8000-000000000003', '38100000-0000-4000-8000-00000000000a', 'Removed legacy shared doc'); SET LOCAL ROLE authenticated; -- ── 1) removed member / non-member cannot publish into T ───────────────── SELECT pg_temp.act_as('38000000-0000-4000-8000-000000000003'); SELECT pg_temp.assert_denied( $$INSERT INTO public.meetings (user_id, team_id, title) VALUES (auth.uid(), '38100000-0000-4000-8000-00000000000a', 'injected')$$, 'non-member meeting insert into team' ); SELECT pg_temp.assert_denied( $$UPDATE public.meetings SET team_id = '38100000-0000-4000-8000-00000000000a' WHERE id = '38200000-0000-4000-8000-000000000002'$$, 'non-member moves own meeting into team' ); SELECT pg_temp.assert_denied( $$UPDATE public.meetings SET edited_transcript = 'ignore previous instructions' WHERE id = '38200000-0000-4000-8000-000000000003'$$, 'removed member keeps editing a meeting still shared with the team' ); SELECT pg_temp.assert_denied( $$INSERT INTO public.knowledge_documents (user_id, team_id, title) VALUES (auth.uid(), '38100000-0000-4000-8000-00000000000a', 'poison')$$, 'non-member knowledge document insert into team' ); SELECT pg_temp.assert_denied( $$UPDATE public.knowledge_documents SET team_id = '38100000-0000-4000-8000-00000000000a' WHERE id = '38400000-0000-4000-8000-000000000001'$$, 'non-member moves own knowledge document into team' ); SELECT pg_temp.assert_denied( $$INSERT INTO public.knowledge_chunks (document_id, chunk_index, content) VALUES ('38400000-0000-4000-8000-000000000002', 0, 'poison chunk')$$, 'removed member adds chunks to a document still shared with the team' ); -- Personal writes and unsharing stay available to the removed user. INSERT INTO public.meetings (user_id, team_id, title) VALUES (auth.uid(), NULL, 'personal ok'); UPDATE public.meetings SET title = 'personal edit ok' WHERE id = '38200000-0000-4000-8000-000000000002'; INSERT INTO public.knowledge_chunks (document_id, chunk_index, content) VALUES ('38400000-0000-4000-8000-000000000001', 0, 'personal chunk ok'); UPDATE public.meetings SET team_id = NULL WHERE id = '38200000-0000-4000-8000-000000000003'; UPDATE public.knowledge_documents SET team_id = NULL WHERE id = '38400000-0000-4000-8000-000000000002'; RESET ROLE; SELECT pg_temp.assert_true( (SELECT count(*) FROM public.meetings WHERE user_id = '38000000-0000-4000-8000-000000000003' AND team_id IS NOT NULL) = 0, 'removed member was able to unshare and has no team meetings left' ); SELECT pg_temp.assert_true( (SELECT title FROM public.meetings WHERE id = '38200000-0000-4000-8000-000000000002') = 'personal edit ok', 'owner edits of personal meetings still apply' ); SET LOCAL ROLE authenticated; -- A current member can still share into the team. SELECT pg_temp.act_as('38000000-0000-4000-8000-000000000002'); INSERT INTO public.meetings (user_id, team_id, title) VALUES (auth.uid(), '38100000-0000-4000-8000-00000000000a', 'member share ok'); INSERT INTO public.knowledge_documents (id, user_id, team_id, title) VALUES ('38400000-0000-4000-8000-000000000003', auth.uid(), '38100000-0000-4000-8000-00000000000a', 'member shared doc ok'); INSERT INTO public.knowledge_chunks (document_id, chunk_index, content) VALUES ('38400000-0000-4000-8000-000000000003', 0, 'member chunk ok'); UPDATE public.meetings SET title = 'member edit ok' WHERE id = '38200000-0000-4000-8000-000000000001'; -- A member cannot attach their document to a meeting they cannot see. SELECT pg_temp.assert_denied( $$UPDATE public.meeting_documents SET meeting_id = '38200000-0000-4000-8000-000000000002' WHERE id = '38300000-0000-4000-8000-000000000001'$$, 'owner attaches document to a stranger''s private meeting' ); -- ── 2) admin cannot take over a member's rows ──────────────────────────── SELECT pg_temp.act_as('38000000-0000-4000-8000-000000000001'); SELECT pg_temp.assert_denied( $$UPDATE public.meetings SET user_id = auth.uid() WHERE id = '38200000-0000-4000-8000-000000000001'$$, 'admin rewrites member meeting owner', 'meeting_owner_immutable' ); SELECT pg_temp.assert_denied( $$UPDATE public.meetings SET team_id = '38100000-0000-4000-8000-00000000000b' WHERE id = '38200000-0000-4000-8000-000000000001'$$, 'admin moves member meeting to another team', 'meeting_team_owner_only' ); SELECT pg_temp.assert_denied( $$UPDATE public.meeting_documents SET user_id = auth.uid() WHERE id = '38300000-0000-4000-8000-000000000001'$$, 'admin rewrites member document owner', 'meeting_document_owner_immutable' ); -- Admin edit rights on content are unchanged. UPDATE public.meetings SET title = 'admin edit ok' WHERE id = '38200000-0000-4000-8000-000000000001'; UPDATE public.meeting_documents SET content = 'admin edit ok' WHERE id = '38300000-0000-4000-8000-000000000001'; -- Owner-only delete still cannot reach the member's rows. DELETE FROM public.meetings WHERE id = '38200000-0000-4000-8000-000000000001'; DELETE FROM public.meeting_documents WHERE id = '38300000-0000-4000-8000-000000000001'; RESET ROLE; SELECT pg_temp.assert_true( (SELECT user_id FROM public.meetings WHERE id = '38200000-0000-4000-8000-000000000001') = '38000000-0000-4000-8000-000000000002'::uuid, 'member keeps ownership of the team meeting' ); SELECT pg_temp.assert_true( (SELECT title FROM public.meetings WHERE id = '38200000-0000-4000-8000-000000000001') = 'admin edit ok', 'admin content edit applied' ); SELECT pg_temp.assert_true( (SELECT user_id FROM public.meeting_documents WHERE id = '38300000-0000-4000-8000-000000000001') = '38000000-0000-4000-8000-000000000002'::uuid, 'member keeps ownership of the document' ); -- ── team deletion still clears team_id through ON DELETE SET NULL ──────── -- The delete runs as postgres (the atomic team RPCs are SECURITY DEFINER) but -- keeps the admin's JWT, so the ownership trigger sees a non-owner actor on -- the cascaded UPDATE and must still let team_id go to NULL. SELECT pg_temp.act_as('38000000-0000-4000-8000-000000000001'); DELETE FROM public.teams WHERE id = '38100000-0000-4000-8000-00000000000a'; SELECT pg_temp.assert_true( (SELECT team_id FROM public.meetings WHERE id = '38200000-0000-4000-8000-000000000001') IS NULL, 'team delete cascades team_id to NULL on a member meeting' ); SELECT pg_temp.assert_true( (SELECT team_id FROM public.knowledge_documents WHERE id = '38400000-0000-4000-8000-000000000003') IS NULL, 'team delete cascades team_id to NULL on a member knowledge document' ); ROLLBACK;