d3ro-voice/server/supabase/tests/team-scoped-write-checks.integration.sql

252 lines
11 KiB
PL/PgSQL

\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;