d3ro-voice/server/supabase/migrations/20260927000038_team_scoped_write_checks.sql

183 lines
7.2 KiB
PL/PgSQL

-- 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())))
)
);