From f14e28ba6ba25e86342f23e2671e1f15ba3bf103 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 3 Jul 2024 15:43:24 -0400 Subject: [PATCH] re-architect access around permission tokens Tokens give you permissions over a set of entities. When an entity is created it defines its set. Permissions are, read, write, change_entity_set, and create_token. The latter two are unused as of now. Tokens also define their "root entities" so they can be used directly in links to give access. This may later prove to be a bad idea, but works for now! In the code we add the context of EntitySetProvider. For now there is just on at the top level of a doc, but we will need to wrap other sections of the tree if we implement scoped permissions. --- app/[doc_id]/Doc.tsx | 41 ++-- app/[doc_id]/page.tsx | 32 ++- app/page.tsx | 49 ++++- components/BlockOptions.tsx | 6 + components/Blocks.tsx | 9 + components/EntitySetProvider.tsx | 28 +++ components/TextBlock/index.tsx | 24 ++- components/TextBlock/keymap.ts | 5 +- components/UpdateURL.tsx | 8 +- drizzle/relations.ts | 35 +++- drizzle/schema.ts | 140 +++++-------- src/replicache/clientMutationContext.ts | 4 +- src/replicache/index.tsx | 19 +- src/replicache/mutations.ts | 11 +- src/replicache/push.ts | 13 +- src/replicache/serverMutationContext.ts | 50 ++++- supabase/database.types.ts | 94 +++++++++ .../20240703183954_add_permission_system.sql | 184 ++++++++++++++++++ 18 files changed, 613 insertions(+), 139 deletions(-) create mode 100644 components/EntitySetProvider.tsx create mode 100644 supabase/migrations/20240703183954_add_permission_system.sql diff --git a/app/[doc_id]/Doc.tsx b/app/[doc_id]/Doc.tsx index 60f3b033..b4798602 100644 --- a/app/[doc_id]/Doc.tsx +++ b/app/[doc_id]/Doc.tsx @@ -1,4 +1,4 @@ -import { Fact, ReplicacheProvider } from "src/replicache"; +import { Fact, PermissionToken, ReplicacheProvider } from "src/replicache"; import { Database } from "../../supabase/database.types"; import { Attributes } from "src/replicache/attributes"; import { createServerClient } from "@supabase/ssr"; @@ -8,24 +8,37 @@ import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; import { MobileFooter } from "components/MobileFooter"; import { PopUpProvider } from "components/Toast"; import { YJSFragmentToString } from "components/TextBlock/RenderYJSFragment"; +import { + EntitySetContext, + EntitySetProvider, +} from "components/EntitySetProvider"; export function Doc(props: { + token: PermissionToken; initialFacts: Fact[]; doc_id: string; }) { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/app/[doc_id]/page.tsx b/app/[doc_id]/page.tsx index 5e6f3c5c..5277476f 100644 --- a/app/[doc_id]/page.tsx +++ b/app/[doc_id]/page.tsx @@ -24,21 +24,41 @@ let supabase = createServerClient( { cookies: {} }, ); type Props = { + // this is now a token id not doc! Should probs rename params: { doc_id: string }; }; export default async function DocumentPage(props: Props) { - let { data } = await supabase.rpc("get_facts", { root: props.params.doc_id }); + let res = await supabase + .from("permission_tokens") + .select("*, permission_token_rights(*)") + .eq("id", props.params.doc_id) + .single(); + let rootEntity = res.data?.root_entity; + if (!rootEntity || !res.data) + return
404 no rootEntity found idk man
; + let { data } = await supabase.rpc("get_facts", { + root: rootEntity, + }); let initialFacts = (data as unknown as Fact[]) || []; - return ; + return ( + + ); } export async function generateMetadata(props: Props): Promise { - let { data } = await supabase.rpc("get_facts", { root: props.params.doc_id }); + let res = await supabase + .from("permission_tokens") + .select("*, permission_token_rights(*)") + .eq("id", props.params.doc_id) + .single(); + let rootEntity = res.data?.root_entity; + if (!rootEntity || !res.data) return { title: "Doc not found" }; + let { data } = await supabase.rpc("get_facts", { + root: rootEntity, + }); let initialFacts = (data as unknown as Fact[]) || []; let blocks = initialFacts - .filter( - (f) => f.attribute === "card/block" && f.entity === props.params.doc_id, - ) + .filter((f) => f.attribute === "card/block" && f.entity === rootEntity) .map((_f) => { let block = _f as Fact<"card/block">; let type = initialFacts.find( diff --git a/app/page.tsx b/app/page.tsx index 4d326c36..bbd23bd2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,10 @@ import { drizzle } from "drizzle-orm/postgres-js"; -import { entities } from "drizzle/schema"; +import { + entities, + permission_tokens, + permission_token_rights, + entity_sets, +} from "drizzle/schema"; import { redirect } from "next/navigation"; import postgres from "postgres"; import { Doc } from "./[doc_id]/Doc"; @@ -12,11 +17,47 @@ export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; export default async function RootPage() { - let rows = await db.insert(entities).values({}).returning(); + // Creating a new document + let { permissionToken, rights, entity, entity_set } = await db.transaction( + async (tx) => { + // Create a new entity set + let [entity_set] = await tx.insert(entity_sets).values({}).returning(); + // Create a root-entity + let [entity] = await tx + .insert(entities) + // And add it to that permission set + .values({ set: entity_set.id }) + .returning(); + //Create a new permission token + let [permissionToken] = await tx + .insert(permission_tokens) + .values({ root_entity: entity.id }) + .returning(); + //and give it all the permission on that entity set + let [rights] = await tx + .insert(permission_token_rights) + .values({ + token: permissionToken.id, + entity_set: entity_set.id, + read: true, + write: true, + create_token: true, + change_entity_set: true, + }) + .returning(); + return { permissionToken, rights, entity, entity_set }; + }, + ); + // Here i need to pass the permission token instead of the doc_id + // In the replicache provider I guess I need to fetch the relevant stuff of the permission token? return ( <> - - + + ); } diff --git a/components/BlockOptions.tsx b/components/BlockOptions.tsx index e9d6bd62..6e36b57f 100644 --- a/components/BlockOptions.tsx +++ b/components/BlockOptions.tsx @@ -13,6 +13,7 @@ import { focusCard } from "./Cards"; import { useState } from "react"; import { Separator } from "./Layout"; import { addLinkBlock } from "src/utils/addLinkBlock"; +import { useEntitySetContext } from "./EntitySetProvider"; type Props = { parent: string; @@ -23,6 +24,7 @@ type Props = { }; export function BlockOptions(props: Props) { let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); let focusedElement = useUIState((s) => s.focusedBlock); let focusedCardID = @@ -51,6 +53,7 @@ export function BlockOptions(props: Props) { entity = crypto.randomUUID(); await rep?.mutate.addBlock({ parent: props.parent, + permission_set: entity_set.set, type: "text", position: generateKeyBetween( props.position, @@ -81,6 +84,7 @@ export function BlockOptions(props: Props) { let entity = crypto.randomUUID(); await rep?.mutate.addBlock({ + permission_set: entity_set.set, parent: props.parent, type: "card", position: generateKeyBetween( @@ -110,6 +114,7 @@ export function BlockOptions(props: Props) { } const BlockLinkButton = (props: Props) => { + let entity_set = useEntitySetContext(); let [linkOpen, setLinkOpen] = useState(false); let [linkValue, setLinkValue] = useState(""); let { rep } = useReplicache(); @@ -119,6 +124,7 @@ const BlockLinkButton = (props: Props) => { entity = crypto.randomUUID(); await rep?.mutate.addBlock({ + permission_set: entity_set.set, parent: props.parent, type: "card", position: generateKeyBetween(props.position, props.nextPosition), diff --git a/components/Blocks.tsx b/components/Blocks.tsx index b53763ad..7de425a0 100644 --- a/components/Blocks.tsx +++ b/components/Blocks.tsx @@ -13,6 +13,7 @@ import { ExternalLinkBlock } from "./ExternalLinkBlock"; import { BlockOptions } from "./BlockOptions"; import { useBlocks } from "src/hooks/queries/useBlocks"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; +import { useEntitySetContext } from "./EntitySetProvider"; export type Block = { parent: string; @@ -22,6 +23,7 @@ export type Block = { }; export function Blocks(props: { entityID: string }) { let rep = useReplicache(); + let entity_set = useEntitySetContext(); let blocks = useBlocks(props.entityID); let lastBlock = blocks[blocks.length - 1]; @@ -38,6 +40,7 @@ export function Blocks(props: { entityID: string }) { let newEntityID = crypto.randomUUID(); await rep.rep?.mutate.addBlock({ parent: props.entityID, + permission_set: entity_set.set, type: "text", position: generateKeyBetween(lastBlock.position || null, null), newEntityID, @@ -77,6 +80,7 @@ export function Blocks(props: { entityID: string }) { focusBlock({ ...lastBlock, type: "text" }, { type: "end" }); } else { rep?.rep?.mutate.addBlock({ + permission_set: entity_set.set, parent: props.entityID, type: "text", position: generateKeyBetween(lastBlock?.position || null, null), @@ -97,6 +101,7 @@ export function Blocks(props: { entityID: string }) { function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); let textContent = useEntity( props.lastBlock?.type === "text" ? props.lastBlock.value : null, "block/text", @@ -115,6 +120,7 @@ function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { await rep?.mutate.addBlock({ parent: props.entityID, type: "text", + permission_set: entity_set.set, position: generateKeyBetween( props.lastBlock?.position || null, null, @@ -161,6 +167,7 @@ function Block(props: BlockProps) { ); let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); useEffect(() => { if (!selected || !rep) return; let r = rep; @@ -198,6 +205,7 @@ function Block(props: BlockProps) { if (e.key === "Enter") { let newEntityID = crypto.randomUUID(); r.mutate.addBlock({ + permission_set: entity_set.set, newEntityID, parent: props.parent, type: "text", @@ -215,6 +223,7 @@ function Block(props: BlockProps) { window.addEventListener("keydown", listener); return () => window.removeEventListener("keydown", listener); }, [ + entity_set, selected, props.entityID, props.nextBlock, diff --git a/components/EntitySetProvider.tsx b/components/EntitySetProvider.tsx new file mode 100644 index 00000000..555af619 --- /dev/null +++ b/components/EntitySetProvider.tsx @@ -0,0 +1,28 @@ +"use client"; +import { createContext, useContext } from "react"; +import { useReplicache } from "src/replicache"; + +export const EntitySetContext = createContext({ + set: "", + permissions: { read: false, write: false }, +}); +export const useEntitySetContext = () => useContext(EntitySetContext); + +export function EntitySetProvider(props: { + set: string; + children: React.ReactNode; +}) { + let { permission_token } = useReplicache(); + return ( + r.entity_set === props.set, + ) || { read: false, write: false }, + }} + > + {props.children} + + ); +} diff --git a/components/TextBlock/index.tsx b/components/TextBlock/index.tsx index f1098d51..d7d0c87b 100644 --- a/components/TextBlock/index.tsx +++ b/components/TextBlock/index.tsx @@ -37,13 +37,15 @@ import { isIOS } from "@react-aria/utils"; import { useIsMobile } from "src/hooks/isMobile"; import { setMark } from "src/utils/prosemirror/setMark"; import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; +import { useEntitySetContext } from "components/EntitySetProvider"; export function TextBlock(props: BlockProps & { className: string }) { let initialized = useInitialPageLoad(); let first = props.previousBlock === null; + let permission = useEntitySetContext().permissions.write; return ( <> - {!initialized && ( + {(!initialized || !permission) && ( )} -
- - -
+ {permission && ( +
+ + +
+ )} ); } @@ -139,10 +143,11 @@ export function BaseTextBlock(props: BlockProps & { className: string }) { let [value, factID] = useYJSValue(props.entityID); let repRef = useRef>(null); - let propsRef = useRef(props); + let entity_set = useEntitySetContext(); + let propsRef = useRef({ ...props, entity_set }); useEffect(() => { - propsRef.current = props; - }, [props]); + propsRef.current = { ...props, entity_set }; + }, [props, entity_set]); let rep = useReplicache(); useEffect(() => { repRef.current = rep.rep; @@ -244,6 +249,7 @@ export function BaseTextBlock(props: BlockProps & { className: string }) { propsRef.current.nextPosition, ); repRef.current?.mutate.addBlock({ + permission_set: entity_set.set, newEntityID: entityID, parent: propsRef.current.parent, type: type, @@ -297,6 +303,7 @@ export function BaseTextBlock(props: BlockProps & { className: string }) { propsRef.current.nextPosition, ); repRef.current?.mutate.addBlock({ + permission_set: entity_set.set, newEntityID, parent: propsRef.current.parent, type: "text", @@ -332,6 +339,7 @@ export function BaseTextBlock(props: BlockProps & { className: string }) { } else { entity = crypto.randomUUID(); rep.rep.mutate.addBlock({ + permission_set: entity_set.set, type: "image", newEntityID: entity, parent: props.parent, diff --git a/components/TextBlock/keymap.ts b/components/TextBlock/keymap.ts index b9072304..dd1772a7 100644 --- a/components/TextBlock/keymap.ts +++ b/components/TextBlock/keymap.ts @@ -13,7 +13,7 @@ import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { focusCard } from "components/Cards"; export const TextBlockKeymap = ( - propsRef: MutableRefObject, + propsRef: MutableRefObject, repRef: MutableRefObject | null>, ) => keymap({ @@ -240,7 +240,7 @@ const backspace = const enter = ( - propsRef: MutableRefObject, + propsRef: MutableRefObject, repRef: MutableRefObject | null>, ) => (state: EditorState, dispatch?: (tr: Transaction) => void) => { @@ -255,6 +255,7 @@ const enter = ); repRef.current?.mutate.addBlock({ newEntityID, + permission_set: propsRef.current.entity_set.set, parent: propsRef.current.parent, type: "text", position, diff --git a/components/UpdateURL.tsx b/components/UpdateURL.tsx index 64ef1295..8f35212a 100644 --- a/components/UpdateURL.tsx +++ b/components/UpdateURL.tsx @@ -1,9 +1,11 @@ "use client"; +import { useRouter } from "next/navigation"; import { useEffect } from "react"; export function UpdateURL(props: { url: string }) { + let router = useRouter(); useEffect(() => { - window.history.replaceState(null, "", props.url); - }, [props.url]); - return null; + router.replace(props.url); + }, [props.url, router]); + return <>; } diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 3fde2f54..69d279a8 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,27 @@ import { relations } from "drizzle-orm/relations"; -import { entities, facts } from "./schema"; +import { entity_sets, entities, permission_tokens, facts, permission_token_rights } from "./schema"; + +export const entitiesRelations = relations(entities, ({one, many}) => ({ + entity_set: one(entity_sets, { + fields: [entities.set], + references: [entity_sets.id] + }), + permission_tokens: many(permission_tokens), + facts: many(facts), +})); + +export const entity_setsRelations = relations(entity_sets, ({many}) => ({ + entities: many(entities), + permission_token_rights: many(permission_token_rights), +})); + +export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ + entity: one(entities, { + fields: [permission_tokens.root_entity], + references: [entities.id] + }), + permission_token_rights: many(permission_token_rights), +})); export const factsRelations = relations(facts, ({one}) => ({ entity: one(entities, { @@ -8,6 +30,13 @@ export const factsRelations = relations(facts, ({one}) => ({ }), })); -export const entitiesRelations = relations(entities, ({many}) => ({ - facts: many(facts), +export const permission_token_rightsRelations = relations(permission_token_rights, ({one}) => ({ + entity_set: one(entity_sets, { + fields: [permission_token_rights.entity_set], + references: [entity_sets.id] + }), + permission_token: one(permission_tokens, { + fields: [permission_token_rights.token], + references: [permission_tokens.id] + }), })); \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 3951d0d2..13b7c8ba 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,95 +1,63 @@ -import { - pgTable, - pgEnum, - uuid, - timestamp, - text, - bigint, - foreignKey, - jsonb, -} from "drizzle-orm/pg-core"; -import { Fact } from "src/replicache"; -import { Attributes } from "src/replicache/attributes"; +import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, jsonb, primaryKey, boolean } from "drizzle-orm/pg-core" + import { sql } from "drizzle-orm" -export const aal_level = pgEnum("aal_level", ["aal1", "aal2", "aal3"]); -export const code_challenge_method = pgEnum("code_challenge_method", [ - "s256", - "plain", -]); -export const factor_status = pgEnum("factor_status", [ - "unverified", - "verified", -]); -export const factor_type = pgEnum("factor_type", ["totp", "webauthn"]); -export const request_status = pgEnum("request_status", [ - "PENDING", - "SUCCESS", - "ERROR", -]); -export const key_status = pgEnum("key_status", [ - "default", - "valid", - "invalid", - "expired", -]); -export const key_type = pgEnum("key_type", [ - "aead-ietf", - "aead-det", - "hmacsha512", - "hmacsha256", - "auth", - "shorthash", - "generichash", - "kdf", - "secretbox", - "secretstream", - "stream_xchacha20", -]); -export const action = pgEnum("action", [ - "INSERT", - "UPDATE", - "DELETE", - "TRUNCATE", - "ERROR", -]); -export const equality_op = pgEnum("equality_op", [ - "eq", - "neq", - "lt", - "lte", - "gt", - "gte", - "in", -]); +export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) +export const code_challenge_method = pgEnum("code_challenge_method", ['s256', 'plain']) +export const factor_status = pgEnum("factor_status", ['unverified', 'verified']) +export const factor_type = pgEnum("factor_type", ['totp', 'webauthn']) +export const one_time_token_type = pgEnum("one_time_token_type", ['confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token']) +export const request_status = pgEnum("request_status", ['PENDING', 'SUCCESS', 'ERROR']) +export const key_status = pgEnum("key_status", ['default', 'valid', 'invalid', 'expired']) +export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512', 'hmacsha256', 'auth', 'shorthash', 'generichash', 'kdf', 'secretbox', 'secretstream', 'stream_xchacha20']) +export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) +export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) + + +export const replicache_clients = pgTable("replicache_clients", { + client_id: text("client_id").primaryKey().notNull(), + client_group: text("client_group").notNull(), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), +}); export const entities = pgTable("entities", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - created_at: timestamp("created_at", { withTimezone: true, mode: "string" }) - .defaultNow() - .notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), }); -export const replicache_clients = pgTable("replicache_clients", { - client_id: text("client_id").primaryKey().notNull(), - client_group: text("client_group").notNull(), - // You can use { mode: "bigint" } if numbers are exceeding js number limitations - last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), +export const entity_sets = pgTable("entity_sets", { + id: uuid("id").defaultRandom().primaryKey().notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + +export const permission_tokens = pgTable("permission_tokens", { + id: uuid("id").defaultRandom().primaryKey().notNull(), + root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), }); export const facts = pgTable("facts", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - entity: uuid("entity") - .notNull() - .references(() => entities.id, { - onDelete: "cascade", - onUpdate: "restrict", - }), - attribute: text("attribute").notNull().$type(), - data: jsonb("data").notNull().$type["data"]>(), - created_at: timestamp("created_at", { mode: "string" }) - .defaultNow() - .notNull(), - updated_at: timestamp("updated_at", { mode: "string" }), - // You can use { mode: "bigint" } if numbers are exceeding js number limitations - version: bigint("version", { mode: "number" }).default(0).notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), + attribute: text("attribute").notNull(), + data: jsonb("data").notNull(), + created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp("updated_at", { mode: 'string' }), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + version: bigint("version", { mode: "number" }).default(0).notNull(), }); + +export const permission_token_rights = pgTable("permission_token_rights", { + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + entity_set: uuid("entity_set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), + read: boolean("read").default(false).notNull(), + write: boolean("write").default(false).notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + create_token: boolean("create_token").default(false).notNull(), + change_entity_set: boolean("change_entity_set").default(false).notNull(), +}, +(table) => { + return { + permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), + } +}); \ No newline at end of file diff --git a/src/replicache/clientMutationContext.ts b/src/replicache/clientMutationContext.ts index 57b5341f..afc275a1 100644 --- a/src/replicache/clientMutationContext.ts +++ b/src/replicache/clientMutationContext.ts @@ -14,8 +14,8 @@ export function clientMutationContext(tx: WriteTransaction) { let supabase = supabaseBrowserClient(); return cb({ supabase }); }, - async createEntity(_entityID) { - tx.set(_entityID, true); + async createEntity({ entityID }) { + tx.set(entityID, true); return true; }, scanIndex: { diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx index b662facb..67328c30 100644 --- a/src/replicache/index.tsx +++ b/src/replicache/index.tsx @@ -19,6 +19,7 @@ export type Fact = { let ReplicacheContext = createContext({ rep: null as null | Replicache, initialFacts: [] as Fact[], + permission_token: {} as PermissionToken, }); export function useReplicache() { return useContext(ReplicacheContext); @@ -29,8 +30,18 @@ export type ReplicacheMutators = { args: Parameters<(typeof mutations)[k]>[0], ) => Promise; }; + +export type PermissionToken = { + id: string; + permission_token_rights: { + entity_set: string; + read: boolean; + write: boolean; + }[]; +}; export function ReplicacheProvider(props: { initialFacts: Fact[]; + token: PermissionToken; name: string; children: React.ReactNode; }) { @@ -55,7 +66,7 @@ export function ReplicacheProvider(props: { licenseKey: "l381074b8d5224dabaef869802421225a", pusher: async (pushRequest) => { return { - response: await Push(pushRequest, props.name), + response: await Push(pushRequest, props.name, props.token), httpRequestInfo: { errorMessage: "", httpStatusCode: 200 }, }; }, @@ -87,7 +98,11 @@ export function ReplicacheProvider(props: { }, [props.name]); return ( {props.children} diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 72857705..804b3e69 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -5,7 +5,10 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Database } from "supabase/database.types"; export type MutationContext = { - createEntity: (entityID: string) => Promise; + createEntity: (args: { + entityID: string; + permission_set: string; + }) => Promise; scanIndex: { eav: ( entity: string, @@ -29,11 +32,15 @@ type Mutation = (args: T, ctx: MutationContext) => Promise; const addBlock: Mutation<{ parent: string; + permission_set: string; type: Fact<"block/type">["data"]["value"]; newEntityID: string; position: string; }> = async (args, ctx) => { - await ctx.createEntity(args.newEntityID); + await ctx.createEntity({ + entityID: args.newEntityID, + permission_set: args.permission_set, + }); await ctx.assertFact({ entity: args.parent, data: { diff --git a/src/replicache/push.ts b/src/replicache/push.ts index 53776aa1..b9a0ba6b 100644 --- a/src/replicache/push.ts +++ b/src/replicache/push.ts @@ -3,8 +3,9 @@ import { PushRequest, PushResponse } from "replicache"; import { serverMutationContext } from "./serverMutationContext"; import { mutations } from "./mutations"; import { drizzle } from "drizzle-orm/postgres-js"; +import { eq } from "drizzle-orm"; import postgres from "postgres"; -import { replicache_clients } from "drizzle/schema"; +import { permission_token_rights, replicache_clients } from "drizzle/schema"; import { getClientGroup } from "./utils"; import { createClient } from "@supabase/supabase-js"; import { Database } from "supabase/database.types"; @@ -18,10 +19,15 @@ const db = drizzle(client); export async function Push( pushRequest: PushRequest, rootEntity: string, + token: { id: string }, ): Promise { if (pushRequest.pushVersion !== 1) return { error: "VersionNotSupported", versionType: "push" }; let clientGroup = await getClientGroup(db, pushRequest.clientGroupID); + let token_rights = await db + .select() + .from(permission_token_rights) + .where(eq(permission_token_rights.token, token.id)); for (let mutation of pushRequest.mutations) { let lastMutationID = clientGroup[mutation.clientID] || 0; if (mutation.id <= lastMutationID) continue; @@ -32,7 +38,10 @@ export async function Push( } await db.transaction(async (tx) => { try { - await mutations[name](mutation.args as any, serverMutationContext(tx)); + await mutations[name]( + mutation.args as any, + serverMutationContext(tx, token_rights), + ); } catch (e) { console.log( `Error occured while running mutation: ${name}`, diff --git a/src/replicache/serverMutationContext.ts b/src/replicache/serverMutationContext.ts index 0eae62c3..6dcacc27 100644 --- a/src/replicache/serverMutationContext.ts +++ b/src/replicache/serverMutationContext.ts @@ -5,12 +5,17 @@ import * as Y from "yjs"; import { MutationContext } from "./mutations"; import { entities, facts } from "drizzle/schema"; import { Attributes, FilterAttributes } from "./attributes"; -import { Fact } from "."; +import { Fact, PermissionToken } from "."; import { DeepReadonly } from "replicache"; import { createClient } from "@supabase/supabase-js"; import { Database } from "supabase/database.types"; -export function serverMutationContext(tx: PgTransaction) { - let ctx: MutationContext = { +export function serverMutationContext( + tx: PgTransaction, + token_rights: PermissionToken["permission_token_rights"], +) { + let ctx: MutationContext & { + checkPermission: (entity: string) => Promise; + } = { async runOnServer(cb) { let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -18,14 +23,37 @@ export function serverMutationContext(tx: PgTransaction) { ); return cb({ supabase }); }, + async checkPermission(entity: string) { + let [permission_set] = await tx + .select({ entity_set: entities.set }) + .from(entities) + .where(driz.eq(entities.id, entity)); + return ( + !!permission_set && + !!token_rights.find( + (r) => r.entity_set === permission_set.entity_set && r.write == true, + ) + ); + }, async runOnClient(_cb) {}, - async createEntity(entity) { + async createEntity({ entityID, permission_set }) { + if ( + !token_rights.find( + (r) => r.entity_set === permission_set && r.write === true, + ) + ) { + console.log("NO RIGHT???"); + console.log(token_rights); + console.log(permission_set); + return false; + } await tx.transaction( async (tx2) => await tx2 .insert(entities) .values({ - id: entity, + set: permission_set, + id: entityID, }) .catch(console.log), ); @@ -54,6 +82,11 @@ export function serverMutationContext(tx: PgTransaction) { if (!attribute) return; let id = f.id || crypto.randomUUID(); let data = { ...f.data }; + let [permission_set] = await tx + .select({ entity_set: entities.set }) + .from(entities) + .where(driz.eq(entities.id, f.entity)); + if (!this.checkPermission(f.entity)) return; if (attribute.cardinality === "one") { let existingFact = await tx .select({ id: facts.id, data: facts.data }) @@ -104,9 +137,16 @@ export function serverMutationContext(tx: PgTransaction) { ); }, async retractFact(id) { + let [f] = await tx + .select() + .from(facts) + .rightJoin(entities, driz.eq(entities.id, facts.entity)) + .where(driz.eq(facts.id, id)); + if (!f || !this.checkPermission(f.entities.id)) return; await tx.delete(facts).where(driz.eq(facts.id, id)); }, async deleteEntity(entity) { + if (!this.checkPermission(entity)) return; await Promise.all([ tx.delete(entities).where(driz.eq(entities.id, entity)), tx diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 126af9c9..3b4bd810 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -35,6 +35,32 @@ export type Database = { public: { Tables: { entities: { + Row: { + created_at: string + id: string + set: string + } + Insert: { + created_at?: string + id?: string + set: string + } + Update: { + created_at?: string + id?: string + set?: string + } + Relationships: [ + { + foreignKeyName: "entities_set_fkey" + columns: ["set"] + isOneToOne: false + referencedRelation: "entity_sets" + referencedColumns: ["id"] + }, + ] + } + entity_sets: { Row: { created_at: string id: string @@ -87,6 +113,74 @@ export type Database = { }, ] } + permission_token_rights: { + Row: { + change_entity_set: boolean + create_token: boolean + created_at: string + entity_set: string + read: boolean + token: string + write: boolean + } + Insert: { + change_entity_set?: boolean + create_token?: boolean + created_at?: string + entity_set: string + read?: boolean + token: string + write?: boolean + } + Update: { + change_entity_set?: boolean + create_token?: boolean + created_at?: string + entity_set?: string + read?: boolean + token?: string + write?: boolean + } + Relationships: [ + { + foreignKeyName: "permission_token_rights_entity_set_fkey" + columns: ["entity_set"] + isOneToOne: false + referencedRelation: "entity_sets" + referencedColumns: ["id"] + }, + { + foreignKeyName: "permission_token_rights_token_fkey" + columns: ["token"] + isOneToOne: false + referencedRelation: "permission_tokens" + referencedColumns: ["id"] + }, + ] + } + permission_tokens: { + Row: { + id: string + root_entity: string + } + Insert: { + id?: string + root_entity: string + } + Update: { + id?: string + root_entity?: string + } + Relationships: [ + { + foreignKeyName: "permission_tokens_root_entity_fkey" + columns: ["root_entity"] + isOneToOne: false + referencedRelation: "entities" + referencedColumns: ["id"] + }, + ] + } replicache_clients: { Row: { client_group: string diff --git a/supabase/migrations/20240703183954_add_permission_system.sql b/supabase/migrations/20240703183954_add_permission_system.sql new file mode 100644 index 00000000..1231f391 --- /dev/null +++ b/supabase/migrations/20240703183954_add_permission_system.sql @@ -0,0 +1,184 @@ +create table "public"."entity_sets" ( + "id" uuid not null default gen_random_uuid(), + "created_at" timestamp with time zone not null default now() +); + + +alter table "public"."entity_sets" enable row level security; + +create table "public"."permission_token_rights" ( + "token" uuid not null, + "entity_set" uuid not null, + "read" boolean not null default false, + "write" boolean not null default false, + "created_at" timestamp with time zone not null default now(), + "create_token" boolean not null default false, + "change_entity_set" boolean not null default false +); + + +alter table "public"."permission_token_rights" enable row level security; + +create table "public"."permission_tokens" ( + "id" uuid not null default gen_random_uuid(), + "root_entity" uuid not null +); + + +alter table "public"."permission_tokens" enable row level security; + +alter table "public"."entities" add column "set" uuid not null; + +CREATE UNIQUE INDEX entity_sets_pkey ON public.entity_sets USING btree (id); + +CREATE UNIQUE INDEX permission_token_rights_pkey ON public.permission_token_rights USING btree (token, entity_set); + +CREATE UNIQUE INDEX permission_tokens_pkey ON public.permission_tokens USING btree (id); + +alter table "public"."entity_sets" add constraint "entity_sets_pkey" PRIMARY KEY using index "entity_sets_pkey"; + +alter table "public"."permission_token_rights" add constraint "permission_token_rights_pkey" PRIMARY KEY using index "permission_token_rights_pkey"; + +alter table "public"."permission_tokens" add constraint "permission_tokens_pkey" PRIMARY KEY using index "permission_tokens_pkey"; + +alter table "public"."entities" add constraint "entities_set_fkey" FOREIGN KEY (set) REFERENCES entity_sets(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."entities" validate constraint "entities_set_fkey"; + +alter table "public"."permission_token_rights" add constraint "permission_token_rights_entity_set_fkey" FOREIGN KEY (entity_set) REFERENCES entity_sets(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."permission_token_rights" validate constraint "permission_token_rights_entity_set_fkey"; + +alter table "public"."permission_token_rights" add constraint "permission_token_rights_token_fkey" FOREIGN KEY (token) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."permission_token_rights" validate constraint "permission_token_rights_token_fkey"; + +alter table "public"."permission_tokens" add constraint "permission_tokens_root_entity_fkey" FOREIGN KEY (root_entity) REFERENCES entities(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."permission_tokens" validate constraint "permission_tokens_root_entity_fkey"; + +grant delete on table "public"."entity_sets" to "anon"; + +grant insert on table "public"."entity_sets" to "anon"; + +grant references on table "public"."entity_sets" to "anon"; + +grant select on table "public"."entity_sets" to "anon"; + +grant trigger on table "public"."entity_sets" to "anon"; + +grant truncate on table "public"."entity_sets" to "anon"; + +grant update on table "public"."entity_sets" to "anon"; + +grant delete on table "public"."entity_sets" to "authenticated"; + +grant insert on table "public"."entity_sets" to "authenticated"; + +grant references on table "public"."entity_sets" to "authenticated"; + +grant select on table "public"."entity_sets" to "authenticated"; + +grant trigger on table "public"."entity_sets" to "authenticated"; + +grant truncate on table "public"."entity_sets" to "authenticated"; + +grant update on table "public"."entity_sets" to "authenticated"; + +grant delete on table "public"."entity_sets" to "service_role"; + +grant insert on table "public"."entity_sets" to "service_role"; + +grant references on table "public"."entity_sets" to "service_role"; + +grant select on table "public"."entity_sets" to "service_role"; + +grant trigger on table "public"."entity_sets" to "service_role"; + +grant truncate on table "public"."entity_sets" to "service_role"; + +grant update on table "public"."entity_sets" to "service_role"; + +grant delete on table "public"."permission_token_rights" to "anon"; + +grant insert on table "public"."permission_token_rights" to "anon"; + +grant references on table "public"."permission_token_rights" to "anon"; + +grant select on table "public"."permission_token_rights" to "anon"; + +grant trigger on table "public"."permission_token_rights" to "anon"; + +grant truncate on table "public"."permission_token_rights" to "anon"; + +grant update on table "public"."permission_token_rights" to "anon"; + +grant delete on table "public"."permission_token_rights" to "authenticated"; + +grant insert on table "public"."permission_token_rights" to "authenticated"; + +grant references on table "public"."permission_token_rights" to "authenticated"; + +grant select on table "public"."permission_token_rights" to "authenticated"; + +grant trigger on table "public"."permission_token_rights" to "authenticated"; + +grant truncate on table "public"."permission_token_rights" to "authenticated"; + +grant update on table "public"."permission_token_rights" to "authenticated"; + +grant delete on table "public"."permission_token_rights" to "service_role"; + +grant insert on table "public"."permission_token_rights" to "service_role"; + +grant references on table "public"."permission_token_rights" to "service_role"; + +grant select on table "public"."permission_token_rights" to "service_role"; + +grant trigger on table "public"."permission_token_rights" to "service_role"; + +grant truncate on table "public"."permission_token_rights" to "service_role"; + +grant update on table "public"."permission_token_rights" to "service_role"; + +grant delete on table "public"."permission_tokens" to "anon"; + +grant insert on table "public"."permission_tokens" to "anon"; + +grant references on table "public"."permission_tokens" to "anon"; + +grant select on table "public"."permission_tokens" to "anon"; + +grant trigger on table "public"."permission_tokens" to "anon"; + +grant truncate on table "public"."permission_tokens" to "anon"; + +grant update on table "public"."permission_tokens" to "anon"; + +grant delete on table "public"."permission_tokens" to "authenticated"; + +grant insert on table "public"."permission_tokens" to "authenticated"; + +grant references on table "public"."permission_tokens" to "authenticated"; + +grant select on table "public"."permission_tokens" to "authenticated"; + +grant trigger on table "public"."permission_tokens" to "authenticated"; + +grant truncate on table "public"."permission_tokens" to "authenticated"; + +grant update on table "public"."permission_tokens" to "authenticated"; + +grant delete on table "public"."permission_tokens" to "service_role"; + +grant insert on table "public"."permission_tokens" to "service_role"; + +grant references on table "public"."permission_tokens" to "service_role"; + +grant select on table "public"."permission_tokens" to "service_role"; + +grant trigger on table "public"."permission_tokens" to "service_role"; + +grant truncate on table "public"."permission_tokens" to "service_role"; + +grant update on table "public"."permission_tokens" to "service_role"; -- 2.51.2