-- Team-scoped write checks for meetings, meeting_documents and knowledge. -- -- Two holes in the shipped policies: -- -- 1) Publishing into a team without being a member of it. -- meetings_insert and knowledge_documents_insert only checked user_id. -- meetings_update (20260411000001) and knowledge_documents_update -- (20260410000002) had no WITH CHECK, so Postgres reused USING, which also -- ignores team_id. Anyone who knew a team uuid (a removed member, or an -- invitee who never joined) could INSERT a row with team_id = T, or move -- one of their own rows into T. meetings_read then showed it to every T -- member, and match_knowledge_chunks fed the chunks into every T member's -- RAG answers. remove_team_member only deletes the team_members row, so -- removal did not close this route. -- -- Now every write that leaves a row with team_id set requires the writer -- to be a current member of that team. A removed member keeps full -- control of their own rows (read, delete, and unsharing by setting -- team_id back to NULL), but can no longer write content that the team -- sees. knowledge_chunks_insert gets the same rule, so a removed member -- cannot keep adding chunks to a document that is still shared. -- -- 2) Team admins taking over a member's rows. -- The admin branch of meetings_update and meeting_documents_update passed -- USING, and with no WITH CHECK the rewritten row passed the owner branch -- once the admin set user_id to their own id. meetings_delete and -- meeting_documents_delete_own are owner-only, so the admin could then -- delete the member's meeting (cascading transcripts, memos and documents) -- and the sync tombstone went to the admin instead of the owner. -- -- Ownership is now immutable for JWT callers: a BEFORE UPDATE trigger -- rejects any change of user_id, and rejects a non-owner moving the row -- to another team (meetings) or another meeting (meeting_documents). -- Clearing team_id stays allowed so ON DELETE SET NULL from teams keeps -- working. The WITH CHECK clauses also pin the owner branch: an owner can -- only attach a document to a meeting they can see, the same rule as -- meeting_documents_insert. -- -- Service-role and internal jobs (no auth.uid()) are not affected by the -- triggers, and SECURITY DEFINER RPCs never write team_id or user_id on these -- tables (the portability import forces team_id NULL). -- ── meetings ───────────────────────────────────────────────────────────── DROP POLICY IF EXISTS "meetings_insert" ON public.meetings; CREATE POLICY "meetings_insert" ON public.meetings FOR INSERT WITH CHECK ( user_id = auth.uid() AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids(auth.uid()))) ); DROP POLICY IF EXISTS "meetings_update" ON public.meetings; CREATE POLICY "meetings_update" ON public.meetings FOR UPDATE USING ( user_id = auth.uid() OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_admin_team_ids(auth.uid()))) ) WITH CHECK ( ( user_id = auth.uid() OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_admin_team_ids(auth.uid()))) ) AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids(auth.uid()))) ); CREATE OR REPLACE FUNCTION public.enforce_meeting_ownership_v1() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ DECLARE actor_id uuid := auth.uid(); BEGIN IF actor_id IS NULL THEN RETURN NEW; END IF; IF NEW.user_id IS DISTINCT FROM OLD.user_id THEN RAISE EXCEPTION 'meeting_owner_immutable' USING ERRCODE = '42501'; END IF; IF NEW.team_id IS DISTINCT FROM OLD.team_id AND NEW.team_id IS NOT NULL AND OLD.user_id IS DISTINCT FROM actor_id THEN RAISE EXCEPTION 'meeting_team_owner_only' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; REVOKE ALL ON FUNCTION public.enforce_meeting_ownership_v1() FROM PUBLIC, anon, authenticated; DROP TRIGGER IF EXISTS meetings_ownership_v1 ON public.meetings; CREATE TRIGGER meetings_ownership_v1 BEFORE UPDATE OF user_id, team_id ON public.meetings FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_ownership_v1(); -- ── meeting_documents ──────────────────────────────────────────────────── DROP POLICY IF EXISTS "meeting_documents_update" ON public.meeting_documents; CREATE POLICY "meeting_documents_update" ON public.meeting_documents FOR UPDATE USING ( user_id = auth.uid() OR meeting_id IN ( SELECT id FROM public.meetings WHERE team_id IN (SELECT public.user_admin_team_ids(auth.uid())) ) ) WITH CHECK ( ( user_id = auth.uid() OR meeting_id IN ( SELECT id FROM public.meetings WHERE team_id IN (SELECT public.user_admin_team_ids(auth.uid())) ) ) AND meeting_id IN ( SELECT id FROM public.meetings WHERE user_id = auth.uid() OR (team_id IS NOT NULL AND team_id IN (SELECT public.user_team_ids(auth.uid()))) ) ); CREATE OR REPLACE FUNCTION public.enforce_meeting_document_ownership_v1() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ DECLARE actor_id uuid := auth.uid(); BEGIN IF actor_id IS NULL THEN RETURN NEW; END IF; IF NEW.user_id IS DISTINCT FROM OLD.user_id THEN RAISE EXCEPTION 'meeting_document_owner_immutable' USING ERRCODE = '42501'; END IF; IF NEW.meeting_id IS DISTINCT FROM OLD.meeting_id AND OLD.user_id IS DISTINCT FROM actor_id THEN RAISE EXCEPTION 'meeting_document_meeting_owner_only' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; REVOKE ALL ON FUNCTION public.enforce_meeting_document_ownership_v1() FROM PUBLIC, anon, authenticated; DROP TRIGGER IF EXISTS meeting_documents_ownership_v1 ON public.meeting_documents; CREATE TRIGGER meeting_documents_ownership_v1 BEFORE UPDATE OF user_id, meeting_id ON public.meeting_documents FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_document_ownership_v1(); -- ── knowledge_documents / knowledge_chunks ─────────────────────────────── DROP POLICY IF EXISTS "knowledge_documents_insert" ON public.knowledge_documents; CREATE POLICY "knowledge_documents_insert" ON public.knowledge_documents FOR INSERT WITH CHECK ( user_id = auth.uid() AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids(auth.uid()))) ); DROP POLICY IF EXISTS "knowledge_documents_update" ON public.knowledge_documents; CREATE POLICY "knowledge_documents_update" ON public.knowledge_documents FOR UPDATE USING (user_id = auth.uid()) WITH CHECK ( user_id = auth.uid() AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids(auth.uid()))) ); DROP POLICY IF EXISTS "knowledge_chunks_insert" ON public.knowledge_chunks; CREATE POLICY "knowledge_chunks_insert" ON public.knowledge_chunks FOR INSERT WITH CHECK ( document_id IN ( SELECT id FROM public.knowledge_documents WHERE user_id = auth.uid() AND (team_id IS NULL OR team_id IN (SELECT public.user_team_ids(auth.uid()))) ) );