From c49567385ff3ad1ee2c4a68744f2a913b105bc95 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 25 Jun 2026 10:21:13 -0400 Subject: [PATCH 1/3] ENG-1951 Tighten table access --- packages/database/src/dbTypes.ts | 14 +- .../20260625130253_tighter_security.sql | 135 ++++++++++++++++++ .../database/supabase/schemas/account.sql | 22 +-- packages/database/supabase/schemas/assets.sql | 6 +- .../database/supabase/schemas/concept.sql | 14 +- .../database/supabase/schemas/content.sql | 16 +-- .../database/supabase/schemas/contributor.sql | 21 ++- .../database/supabase/schemas/embedding.sql | 6 +- packages/database/supabase/schemas/sync.sql | 10 +- 9 files changed, 205 insertions(+), 39 deletions(-) create mode 100644 packages/database/supabase/migrations/20260625130253_tighter_security.sql diff --git a/packages/database/src/dbTypes.ts b/packages/database/src/dbTypes.ts index c37e3a030..acde3c94f 100644 --- a/packages/database/src/dbTypes.ts +++ b/packages/database/src/dbTypes.ts @@ -1512,6 +1512,7 @@ export type Database = { } } can_access_account: { Args: { account_uid: string }; Returns: boolean } + can_view_concept: { Args: { concept_id: number }; Returns: boolean } can_view_content: { Args: { content_id: number }; Returns: boolean } can_view_specific_resource: { Args: { source_local_id_: string; space_id_: number } @@ -1630,7 +1631,13 @@ export type Database = { } Returns: string } - document_in_space: { Args: { document_id: number }; Returns: boolean } + document_in_space: { + Args: { + access_level?: Database["public"]["Enums"]["SpaceAccessPermissions"] + document_id: number + } + Returns: boolean + } document_of_content: { Args: { content: Database["public"]["Views"]["my_contents"]["Row"] } Returns: { @@ -1812,7 +1819,10 @@ export type Database = { } } unowned_account_in_shared_space: { - Args: { p_account_id: number } + Args: { + access_level?: Database["public"]["Enums"]["SpaceAccessPermissions"] + p_account_id: number + } Returns: boolean } upsert_account_in_space: { diff --git a/packages/database/supabase/migrations/20260625130253_tighter_security.sql b/packages/database/supabase/migrations/20260625130253_tighter_security.sql new file mode 100644 index 000000000..c4f0baf95 --- /dev/null +++ b/packages/database/supabase/migrations/20260625130253_tighter_security.sql @@ -0,0 +1,135 @@ +CREATE OR REPLACE FUNCTION public.unowned_account_in_shared_space(p_account_id BIGINT, access_level public."SpaceAccessPermissions" = 'reader') RETURNS boolean +STABLE SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public."SpaceAccess" AS sa + JOIN public.my_user_accounts() ON (sa.account_uid = my_user_accounts) + JOIN public."LocalAccess" AS la USING (space_id) + JOIN public."PlatformAccount" AS pa ON (pa.id=la.account_id) + WHERE la.account_id = p_account_id + AND pa.dg_account IS NULL + AND sa.permissions >= access_level + ); +$$; + + +DROP POLICY IF EXISTS platform_account_delete_policy ON public."PlatformAccount"; +CREATE POLICY platform_account_delete_policy ON public."PlatformAccount" FOR DELETE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); + +DROP POLICY IF EXISTS platform_account_insert_policy ON public."PlatformAccount"; +CREATE POLICY platform_account_insert_policy ON public."PlatformAccount" FOR INSERT WITH CHECK (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); + +DROP POLICY IF EXISTS platform_account_update_policy ON public."PlatformAccount"; +CREATE POLICY platform_account_update_policy ON public."PlatformAccount" FOR UPDATE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); + +DROP POLICY IF EXISTS local_access_delete_policy ON public."LocalAccess"; +CREATE POLICY local_access_delete_policy ON public."LocalAccess" FOR DELETE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP POLICY IF EXISTS local_access_insert_policy ON public."LocalAccess"; +CREATE POLICY local_access_insert_policy ON public."LocalAccess" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP POLICY IF EXISTS local_access_update_policy ON public."LocalAccess"; +CREATE POLICY local_access_update_policy ON public."LocalAccess" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP POLICY IF EXISTS agent_identifier_delete_policy ON public."AgentIdentifier"; +CREATE POLICY agent_identifier_delete_policy ON public."AgentIdentifier" FOR DELETE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP POLICY IF EXISTS agent_identifier_insert_policy ON public."AgentIdentifier"; +CREATE POLICY agent_identifier_insert_policy ON public."AgentIdentifier" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP POLICY IF EXISTS agent_identifier_update_policy ON public."AgentIdentifier"; +CREATE POLICY agent_identifier_update_policy ON public."AgentIdentifier" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); + +DROP FUNCTION public.unowned_account_in_shared_space(BIGINT); + +CREATE OR REPLACE FUNCTION public.document_in_space(document_id BIGINT, access_level public."SpaceAccessPermissions" = 'reader') RETURNS boolean +STABLE +SET search_path = '' +LANGUAGE sql +AS $$ + SELECT public.in_space(space_id, access_level) FROM public."Document" WHERE id=document_id +$$; + +CREATE OR REPLACE FUNCTION public.can_view_concept(concept_id BIGINT) RETURNS BOOLEAN +STABLE +SET search_path = '' +LANGUAGE sql +AS $$ + SELECT public.can_view_specific_resource(space_id, source_local_id) FROM public."Concept" WHERE id=concept_id; +$$; + +CREATE OR REPLACE FUNCTION public.generic_entity_access(target_id BIGINT, target_type public."EntityType") RETURNS boolean +STABLE SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + SELECT CASE + WHEN target_type = 'Space' THEN public.in_space(target_id, 'editor') + WHEN target_type = 'Content' THEN public.content_in_space(target_id, 'editor') + WHEN target_type = 'Concept' THEN public.concept_in_space(target_id, 'editor') + WHEN target_type = 'Document' THEN public.document_in_space(target_id, 'editor') + WHEN target_type = 'PlatformAccount' THEN public.account_in_shared_space(target_id, 'editor') + ELSE false + END; +$$; + +DROP FUNCTION public.document_in_space(BIGINT); + +DROP POLICY IF EXISTS embedding_openai_te3s_1536_delete_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; +CREATE POLICY embedding_openai_te3s_1536_delete_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" +FOR DELETE USING (public.content_in_space (target_id, 'editor')) ; +DROP POLICY IF EXISTS embedding_openai_te3s_1536_insert_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; +CREATE POLICY embedding_openai_te3s_1536_insert_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" +FOR INSERT WITH CHECK (public.content_in_space (target_id, 'editor')) ; +DROP POLICY IF EXISTS embedding_openai_te3s_1536_update_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; +CREATE POLICY embedding_openai_te3s_1536_update_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" +FOR UPDATE USING (public.content_in_space (target_id, 'editor')) ; + +DROP POLICY IF EXISTS document_delete_policy ON public."Document"; +CREATE POLICY document_delete_policy ON public."Document" FOR DELETE USING (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS document_insert_policy ON public."Document"; +CREATE POLICY document_insert_policy ON public."Document" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS document_update_policy ON public."Document"; +CREATE POLICY document_update_policy ON public."Document" FOR UPDATE USING (public.in_space(space_id, 'editor')); + +DROP POLICY IF EXISTS content_delete_policy ON public."Content"; +CREATE POLICY content_delete_policy ON public."Content" FOR DELETE USING (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS content_insert_policy ON public."Content"; +CREATE POLICY content_insert_policy ON public."Content" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS content_update_policy ON public."Content"; +CREATE POLICY content_update_policy ON public."Content" FOR UPDATE USING (public.in_space(space_id, 'editor')); + +DROP POLICY IF EXISTS concept_delete_policy ON public."Concept"; +CREATE POLICY concept_delete_policy ON public."Concept" FOR DELETE USING (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS concept_insert_policy ON public."Concept"; +CREATE POLICY concept_insert_policy ON public."Concept" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS concept_update_policy ON public."Concept"; +CREATE POLICY concept_update_policy ON public."Concept" FOR UPDATE USING (public.in_space(space_id, 'editor')); + +DROP POLICY IF EXISTS file_reference_delete_policy ON public."FileReference"; +CREATE POLICY file_reference_delete_policy ON public."FileReference" FOR DELETE USING (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS file_reference_insert_policy ON public."FileReference"; +CREATE POLICY file_reference_insert_policy ON public."FileReference" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); +DROP POLICY IF EXISTS file_reference_update_policy ON public."FileReference"; +CREATE POLICY file_reference_update_policy ON public."FileReference" FOR UPDATE USING (public.in_space(space_id, 'editor')); + +DROP POLICY IF EXISTS concept_contributors_policy ON public.concept_contributors; +DROP POLICY IF EXISTS concept_contributors_select_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_select_policy ON public.concept_contributors FOR SELECT USING (public.concept_in_space(concept_id) OR public.can_view_concept(concept_id)); +DROP POLICY IF EXISTS concept_contributors_delete_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_delete_policy ON public.concept_contributors FOR DELETE USING (public.concept_in_space(concept_id, 'editor')); +DROP POLICY IF EXISTS concept_contributors_insert_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_insert_policy ON public.concept_contributors FOR INSERT WITH CHECK (public.concept_in_space(concept_id, 'editor')); +DROP POLICY IF EXISTS concept_contributors_update_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_update_policy ON public.concept_contributors FOR UPDATE USING (public.concept_in_space(concept_id, 'editor')); + +DROP POLICY IF EXISTS content_contributors_policy ON public.content_contributors; +DROP POLICY IF EXISTS content_contributors_select_policy ON public.content_contributors; +CREATE POLICY content_contributors_select_policy ON public.content_contributors FOR SELECT USING (public.content_in_space(content_id) OR public.can_view_content(content_id)); +DROP POLICY IF EXISTS content_contributors_delete_policy ON public.content_contributors; +CREATE POLICY content_contributors_delete_policy ON public.content_contributors FOR DELETE USING (public.content_in_space(content_id, 'editor')); +DROP POLICY IF EXISTS content_contributors_insert_policy ON public.content_contributors; +CREATE POLICY content_contributors_insert_policy ON public.content_contributors FOR INSERT WITH CHECK (public.content_in_space(content_id, 'editor')); +DROP POLICY IF EXISTS content_contributors_update_policy ON public.content_contributors; +CREATE POLICY content_contributors_update_policy ON public.content_contributors FOR UPDATE USING (public.content_in_space(content_id, 'editor')); diff --git a/packages/database/supabase/schemas/account.sql b/packages/database/supabase/schemas/account.sql index 73b59baf4..bc415f662 100644 --- a/packages/database/supabase/schemas/account.sql +++ b/packages/database/supabase/schemas/account.sql @@ -302,7 +302,7 @@ $$; COMMENT ON FUNCTION public.account_in_shared_space IS 'security utility: does current user share a space with this account?'; -CREATE OR REPLACE FUNCTION public.unowned_account_in_shared_space(p_account_id BIGINT) RETURNS boolean +CREATE OR REPLACE FUNCTION public.unowned_account_in_shared_space(p_account_id BIGINT, access_level public."SpaceAccessPermissions" = 'reader') RETURNS boolean STABLE SECURITY DEFINER SET search_path = '' LANGUAGE sql AS $$ @@ -314,7 +314,7 @@ LANGUAGE sql AS $$ JOIN public."PlatformAccount" AS pa ON (pa.id=la.account_id) WHERE la.account_id = p_account_id AND pa.dg_account IS NULL - AND sa.permissions >= 'reader' + AND sa.permissions >= access_level ); $$; @@ -511,13 +511,13 @@ DROP POLICY IF EXISTS platform_account_select_policy ON public."PlatformAccount" CREATE POLICY platform_account_select_policy ON public."PlatformAccount" FOR SELECT USING (dg_account = (SELECT auth.uid() LIMIT 1) OR public.account_in_shared_space(id, 'partial')); DROP POLICY IF EXISTS platform_account_delete_policy ON public."PlatformAccount"; -CREATE POLICY platform_account_delete_policy ON public."PlatformAccount" FOR DELETE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id))); +CREATE POLICY platform_account_delete_policy ON public."PlatformAccount" FOR DELETE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); DROP POLICY IF EXISTS platform_account_insert_policy ON public."PlatformAccount"; -CREATE POLICY platform_account_insert_policy ON public."PlatformAccount" FOR INSERT WITH CHECK (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id))); +CREATE POLICY platform_account_insert_policy ON public."PlatformAccount" FOR INSERT WITH CHECK (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); DROP POLICY IF EXISTS platform_account_update_policy ON public."PlatformAccount"; -CREATE POLICY platform_account_update_policy ON public."PlatformAccount" FOR UPDATE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id))); +CREATE POLICY platform_account_update_policy ON public."PlatformAccount" FOR UPDATE USING (dg_account = (SELECT auth.uid() LIMIT 1) OR (dg_account IS null AND public.unowned_account_in_shared_space(id, 'editor'))); -- SpaceAccess: Created through the create_account_in_space and the Space create route, both of which bypass RLS. -- Can be updated by a space peer for now, unless claimed by a user. @@ -547,13 +547,13 @@ DROP POLICY IF EXISTS local_access_select_policy ON public."LocalAccess"; CREATE POLICY local_access_select_policy ON public."LocalAccess" FOR SELECT USING (public.in_space(space_id)); DROP POLICY IF EXISTS local_access_delete_policy ON public."LocalAccess"; -CREATE POLICY local_access_delete_policy ON public."LocalAccess" FOR DELETE USING (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY local_access_delete_policy ON public."LocalAccess" FOR DELETE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); DROP POLICY IF EXISTS local_access_insert_policy ON public."LocalAccess"; -CREATE POLICY local_access_insert_policy ON public."LocalAccess" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY local_access_insert_policy ON public."LocalAccess" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); DROP POLICY IF EXISTS local_access_update_policy ON public."LocalAccess"; -CREATE POLICY local_access_update_policy ON public."LocalAccess" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY local_access_update_policy ON public."LocalAccess" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); -- AgentIdentifier: Allow space members to do anything, to allow editing authors. -- Eventually: Once the account is claimed by a user, only allow this user to modify it. @@ -566,13 +566,13 @@ DROP POLICY IF EXISTS agent_identifier_select_policy ON public."AgentIdentifier" CREATE POLICY agent_identifier_select_policy ON public."AgentIdentifier" FOR SELECT USING (public.account_in_shared_space(account_id)); DROP POLICY IF EXISTS agent_identifier_delete_policy ON public."AgentIdentifier"; -CREATE POLICY agent_identifier_delete_policy ON public."AgentIdentifier" FOR DELETE USING (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY agent_identifier_delete_policy ON public."AgentIdentifier" FOR DELETE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); DROP POLICY IF EXISTS agent_identifier_insert_policy ON public."AgentIdentifier"; -CREATE POLICY agent_identifier_insert_policy ON public."AgentIdentifier" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY agent_identifier_insert_policy ON public."AgentIdentifier" FOR INSERT WITH CHECK (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); DROP POLICY IF EXISTS agent_identifier_update_policy ON public."AgentIdentifier"; -CREATE POLICY agent_identifier_update_policy ON public."AgentIdentifier" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id) OR public.is_my_account(account_id)); +CREATE POLICY agent_identifier_update_policy ON public."AgentIdentifier" FOR UPDATE USING (public.unowned_account_in_shared_space(account_id, 'editor') OR public.is_my_account(account_id)); ALTER TABLE public.group_membership ENABLE ROW LEVEL SECURITY; diff --git a/packages/database/supabase/schemas/assets.sql b/packages/database/supabase/schemas/assets.sql index f953d4419..001fb2977 100644 --- a/packages/database/supabase/schemas/assets.sql +++ b/packages/database/supabase/schemas/assets.sql @@ -47,11 +47,11 @@ DROP POLICY IF EXISTS file_reference_policy ON public."FileReference"; DROP POLICY IF EXISTS file_reference_select_policy ON public."FileReference"; CREATE POLICY file_reference_select_policy ON public."FileReference" FOR SELECT USING (public.in_space(space_id) OR public.can_view_specific_resource(space_id, source_local_id)); DROP POLICY IF EXISTS file_reference_delete_policy ON public."FileReference"; -CREATE POLICY file_reference_delete_policy ON public."FileReference" FOR DELETE USING (public.in_space(space_id)); +CREATE POLICY file_reference_delete_policy ON public."FileReference" FOR DELETE USING (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS file_reference_insert_policy ON public."FileReference"; -CREATE POLICY file_reference_insert_policy ON public."FileReference" FOR INSERT WITH CHECK (public.in_space(space_id)); +CREATE POLICY file_reference_insert_policy ON public."FileReference" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS file_reference_update_policy ON public."FileReference"; -CREATE POLICY file_reference_update_policy ON public."FileReference" FOR UPDATE USING (public.in_space(space_id)); +CREATE POLICY file_reference_update_policy ON public."FileReference" FOR UPDATE USING (public.in_space(space_id, 'editor')); -- We cannot delete blobs from sql; we'll need to call an edge function with pg_net. -- We could pass the name to the edge function, but it's safer to accumulate paths in a table diff --git a/packages/database/supabase/schemas/concept.sql b/packages/database/supabase/schemas/concept.sql index ade37cdbf..c91a15854 100644 --- a/packages/database/supabase/schemas/concept.sql +++ b/packages/database/supabase/schemas/concept.sql @@ -133,6 +133,14 @@ WHERE ( OR (space_id = any(public.my_space_ids('partial')) AND ra.space_id IS NOT null) ); +CREATE OR REPLACE FUNCTION public.can_view_concept(concept_id BIGINT) RETURNS BOOLEAN +STABLE +SET search_path = '' +LANGUAGE sql +AS $$ + SELECT public.can_view_specific_resource(space_id, source_local_id) FROM public."Concept" WHERE id=concept_id; +$$; + -- following https://docs.postgrest.org/en/v13/references/api/resource_embedding.html#recursive-relationships CREATE OR REPLACE FUNCTION public.schema_of_concept(concept public."Concept") RETURNS SETOF public."Concept" STRICT STABLE @@ -521,11 +529,11 @@ DROP POLICY IF EXISTS concept_policy ON public."Concept"; DROP POLICY IF EXISTS concept_select_policy ON public."Concept"; CREATE POLICY concept_select_policy ON public."Concept" FOR SELECT USING (public.in_space(space_id) OR public.can_view_specific_resource(space_id, source_local_id)); DROP POLICY IF EXISTS concept_delete_policy ON public."Concept"; -CREATE POLICY concept_delete_policy ON public."Concept" FOR DELETE USING (public.in_space(space_id)); +CREATE POLICY concept_delete_policy ON public."Concept" FOR DELETE USING (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS concept_insert_policy ON public."Concept"; -CREATE POLICY concept_insert_policy ON public."Concept" FOR INSERT WITH CHECK (public.in_space(space_id)); +CREATE POLICY concept_insert_policy ON public."Concept" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS concept_update_policy ON public."Concept"; -CREATE POLICY concept_update_policy ON public."Concept" FOR UPDATE USING (public.in_space(space_id)); +CREATE POLICY concept_update_policy ON public."Concept" FOR UPDATE USING (public.in_space(space_id, 'editor')); -- since ResourceAccess is used for both Content and Concepts, -- we cannot count on the usual foreign key delete cascades. diff --git a/packages/database/supabase/schemas/content.sql b/packages/database/supabase/schemas/content.sql index 98f18f179..aed79b863 100644 --- a/packages/database/supabase/schemas/content.sql +++ b/packages/database/supabase/schemas/content.sql @@ -652,12 +652,12 @@ $$; COMMENT ON FUNCTION public.content_in_space IS 'security utility: does current user have access to this content''s space?'; -CREATE OR REPLACE FUNCTION public.document_in_space(document_id BIGINT) RETURNS boolean +CREATE OR REPLACE FUNCTION public.document_in_space(document_id BIGINT, access_level public."SpaceAccessPermissions" = 'reader') RETURNS boolean STABLE SET search_path = '' LANGUAGE sql AS $$ - SELECT public.in_space(space_id) FROM public."Document" WHERE id=document_id + SELECT public.in_space(space_id, access_level) FROM public."Document" WHERE id=document_id $$; COMMENT ON FUNCTION public.document_in_space IS 'security utility: does current user have access to this document''s space?'; @@ -668,11 +668,11 @@ DROP POLICY IF EXISTS document_policy ON public."Document"; DROP POLICY IF EXISTS document_select_policy ON public."Document"; CREATE POLICY document_select_policy ON public."Document" FOR SELECT USING (public.in_space(space_id) OR public.can_view_specific_resource(space_id, source_local_id)); DROP POLICY IF EXISTS document_delete_policy ON public."Document"; -CREATE POLICY document_delete_policy ON public."Document" FOR DELETE USING (public.in_space(space_id)); +CREATE POLICY document_delete_policy ON public."Document" FOR DELETE USING (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS document_insert_policy ON public."Document"; -CREATE POLICY document_insert_policy ON public."Document" FOR INSERT WITH CHECK (public.in_space(space_id)); +CREATE POLICY document_insert_policy ON public."Document" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS document_update_policy ON public."Document"; -CREATE POLICY document_update_policy ON public."Document" FOR UPDATE USING (public.in_space(space_id)); +CREATE POLICY document_update_policy ON public."Document" FOR UPDATE USING (public.in_space(space_id, 'editor')); ALTER TABLE public."Content" ENABLE ROW LEVEL SECURITY; @@ -680,11 +680,11 @@ DROP POLICY IF EXISTS content_policy ON public."Content"; DROP POLICY IF EXISTS content_select_policy ON public."Content"; CREATE POLICY content_select_policy ON public."Content" FOR SELECT USING (public.in_space(space_id) OR public.can_view_specific_resource(space_id, source_local_id)); DROP POLICY IF EXISTS content_delete_policy ON public."Content"; -CREATE POLICY content_delete_policy ON public."Content" FOR DELETE USING (public.in_space(space_id)); +CREATE POLICY content_delete_policy ON public."Content" FOR DELETE USING (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS content_insert_policy ON public."Content"; -CREATE POLICY content_insert_policy ON public."Content" FOR INSERT WITH CHECK (public.in_space(space_id)); +CREATE POLICY content_insert_policy ON public."Content" FOR INSERT WITH CHECK (public.in_space(space_id, 'editor')); DROP POLICY IF EXISTS content_update_policy ON public."Content"; -CREATE POLICY content_update_policy ON public."Content" FOR UPDATE USING (public.in_space(space_id)); +CREATE POLICY content_update_policy ON public."Content" FOR UPDATE USING (public.in_space(space_id, 'editor')); ALTER TABLE public."ResourceAccess" ENABLE ROW LEVEL SECURITY; diff --git a/packages/database/supabase/schemas/contributor.sql b/packages/database/supabase/schemas/contributor.sql index 8fca5769f..53cab05f5 100644 --- a/packages/database/supabase/schemas/contributor.sql +++ b/packages/database/supabase/schemas/contributor.sql @@ -52,11 +52,24 @@ GRANT ALL ON TABLE public.content_contributors TO authenticated; GRANT ALL ON TABLE public.content_contributors TO service_role; ALTER TABLE public.concept_contributors ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.content_contributors ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS concept_contributors_policy ON public.concept_contributors; -CREATE POLICY concept_contributors_policy ON public.concept_contributors FOR ALL USING (public.concept_in_space(concept_id)); - -ALTER TABLE public.content_contributors ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS concept_contributors_select_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_select_policy ON public.concept_contributors FOR SELECT USING (public.concept_in_space(concept_id) OR public.can_view_concept(concept_id)); +DROP POLICY IF EXISTS concept_contributors_delete_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_delete_policy ON public.concept_contributors FOR DELETE USING (public.concept_in_space(concept_id, 'editor')); +DROP POLICY IF EXISTS concept_contributors_insert_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_insert_policy ON public.concept_contributors FOR INSERT WITH CHECK (public.concept_in_space(concept_id, 'editor')); +DROP POLICY IF EXISTS concept_contributors_update_policy ON public.concept_contributors; +CREATE POLICY concept_contributors_update_policy ON public.concept_contributors FOR UPDATE USING (public.concept_in_space(concept_id, 'editor')); DROP POLICY IF EXISTS content_contributors_policy ON public.content_contributors; -CREATE POLICY content_contributors_policy ON public.content_contributors FOR ALL USING (public.content_in_space(content_id)); +DROP POLICY IF EXISTS content_contributors_select_policy ON public.content_contributors; +CREATE POLICY content_contributors_select_policy ON public.content_contributors FOR SELECT USING (public.content_in_space(content_id) OR public.can_view_content(content_id)); +DROP POLICY IF EXISTS content_contributors_delete_policy ON public.content_contributors; +CREATE POLICY content_contributors_delete_policy ON public.content_contributors FOR DELETE USING (public.content_in_space(content_id, 'editor')); +DROP POLICY IF EXISTS content_contributors_insert_policy ON public.content_contributors; +CREATE POLICY content_contributors_insert_policy ON public.content_contributors FOR INSERT WITH CHECK (public.content_in_space(content_id, 'editor')); +DROP POLICY IF EXISTS content_contributors_update_policy ON public.content_contributors; +CREATE POLICY content_contributors_update_policy ON public.content_contributors FOR UPDATE USING (public.content_in_space(content_id, 'editor')); diff --git a/packages/database/supabase/schemas/embedding.sql b/packages/database/supabase/schemas/embedding.sql index 0e989e229..5a5346491 100644 --- a/packages/database/supabase/schemas/embedding.sql +++ b/packages/database/supabase/schemas/embedding.sql @@ -127,10 +127,10 @@ CREATE POLICY embedding_openai_te3s_1536_select_policy ON public."ContentEmbeddi FOR SELECT USING (public.content_in_space (target_id) OR public.can_view_content (target_id)) ; DROP POLICY IF EXISTS embedding_openai_te3s_1536_delete_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; CREATE POLICY embedding_openai_te3s_1536_delete_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" -FOR DELETE USING (public.content_in_space (target_id)) ; +FOR DELETE USING (public.content_in_space (target_id, 'editor')) ; DROP POLICY IF EXISTS embedding_openai_te3s_1536_insert_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; CREATE POLICY embedding_openai_te3s_1536_insert_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" -FOR INSERT WITH CHECK (public.content_in_space (target_id)) ; +FOR INSERT WITH CHECK (public.content_in_space (target_id, 'editor')) ; DROP POLICY IF EXISTS embedding_openai_te3s_1536_update_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" ; CREATE POLICY embedding_openai_te3s_1536_update_policy ON public."ContentEmbedding_openai_text_embedding_3_small_1536" -FOR UPDATE USING (public.content_in_space (target_id)) ; +FOR UPDATE USING (public.content_in_space (target_id, 'editor')) ; diff --git a/packages/database/supabase/schemas/sync.sql b/packages/database/supabase/schemas/sync.sql index 2316e658c..299fbe253 100644 --- a/packages/database/supabase/schemas/sync.sql +++ b/packages/database/supabase/schemas/sync.sql @@ -291,11 +291,11 @@ STABLE SECURITY DEFINER SET search_path = '' LANGUAGE sql AS $$ SELECT CASE - WHEN target_type = 'Space' THEN public.in_space(target_id) - WHEN target_type = 'Content' THEN public.content_in_space(target_id) - WHEN target_type = 'Concept' THEN public.concept_in_space(target_id) - WHEN target_type = 'Document' THEN public.document_in_space(target_id) - WHEN target_type = 'PlatformAccount' THEN public.account_in_shared_space(target_id) + WHEN target_type = 'Space' THEN public.in_space(target_id, 'editor') + WHEN target_type = 'Content' THEN public.content_in_space(target_id, 'editor') + WHEN target_type = 'Concept' THEN public.concept_in_space(target_id, 'editor') + WHEN target_type = 'Document' THEN public.document_in_space(target_id, 'editor') + WHEN target_type = 'PlatformAccount' THEN public.account_in_shared_space(target_id, 'editor') ELSE false END; $$; From 8a9cee3422cb720816ef89db47a28ce95a7c30b8 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 25 Jun 2026 14:00:21 -0400 Subject: [PATCH 2/3] test --- .../database/features/groupAccess.feature | 50 ++++++++++++++-- .../features/step-definitions/stepdefs.ts | 59 +++++++++++++++---- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/database/features/groupAccess.feature b/packages/database/features/groupAccess.feature index 0179b4bf4..59b4a3238 100644 --- a/packages/database/features/groupAccess.feature +++ b/packages/database/features/groupAccess.feature @@ -20,15 +20,15 @@ Feature: Group content access And user of space s2 accepts the group invitation Then user of space s2 should be a member of group invite_group - Scenario: Creating content + Scenario: Sharing content When Document are added to the database: | $id | source_local_id | created | last_modified | _author_id | _space_id | - | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | - | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | And Content are added to the database: | $id | source_local_id | _document_id | text | created | last_modified | scale | _author_id | _space_id | - | ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | - | ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | Then a user logged in space s1 should see 2 PlatformAccount in the database And a user logged in space s1 should see 2 Content in the database And a user logged in space s2 should see 2 PlatformAccount in the database @@ -47,3 +47,43 @@ Feature: Group content access Then a user logged in space s1 should see 2 Content in the database Then a user logged in space s2 should see 1 Content in the database And a user logged in space s2 should see 2 Space in the database + + Scenario: Reader permissions do not allow cross-space edit + When user of space s1 creates group my_group + And user of space s1 adds space s2 to group my_group + And SpaceAccess are added to the database: + | _account_uid | _space_id | permissions | + | my_group | s1 | reader | + | my_group | s2 | reader | + Then user user2 fails to upsert these documents to space s1: + """json + [ + { + "source_local_id": "s1", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user1" + } + ] + """ + + Scenario: Edit permissions allow cross-space edit + When user of space s1 creates group my_group + And user of space s1 adds space s2 to group my_group + And SpaceAccess are added to the database: + | _account_uid | _space_id | permissions | + | my_group | s1 | editor | + | my_group | s2 | editor | + When user user2 upserts these documents to space s1: + """json + [ + { + "source_local_id": "s1", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user1" + } + ] + """ + Then a user logged in space s1 should see 1 Document in the database + And a user logged in space s2 should see 1 Document in the database diff --git a/packages/database/features/step-definitions/stepdefs.ts b/packages/database/features/step-definitions/stepdefs.ts index c1df6bbfd..676ff52aa 100644 --- a/packages/database/features/step-definitions/stepdefs.ts +++ b/packages/database/features/step-definitions/stepdefs.ts @@ -1,7 +1,10 @@ /* eslint @typescript-eslint/no-explicit-any : 0 */ import assert from "assert"; import { Given, When, Then, world, type DataTable } from "@cucumber/cucumber"; -import { createClient } from "@supabase/supabase-js"; +import { + createClient, + type PostgrestSingleResponse, +} from "@supabase/supabase-js"; import { Constants, type Database, @@ -206,6 +209,7 @@ When( if (PLATFORMS.indexOf(platform) < 0) throw new Error(`Platform must be one of ${PLATFORMS.join(", ")}`); const localRefs = (world.localRefs || {}) as LocalRefsType; + const spaceOfUsername = (world.spaceOfUsername || {}) as LocalRefsType; const spaceResponse = await fetchOrCreateSpaceDirect({ password: SPACE_ANONYMOUS_PASSWORD, url: `https://roamresearch.com/#/app/${spaceName}`, @@ -227,7 +231,9 @@ When( password: SPACE_ANONYMOUS_PASSWORD, }); localRefs[userAccountId] = userId; + spaceOfUsername[userAccountId] = spaceId; world.localRefs = localRefs; + world.spaceOfUsername = spaceOfUsername; }, ); @@ -261,6 +267,16 @@ const getLoggedinDatabase = async (spaceId: number) => { return client; }; +const getLoggedinDatabaseForUsername = async (userName: string) => { + assert.notStrictEqual(userName, undefined); + const localRefs = (world.localRefs || {}) as LocalRefsType; + assert(localRefs[userName]); + const spaceOfUsername = (world.spaceOfUsername || {}) as LocalRefsType; + const spaceId = spaceOfUsername[userName]; + assert(typeof spaceId === "number"); + return await getLoggedinDatabase(spaceId); +}; + // A test of non-empty object count for the named table, as seen by the user Then( "a user logged in space {word} should see a {word} in the database", @@ -299,7 +315,7 @@ Given( const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; if (typeof spaceId !== "number") assert.fail("spaceId not a number"); - const client = await getLoggedinDatabase(spaceId); + const client = await getLoggedinDatabaseForUsername(userName); const response = await client.rpc("upsert_accounts_in_space", { space_id_: spaceId, accounts, @@ -308,23 +324,40 @@ Given( }, ); +const upsertDocs = async ( + userName: string, + spaceName: string, + docString: string, +): Promise> => { + const data = JSON.parse(docString) as Json; + const localRefs = (world.localRefs || {}) as LocalRefsType; + const spaceId = localRefs[spaceName]; + if (typeof spaceId !== "number") assert.fail("spaceId not a number"); + const client = await getLoggedinDatabaseForUsername(userName); + return await client.rpc("upsert_documents", { + v_space_id: spaceId, + data, + }); + //assert.equal(response.error, null); +}; + // invoke the upsert_documents function, expects json Given( "user {word} upserts these documents to space {word}:", async (userName: string, spaceName: string, docString: string) => { - const data = JSON.parse(docString) as Json; - const localRefs = (world.localRefs || {}) as LocalRefsType; - const spaceId = localRefs[spaceName]; - if (typeof spaceId !== "number") assert.fail("spaceId not a number"); - const client = await getLoggedinDatabase(spaceId); - const response = await client.rpc("upsert_documents", { - v_space_id: spaceId, - data, - }); + const response = await upsertDocs(userName, spaceName, docString); assert.equal(response.error, null); }, ); +Given( + "user {word} fails to upsert these documents to space {word}:", + async (userName: string, spaceName: string, docString: string) => { + const response = await upsertDocs(userName, spaceName, docString); + assert(response.error); + }, +); + // invoke the upsert_content function, expects json Given( "user {word} upserts this content to space {word}:", @@ -335,7 +368,7 @@ Given( if (typeof spaceId !== "number") assert.fail("spaceId not a number"); const userId = localRefs[userName]; if (typeof userId !== "number") assert.fail("userId not a number"); - const client = await getLoggedinDatabase(spaceId); + const client = await getLoggedinDatabaseForUsername(userName); const response = await client.rpc("upsert_content", { v_space_id: spaceId, data, @@ -354,7 +387,7 @@ Given( const localRefs = (world.localRefs || {}) as LocalRefsType; const spaceId = localRefs[spaceName]; if (typeof spaceId !== "number") assert.fail("spaceId not a number"); - const client = await getLoggedinDatabase(spaceId); + const client = await getLoggedinDatabaseForUsername(userName); const response = await client.rpc("upsert_concepts", { v_space_id: spaceId, data, From 24189d40d461455c9309811e26726aecfec5c4e0 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 25 Jun 2026 14:20:38 -0400 Subject: [PATCH 3/3] format --- packages/database/features/groupAccess.feature | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/database/features/groupAccess.feature b/packages/database/features/groupAccess.feature index 59b4a3238..dd1b4deb6 100644 --- a/packages/database/features/groupAccess.feature +++ b/packages/database/features/groupAccess.feature @@ -23,12 +23,12 @@ Feature: Group content access Scenario: Sharing content When Document are added to the database: | $id | source_local_id | created | last_modified | _author_id | _space_id | - | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | - | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | And Content are added to the database: | $id | source_local_id | _document_id | text | created | last_modified | scale | _author_id | _space_id | - | ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | - | ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct1 | lct1 | d1 | Claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct2 | lct2 | d2 | Claim 2 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | Then a user logged in space s1 should see 2 PlatformAccount in the database And a user logged in space s1 should see 2 Content in the database And a user logged in space s2 should see 2 PlatformAccount in the database