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
26 changes: 26 additions & 0 deletions packages/database/features/readOnlyViews.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Feature: Read-only access views
User story:
* As a user of a plugin
* Logged in through a given space's anonymous account
* I can read spaces and accounts through the my_spaces and my_accounts views
* But I cannot write through them, since such writes would bypass the tables' RLS

Acceptance criteria:
* Inserts, updates and deletes through my_spaces and my_accounts fail with insufficient privilege
* Reads through both views still work

Background:
Given the database is blank
And the user user1 opens the Roam plugin in space s1

Scenario: Clients cannot write through my_spaces, even for their own space
Then a user logged in space s1 cannot insert spaces through my_spaces
And a user logged in space s1 cannot update spaces through my_spaces
And a user logged in space s1 cannot delete spaces through my_spaces
And a user logged in space s1 should see 1 my_spaces in the database

Scenario: Clients cannot write through my_accounts, even for their own accounts
Then a user logged in space s1 cannot insert accounts through my_accounts
And a user logged in space s1 cannot update accounts through my_accounts
And a user logged in space s1 cannot delete accounts through my_accounts
And a user logged in space s1 should see 2 my_accounts in the database
107 changes: 84 additions & 23 deletions packages/database/features/step-definitions/stepdefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,33 +380,94 @@ Then(
}),
);

type ViewWrites = Record<
"insert" | "update" | "delete",
() => PromiseLike<PostgrestSingleResponse<null>>
>;

// Each update and delete filter matches visible rows, so a granted write would succeed.
const expectWriteRejected = async (
operation: string,
writes: ViewWrites,
): Promise<void> => {
if (!Object.hasOwn(writes, operation))
assert.fail(`unknown operation ${operation}`);
const response = await writes[operation as keyof ViewWrites]();
assert.equal(response.error?.code, INSUFFICIENT_PRIVILEGE);
};

const getSpaceClient = async (
spaceName: string,
): Promise<{
spaceId: number;
client: Awaited<ReturnType<typeof getLoggedinDatabase>>;
}> => {
const localRefs = (world.localRefs || {}) as LocalRefsType;
const spaceId = localRefs[spaceName];
if (typeof spaceId !== "number") assert.fail("spaceId not a number");
return { spaceId, client: await getLoggedinDatabase(spaceId) };
};

Then(
"a user logged in space {word} cannot {word} concepts through my_concepts",
async (spaceName: string, operation: string) => {
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 { spaceId, client } = await getSpaceClient(spaceName);
const view = client.from("my_concepts");
let response: PostgrestSingleResponse<null>;
if (operation === "insert") {
const now = new Date().toISOString();
response = await view.insert({
name: "written through my_concepts",
space_id: spaceId,
created: now,
last_modified: now,
});
} else if (operation === "update") {
response = await view
.update({ name: "written through my_concepts" })
.eq("space_id", spaceId);
} else if (operation === "delete") {
response = await view.delete().eq("space_id", spaceId);
} else {
assert.fail(`unknown operation ${operation}`);
}
assert.equal(response.error?.code, INSUFFICIENT_PRIVILEGE);
const now = new Date().toISOString();
await expectWriteRejected(operation, {
insert: () =>
view.insert({
name: "written through my_concepts",
space_id: spaceId,
created: now,
last_modified: now,
}),
update: () =>
view
.update({ name: "written through my_concepts" })
.eq("space_id", spaceId),
delete: () => view.delete().eq("space_id", spaceId),
});
},
);

Then(
"a user logged in space {word} cannot {word} spaces through my_spaces",
async (spaceName: string, operation: string) => {
const { spaceId, client } = await getSpaceClient(spaceName);
const view = client.from("my_spaces");
await expectWriteRejected(operation, {
insert: () =>
view.insert({
name: "written through my_spaces",
url: "https://example.com/written-through-my-spaces",
platform: "Roam",
}),
update: () =>
view.update({ name: "written through my_spaces" }).eq("id", spaceId),
delete: () => view.delete().eq("id", spaceId),
});
},
);

Then(
"a user logged in space {word} cannot {word} accounts through my_accounts",
async (spaceName: string, operation: string) => {
const { client } = await getSpaceClient(spaceName);
const view = client.from("my_accounts");
await expectWriteRejected(operation, {
insert: () =>
view.insert({
name: "written through my_accounts",
account_local_id: "written-through-my-accounts",
platform: "Roam",
}),
update: () =>
view
.update({ name: "written through my_accounts" })
.eq("platform", "Roam"),
delete: () => view.delete().eq("platform", "Roam"),
});
},
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Single-table views are writable, and writes run as the view owner, bypassing Space's RLS.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.my_spaces FROM anon, authenticated;

-- Single-table views are writable, and writes run as the view owner, bypassing PlatformAccount's RLS.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.my_accounts FROM anon, authenticated;
6 changes: 6 additions & 0 deletions packages/database/supabase/schemas/account.sql
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,9 @@ SELECT
platform
FROM public."Space" WHERE id = any(public.my_space_ids('partial'));

-- Single-table views are writable, and writes run as the view owner, bypassing Space's RLS.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.my_spaces FROM anon, authenticated;

-- PlatformAccount: Access to anyone sharing a space with you to create an account, to allow editing authors
-- Once the account is claimed by a user, only allow this user to modify it.
-- Eventually: Allow platform admin to modify?
Expand Down Expand Up @@ -458,6 +461,9 @@ WHERE id IN (
SELECT id FROM public."PlatformAccount" WHERE dg_account = auth.uid()
);

-- Single-table views are writable, and writes run as the view owner, bypassing PlatformAccount's RLS.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.my_accounts FROM anon, authenticated;

CREATE OR REPLACE VIEW public.my_pseudo_accounts AS
SELECT
pa.id,
Expand Down
Loading