import { ensure } from './helpers/ensure' import { prettyFormatZeroQuery } from './helpers/prettyFormatZeroQuery' import { getZQL } from './state' import { getWhereTableName } from './where' import type { AdminRoleMode, AuthData, Can, TableName, Transaction, Where } from './types' import type { Condition, ExpressionBuilder, Query, Schema as ZeroSchema, } from '@rocicorp/zero' export class PermissionError extends Error { constructor(message: string) { super(message) this.name = 'PermissionError' } } export function createPermissions({ environment, schema, adminRoleMode = 'all', }: { environment: 'client' | 'server' schema: Schema adminRoleMode?: AdminRoleMode }) { type PermissionsWhere = Where
function buildPermissionQuery( authData: AuthData | null, eb: ExpressionBuilder, permissionWhere: PermissionWhere, // TODO until i can get a working PickPrimaryKeys<'message'> objOrId: Record | string, tableNameOverride?: TableName ) { // check admin bypass for queries const adminBypassQueries = adminRoleMode === 'all' || adminRoleMode === 'queries' if (adminBypassQueries && authData?.role === 'admin') { return eb.cmpLit(true, '=', true) } const tableName = tableNameOverride || getWhereTableName(permissionWhere) if (!tableName) { throw new Error(`Must use PermissionWhere for buildPermissionQuery`) } const tableSchema = schema.tables[tableName] if (!tableSchema) { throw new Error(`No schema?`) } const primaryKeys = tableSchema.primaryKey let permissionReturn: Condition | boolean try { permissionReturn = permissionWhere(eb, authData) } catch (err) { // treat throws as deny — ensure() is the idiomatic "deny if falsy" pattern // use .name check instead of instanceof — resilient to duplicate module instances from bundlers if ( process.env.NODE_ENV === 'development' && (err as any)?.name !== 'EnsureError' ) { console.warn(`[permission] ${tableName} threw:`, err) } return eb.cmpLit(true, '=', false) } if (permissionReturn == null) { throw new Error(`No permission defined for ${tableName}`) } if (permissionReturn === true) { return eb.cmpLit(true, '=', true) } if (permissionReturn === false) { return eb.cmpLit(true, '=', false) } const primaryKeyWheres: Condition[] = [] for (const key of primaryKeys) { const value = typeof objOrId === 'string' ? objOrId : objOrId[key] primaryKeyWheres.push(eb.cmp(key as any, value)) } return eb.and(permissionReturn, ...primaryKeyWheres) } // bound to the transaction that will call it, never read from ambient state. // an ambient lookup checks permissions against whichever mutator happens to be // in flight, and only a real AsyncLocalStorage makes that the calling one — a // browser-hosted server has none. const bindCan = (tx: Transaction, authData: AuthData | null): Can => async (where, obj) => { // on client we always allow! we only check on server (like zero does) if (environment !== 'server') return const tableName = getWhereTableName(where) if (!tableName) { throw new Error(`Must use where('table') style where to pass to can()`) } await ensurePermission(tx, authData, tableName, where, obj) } async function ensurePermission( tx: Transaction, authData: AuthData | null, tableName: TableName, where: Where, obj: any // TODO until i can get a working PickPrimaryKeys<'message'> ): Promise { // check admin bypass for mutations const adminBypassMutations = adminRoleMode === 'all' || adminRoleMode === 'mutations' if (adminBypassMutations && authData?.role === 'admin') { return } const zqlBuilder = getZQL() as any const queryBase = zqlBuilder[tableName] as Query let query: Query | null = null try { query = queryBase .where((eb) => { return buildPermissionQuery(authData, eb, where, obj) }) .one() ensure(await tx.run(query)) } catch (err) { const errorTitle = `${tableName} with auth id: ${authData?.id}` if ((err as any)?.name === 'EnsureError') { let msg = `[permission] 🚫 Not Allowed: ${errorTitle}` if (process.env.NODE_ENV === 'development' && query) { msg += `\n ${prettyFormatZeroQuery(query)}` } throw new PermissionError(msg) } throw new Error(`Error running permission ${errorTitle}\n${err}`) } } return { bindCan, buildPermissionQuery, } }