erquhart
erquhart5mo ago

Looks similar to Sara's solution, but a

Looks similar to Sara's solution, but a bit different:
├── convex/
│ ├── db # No endpoints (Convex functions) in this directory, "helpers" only
│ │ ├── index.ts # Barrel export, so the db object can be used across endpoints
│ │ ├── [tableName].ts # Simple table, everything in one file
│ │ ├── [tableName] # Table with a lot of operations, use a directory
│ │ │ ├── index.ts # Table schema, indexes, authorization stuff
│ │ │ ├── read.ts
│ │ │ ├── write.ts # Writes have a lot going on, worked well in a dedicated file
│ │ │ ├── delete.ts # Some tables with cascading deletes were busy enough for a dedicated deletes file
├── convex/
│ ├── db # No endpoints (Convex functions) in this directory, "helpers" only
│ │ ├── index.ts # Barrel export, so the db object can be used across endpoints
│ │ ├── [tableName].ts # Simple table, everything in one file
│ │ ├── [tableName] # Table with a lot of operations, use a directory
│ │ │ ├── index.ts # Table schema, indexes, authorization stuff
│ │ │ ├── read.ts
│ │ │ ├── write.ts # Writes have a lot going on, worked well in a dedicated file
│ │ │ ├── delete.ts # Some tables with cascading deletes were busy enough for a dedicated deletes file
10 Replies
Miak
Miak3w ago
@erquhart sorry to jump on this so much later but I'm dealing with structure right now and I'm wondering do you include your authorization checks within the db functions e.g. await checkIsAdmin or do you handle them outside in the queries/mutation functions e.g. export const list = adminQuery...
erquhart
erquhartOP3w ago
authorization is what got me started down this whole structuring path to begin with - the answer is sort of both. I'll get an example
Miak
Miak3w ago
Yeah I'm struggling with the pros/cons of each. On one hand, ideally it feels like it should be made impossible to query a protected record without an authorization check (putting it within the db function) which makes it such that nobody could accidentally use the wrong function within a public query for example. On the other hand putting it in the db functions prevents you from being able to use things like query/mutation wrappers which make things pretty clean/easy e.g. authenticatedQuery publicQuery etc. I suppose you could create functions that wrap the db functions similar to the query/mutation wrappers. Thanks for taking the time to respond!
erquhart
erquhartOP3w ago
Yeah, I didn't look much at function wrappers at the time, I may end up overhauling this at some point and doing some like that. To make authz work in a relatively simplistic way, I tied all authorization to a specific thing in my data model, let's call it a workspace. Every table then has a workspaceId, even if it isn't directly related. That allows me to do a direct authz check on any record for the current user. The top of most of my functions have something like this
const authzCtx = await db.workspace.authz(
await getAuthnCtx(ctx),
args.workspaceId,
)
const authzCtx = await db.workspace.authz(
await getAuthnCtx(ctx),
args.workspaceId,
)
But some functions don't actually need the workspace id in args, it might just need the id of some other thing. So each table has it's own authz function that knows how to take in an id from it's own table and a user id and determine whether the user has access. If the user has access, the resulting authzCtx is set for the workspace rather than the the specific table, if that makes sense.
const authzCtx = await db.otherTable.authz(
await getAuthnCtx(ctx),
args.otherTableId,
)

// convex/db/otherTable.ts
const authzQuery = async <Ctx extends AuthnCtx<QueryOrMutationCtx>>(
ctx: Ctx,
id: Id<'otherTable'>,
) => {
const doc = await ctx.db.get(id)
return getAuthzCtx(ctx, doc.budgetId)
}

// convex/authz.ts
// This is the core authz function, it checks for a userWorkspace record
// for the currently authenticated user and the given workspaceId.
export const getAuthzCtx = async <Ctx extends AuthnCtx<QueryOrMutationCtx>>(
ctx: Ctx,
workspaceId: Id<'workspaces'>,
) => {
const userWorkspace = await db.userWorkspace.getByWorkspaceId(ctx, budgetId)
const authzCtx = createAuthzCtx(ctx, {
users: userWorkspace.userId,
workspaces: userWorkspace.workspaceId,
})
return authzCtx
}
const authzCtx = await db.otherTable.authz(
await getAuthnCtx(ctx),
args.otherTableId,
)

// convex/db/otherTable.ts
const authzQuery = async <Ctx extends AuthnCtx<QueryOrMutationCtx>>(
ctx: Ctx,
id: Id<'otherTable'>,
) => {
const doc = await ctx.db.get(id)
return getAuthzCtx(ctx, doc.budgetId)
}

// convex/authz.ts
// This is the core authz function, it checks for a userWorkspace record
// for the currently authenticated user and the given workspaceId.
export const getAuthzCtx = async <Ctx extends AuthnCtx<QueryOrMutationCtx>>(
ctx: Ctx,
workspaceId: Id<'workspaces'>,
) => {
const userWorkspace = await db.userWorkspace.getByWorkspaceId(ctx, budgetId)
const authzCtx = createAuthzCtx(ctx, {
users: userWorkspace.userId,
workspaces: userWorkspace.workspaceId,
})
return authzCtx
}
The goal is to do this once at the top of the function, which requires a db hit and is asynchronous, and to get back an object that can be synchronously checked by all db functions for the rest of the call. The authzCtx just has a userId and a workspaceId in my case.
{
...ctx,
authz: {
users, // userId
workspaces, // workspaceId
},
}
{
...ctx,
authz: {
users, // userId
workspaces, // workspaceId
},
}
I don't know how much sense this makes lol, obviously some details missing. Let me know if any more specifics are helpful.
Miak
Miak3w ago
Interesting - yeah thats something that crossed my mind, because if you break everything up into nice reusable db functions how do you avoid ending up with repetitive calls to auth functions. Good to think about - thank you! At the moment for simplicity I'm leaning towards exporting top level functions that handle authorization calls e.g. getPostById but having reusable helper functions within the file that aren't exported.
erquhart
erquhartOP3w ago
If you keep the types strict you can make all of your db functions require a proper authzCtx and fail type check when you pass in a ctx without authz (and fully enforced at runtime)
Miak
Miak3w ago
hmm interesting, how is that check enforced at runtime?
erquhart
erquhartOP3w ago
Each table file has a get function for getting individual records, it requires the workspaceId in the authz object to match the workspaceId on the returned value. All of the functions that get individual records reuse that function For queries, the workspaceId needs to be included in the index, and you set the q.eq() for that part of the index using the workspaceId from the authz object
Miak
Miak3w ago
ah I see, this might essentially be the same thing but I'm thinking I could create a custom query/mutation function which injects the user and their role (ensuring only one db call to get the user), then all of the db functions check the user record already in ctx but handle the authorization check in the db function. I suppose there is still a drawback that if you don't structure the functions well you could end up with multiple checks to something like ctx.user.role but if its in the context thats pretty minimal overhead. I'll have to tinker with your approach in practice to see if it clicks more, thanks again!
erquhart
erquhartOP3w ago
No problem!

Did you find this page helpful?