winsoroaks
winsoroaks15mo ago

i am defeated by RLS

hello team, can y'all pls help me understand how to do RLS? i currently have a func,
export const getCustomer = query(
withQueryRLS(async (ctx, { customerId }) => {
return await ctx.db.get(customerId)
})
)
export const getCustomer = query(
withQueryRLS(async (ctx, { customerId }) => {
return await ctx.db.get(customerId)
})
)
with the rls func defined as below
const { withQueryRLS } = RowLevelSecurity<
{
customer: Doc<"customer">
},
DataModel
>({
customer: {
read: async (ctx) =>
await hasPermission({
ctx,
permissionName: "customer",
operation: "read",
}),
},
})
const { withQueryRLS } = RowLevelSecurity<
{
customer: Doc<"customer">
},
DataModel
>({
customer: {
read: async (ctx) =>
await hasPermission({
ctx,
permissionName: "customer",
operation: "read",
}),
},
})
4 Replies
winsoroaks
winsoroaksOP15mo ago
and here's the hasPermission func
export const hasPermission = async ({
ctx,
permissionName,
operation,
}: {
ctx: any
permissionName: string
operation: "read" | "write"
}) => {

const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Identity not found");
}

const user = await ctx.db
.query(USER_DB)
.withIndex("by_clerkUserId", (q) => q.eq("clerkUserId", identity.tokenIdentifier))
.unique();

const person = await ctx.db
.query(PERSON_DB)
.withIndex("by_org", (q) => q.eq("org", org._id))
.filter((q) => q.eq(q.field("userId"), user._id))
.unique();

const personRoles = await getManyFrom(
ctx.db,
PERSON_ROLES_JOIN_DB,
"personId",
person._id
)

const canDoOperation = (
await Promise.all(
personRoles.map(async (personRole) => {
if (!personRole) {
return false
}

const permissionJoin = await ctx.db
.query(ROLE_PERMISSION_JOIN_DB)
.withIndex("by_roleId", (q: any) => q.eq("roleId", personRole.roleId))
.unique()
if (!permissionJoin) {
return false
}
const permission = await ctx.db
.query(PERMISSION_DB)
.withIndex("by_orgId", (q: any) =>
q.eq("orgId", person.orgId)
)
.filter((q: any) => q.eq("_id", permissionJoin.permissionId))
.unique()
if (!permission) {
return false
}

const { name, operation } = permission
if (name === "admin") {
return true
}
if (permissionName === name) {
if (operation === "write") {
return operation === "write" || operation === "read"
} else if (operation === "read") {
return operation === "read"
}
}
return false
})
)
).some(Boolean)
return canDoOperation
}
export const hasPermission = async ({
ctx,
permissionName,
operation,
}: {
ctx: any
permissionName: string
operation: "read" | "write"
}) => {

const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Identity not found");
}

const user = await ctx.db
.query(USER_DB)
.withIndex("by_clerkUserId", (q) => q.eq("clerkUserId", identity.tokenIdentifier))
.unique();

const person = await ctx.db
.query(PERSON_DB)
.withIndex("by_org", (q) => q.eq("org", org._id))
.filter((q) => q.eq(q.field("userId"), user._id))
.unique();

const personRoles = await getManyFrom(
ctx.db,
PERSON_ROLES_JOIN_DB,
"personId",
person._id
)

