Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/database/features/groupAccess.feature
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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 |
Expand All @@ -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
59 changes: 46 additions & 13 deletions packages/database/features/step-definitions/stepdefs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}`,
Expand All @@ -227,7 +231,9 @@ When(
password: SPACE_ANONYMOUS_PASSWORD,
});
localRefs[userAccountId] = userId;
spaceOfUsername[userAccountId] = spaceId;
world.localRefs = localRefs;
world.spaceOfUsername = spaceOfUsername;
},
);

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -308,23 +324,40 @@ Given(
},
);

const upsertDocs = async (
userName: string,
spaceName: string,
docString: string,
): Promise<PostgrestSingleResponse<number[]>> => {
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}:",
Expand All @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions packages/database/src/dbTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
Loading