const canDoOperation = (
await Promise.all(
personRoles.map(async (personRole) => {
if (!personRole) {
return false
}

const permissionJoin = await ctx.db
.query(ROLE_PERMISSION_JOIN_DB)
.withIndex("by_roleId", (q: any) => q.eq("roleId", personRole.roleId))
.unique()
if (!permissionJoin) {
return false
}
const permission = await ctx.db
.query(PERMISSION_DB)
.withIndex("by_orgId", (q: any) =>
q.eq("orgId", person.orgId)
)
.filter((q: any) => q.eq("_id", permissionJoin.permissionId))
.unique()
if (!permission) {
return false
}

const { name, operation } = permission
if (name === "admin") {
return true
}
if (permissionName === name) {
if (operation === "write") {
return operation === "write" || operation === "read"
} else if (operation === "read") {
return operation === "read"
}
}
return false
})
)
).some(Boolean)
return canDoOperation
}
1. do u foresee anything i can improve here? 2. how to correctly to do ctx? i wanted to define the ctx as type
export const hasPermission = async ({
ctx,
}: {
ctx: GenericQueryCtx<AnyDataModel>
export const hasPermission = async ({
ctx,
}: {
ctx: GenericQueryCtx<AnyDataModel>
but apparently it doesnt work because the ctx in the
read: async (ctx: GenericQueryCtx<AnyDataModel>) =>
read: async (ctx: GenericQueryCtx<AnyDataModel>) =>
is of type Rule. 3. weird enough i have a definedTable for ROLE_PERMISSION_JOIN_DB with index roleId and the first query below fails when i dont do withIndex. is this expected?
// fails
// const permissionJoin = await ctx.db
// .query(ROLE_PERMISSION_JOIN_DB)
// .filter((q: any) => q.eq("roleId", personRole.roleId))
// .unique()

// works
const permissionJoin = await ctx.db
.query(ROLE_PERMISSION_JOIN_DB)
.withIndex("by_roleId", (q: any) => q.eq("roleId", personRole.roleId))
.unique()
// fails
// const permissionJoin = await ctx.db
// .query(ROLE_PERMISSION_JOIN_DB)
// .filter((q: any) => q.eq("roleId", personRole.roleId))
// .unique()

// works
const permissionJoin = await ctx.db
.query(ROLE_PERMISSION_JOIN_DB)
.withIndex("by_roleId", (q: any) => q.eq("roleId", personRole.roleId))
.unique()
4. i suspect because im doing ctx: any, my filter((q: any) has to be of type any? 5. my
const permission = await ctx.db
.query(PERMISSION_DB)
.withIndex("by_orgId", (q: any) =>
q.eq("orgId", person.orgId)
)
.filter((q: any) => q.eq("_id", permissionJoin.permissionId))
.unique()
const permission = await ctx.db
.query(PERMISSION_DB)
.withIndex("by_orgId", (q: any) =>
q.eq("orgId", person.orgId)
)
.filter((q: any) => q.eq("_id", permissionJoin.permissionId))
.unique()
outright fails despite manually checking the IDs actually are correct. i think this will be resolved if the above are taken care of. thank you in advanced. sorry this is a lengthy one 🙏
ian
ian15mo ago
A few thoughts: - The ctx type in the RowLevelSecurity type param should match what you're passing into it (currently set to {customer: Doc<"customer">}. And that should be the same type as hasPermission ctx type. - If you are passing it to a bare query the ctx should be QueryCtx for both. QueryCtx will be defined for your data model (it's GenericQueryCtx<DataModel> imported from your _generated folder) - Optimization: I'd suggest wrapping query in something that looks up the customer if you want this. You could look up user, person, roles, and permission without knowing what the table is, by the looks of it. Then you can avoid doing all this work per-document. - I'm working on some helpers to make composing wrappers hopefully easier - if you are interested I can share something early once I have it, to see how it works for you. I'm also thinking of making a new API for RLS like:
{
[tableName: string]: async (ctx,
{oldDoc, newDoc, action}): boolean,
}
{
[tableName: string]: async (ctx,
{oldDoc, newDoc, action}): boolean,
}
For actions read, create, update, delete , to make it easier to combine rules (e.g. the same rule for update & delete). One of oldDoc and newDoc will be null if it's not an update. Just a sketch. or {doc: Doc, updated?: Doc, action: 'read' | 'create' | 'update' | 'delete', } the current way to compose them is something like:
const myQ = query(withUser({
args: {},
handler: withRLS(async (ctx, args) => {}),
}));
const myQ = query(withUser({
args: {},
handler: withRLS(async (ctx, args) => {}),
}));
where withUser could look up all the things from auth. there's a stack post on withUser if that doesn't make sense
winsoroaks
winsoroaksOP15mo ago
thank you for the detailed writeup. im still digesting this. if i were to the following, there's an incompatible type for the getManyFrom
const { withQueryRLS, withMutationRLS } = RowLevelSecurity<
GenericQueryCtx<AnyDataModel>,
DataModel
>({
customer: {
read: async (ctx) =>
await hasPermission({
ctx,
permissionName: "customer",
operation: "read",
}),
modify: async (ctx, args) => {
return true
},
},
})

// hasPermission
const personRoles = await getManyFrom(
ctx.db,
PERSON_ROLES_JOIN_DB, // Argument of type 'string' is not assignable to parameter of type 'never'.
"personId",
person._id
)
const { withQueryRLS, withMutationRLS } = RowLevelSecurity<
GenericQueryCtx<AnyDataModel>,
DataModel
>({
customer: {
read: async (ctx) =>
await hasPermission({
ctx,
permissionName: "customer",
operation: "read",
}),
modify: async (ctx, args) => {
return true
},
},
})

// hasPermission
const personRoles = await getManyFrom(
ctx.db,
PERSON_ROLES_JOIN_DB, // Argument of type 'string' is not assignable to parameter of type 'never'.
"personId",
person._id
)
im trying to do something along the lines of, "certain employees who have the "customer" permission, can view the customer db" which means i'll need to look up the "user" (employee) and do a bunch of checks wrt to the roles and the permissions table. hence, i need the GenericQueryCtx can u pls elaborate on the following?
Optimization: I'd suggest wrapping query in something that looks up the customer if you want this. You could look up user, person, roles, and permission without knowing what the table is, by the looks of it. Then you can avoid doing all this work per-document.
i think that's what im aiming for, as long as i pass in the GenericQueryCtx is that the correct understanding?
ian
ian15mo ago
This is the example that does the optimization outside of the rules:
const { withQueryRLS } = RowLevelSecurity({
customer: {
read: async ({user, person, roles}: {
user: Doc<USER_DB>,
person: Doc<PERSON_DB>,
roles: Doc<PERSON_ROLES_JOIN_DB>,
},) =>
await hasPermission({
user,
person,
roles,
permissionName: "customer",
operation: "read",
}),
},
});

const withLookups = generateMiddleware({}, async (ctx: QueryCtx) => {
return {
user: (await getUser(ctx)),
person: (await getPerson(ctx)),
roles: (await getRoles(ctx)),
}

});

const getCustomer = query(withLookups({
args: {},
handler: withRLS(async (ctx, args) => {}),
}));
const { withQueryRLS } = RowLevelSecurity({
customer: {
read: async ({user, person, roles}: {
user: Doc<USER_DB>,
person: Doc<PERSON_DB>,
roles: Doc<PERSON_ROLES_JOIN_DB>,
},) =>
await hasPermission({
user,
person,
roles,
permissionName: "customer",
operation: "read",
}),
},
});

const withLookups = generateMiddleware({}, async (ctx: QueryCtx) => {
return {
user: (await getUser(ctx)),
person: (await getPerson(ctx)),
roles: (await getRoles(ctx)),
}

});

const getCustomer = query(withLookups({
args: {},
handler: withRLS(async (ctx, args) => {}),
}));
Note that by typing the ctx how you want it in RowLevelSecurity, you don't need to provide the type params. And by using generateMiddleware from middlewareUtils you don't need to do jump through a ton of hoops to provide basic middleware where you use your code above for the user, person, and roles lookup for the "never" comment, this implies to me that there isn't a by_personId index on PERSON_ROLES_JOIN_DB. I'm thinking of changing that to just require a "personId" index instead: the by_ prefix isn't my favorite, but by_creation_time is already there...

Did you find this page helpful?