Permissions Cookbook
Permissions Cookbook
Recipes for fine-grained access control, from "public blog" to "plan-gated, per-field, per-organization". The concepts behind them are in Authentication & Authorization.
How a request is decided
Every request passes these layers in order; each one can only narrow what the previous allowed:
| # | Layer | Declared with | Denied as |
|---|---|---|---|
| 1 | Operation — may this caller read / create / update / delete this resource at all? | permissions | 401 / 403 |
| 2 | Tenant — only the caller's organization | multiTenancy | 404 |
| 3 | Row visibility — which rows exist for this caller | listFilter (SQL) | 404 |
| 4 | Object — may they act on this row? | objectLevel (function) | 403 (dropped from lists) |
| 5 | Fields — which columns they see / may write | fields, hiddenFields | stripped / 403 |
| 6 | Server-owned columns — ids, tenant, audit, soft-delete | automatic + protectedFields | silently dropped |
Anything not declared at layer 1 is denied. Layers 3–4 apply to every route that touches the row — get,
update, delete, restore, bulk, aggregate, ?include=, M2M — not only lists.
Permission values
permissions: {
read: true, // everyone, signed in or not
create: ctx => !!ctx.user, // a function: sync or async
update: 'posts:write', // callers whose ctx.permissions holds this string
delete: ['posts:delete', 'admin'], // …any of these
aggregate: false, // no one — not even '*'
}
'*' in ctx.permissions is a super-admin: it passes everything except an explicit false.
Recipes
Public read, signed-in write
export const commentsAuth: ResourceAuthConfig = {
permissions: { read: true, create: ctx => !!ctx.user, update: ctx => !!ctx.user, delete: ctx => !!ctx.user },
// …but only your own comment can be changed or removed
objectLevel: (row, ctx) => ctx.operation === 'list' || ctx.operation === 'get' || row.authorId === ctx.user?.id,
}
Stamp authorId on create from the server, never from the body — list it in the registration's
protectedFields so a client cannot forge it, and set it in a hook:
export const commentsHooks = {
beforeCreate: (data: any, ctx: any) => ({ ...data, authorId: ctx.user.id }),
}
(or add the audit() schema preset: createdBy is stamped for you.)
Private rows: everyone sees only their own
listFilter is SQL, so pagination and total stay correct, and it applies to get/update/delete too — another
user's note is a 404, not a 403:
export const notesAuth: ResourceAuthConfig = {
permissions: { read: ctx => !!ctx.user, create: ctx => !!ctx.user, update: ctx => !!ctx.user, delete: ctx => !!ctx.user },
listFilter: (notes, ctx) => eq(notes.ownerId, ctx.user!.id),
}
Drafts vs published
listFilter: (posts, ctx) =>
ctx.permissions.includes('posts:write') ? undefined : eq(posts.published, true),
undefined means "no restriction".
Roles → permission strings (RBAC)
Keep roles out of resource files: map them to permission strings once, where the caller is resolved, and let resources name capabilities.
// server/autoapi-plugins.ts
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: ['*'],
editor: ['posts:write', 'posts:publish', 'media:write'],
author: ['posts:write'],
}
export default [
createBetterAuthPlugin({
getSession: event => serverAuth().api.getSession({ headers: event.headers }),
getPermissions: user => (user.role ?? '').split(',').flatMap((r: string) => ROLE_PERMISSIONS[r.trim()] ?? []),
}),
]
// modules/blog/auth.ts
permissions: { read: true, create: 'posts:write', update: 'posts:write', delete: 'posts:publish' },
Roles inside an organization
With multi-tenancy the tenant layer already limits rows to the active organization; the member's role decides what they may do there:
const orgRole = (ctx: HandlerContext) => ctx.user?.orgRole as string | undefined
const atLeast = (...roles: string[]) => (ctx: HandlerContext) => roles.includes(orgRole(ctx) ?? '')
export const invoicesAuth: ResourceAuthConfig = {
permissions: {
read: atLeast('owner', 'admin', 'member'),
create: atLeast('owner', 'admin'),
update: atLeast('owner', 'admin'),
delete: atLeast('owner'),
},
}
How orgRole gets onto the user: Better-Auth › organization role.
Read-only and secret fields
export const usersAuth: ResourceAuthConfig = {
permissions: { read: ctx => !!ctx.user, update: ctx => !!ctx.user },
objectLevel: (u, ctx) => ctx.operation === 'list' || ctx.operation === 'get' || u.id === ctx.user?.id,
fields: {
email: { read: ctx => ctx.permissions.includes('users:admin') }, // stripped for everyone else, not filterable
role: { write: 'users:admin' }, // anyone else setting it gets a 403
},
}
| You want | Use |
|---|---|
| Never returned, never filterable, for anyone | hiddenFields: ['passwordHash'] on the registration |
| Returned only to some callers | fields.x.read |
| Setting it is an error for some callers | fields.x.write → 403 |
| Setting it is ignored for everyone (server-owned) | protectedFields: ['x'] on the registration |
Hidden and unreadable fields cannot be used in filter, sort, groupBy or ?include= field lists either — a
query on them is a 400, so values cannot be probed.
Close an operation, open one endpoint
A resource can refuse generic writes and still expose a specific action. create: false closes POST /api/orders;
the named gate opens one custom endpoint:
export const ordersAuth: ResourceAuthConfig = {
permissions: { read: ctx => !!ctx.user, create: false, update: false, delete: false },
custom: {
checkout: { permissions: { create: ctx => !!ctx.user } },
refund: { permissions: { update: 'orders:refund' } },
},
}
// server/api/orders/checkout.post.ts
export default createEndpoint({
resource: 'orders',
operation: 'create',
endpointName: 'checkout',
async handler(ctx) { /* create the order from the cart, server-side */ },
})
An operation the named gate does not declare falls back to the resource's permission — never to "allowed".
Gate by subscription plan
Load the caller's plan once per request in a context extender, then gate on it — see Billing & Plan Gating for the full setup:
permissions: {
read: ctx => !!ctx.user,
create: ctx => ctx.requestMeta?.billing?.features.includes('projects') ?? false,
}
Declarative policies with a permission evaluator
Functions are flexible but opaque: the client cannot see why something is blocked. A permission can instead be a
plain object that a registered evaluator understands — the same object can drive the server decision, the
/permissions endpoint and your UI:
// shared/policy.ts — pure, importable from server and client
export interface Policy { roles?: string[], orgRoles?: string[], feature?: string }
export function evaluatePolicy(p: Policy, facts: { roles: string[], orgRole?: string | null, features: string[] }) {
if (p.roles && !p.roles.some(r => facts.roles.includes(r))) return false
if (p.orgRoles && !p.orgRoles.includes(facts.orgRole ?? '')) return false
if (p.feature && !facts.features.includes(p.feature)) return false
return true
}
export const isPolicy = (v: unknown): v is Policy =>
!!v && typeof v === 'object' && ['roles', 'orgRoles', 'feature'].some(k => k in (v as object))
// server/plugins/policy.ts
import { registerPermissionEvaluator } from '@websideproject/nuxt-auto-api/plugins'
import { evaluatePolicy, isPolicy } from '~~/shared/policy'
export default defineNitroPlugin(() => {
registerPermissionEvaluator((value, ctx) => {
if (!isPolicy(value)) return undefined // not mine — the next evaluator may handle it
return evaluatePolicy(value, {
roles: ctx.user?.roles ?? [],
orgRole: ctx.user?.orgRole,
features: ctx.requestMeta?.billing?.features ?? [],
})
})
})
// modules/reports/auth.ts
permissions: {
read: { orgRoles: ['owner', 'admin', 'member'] },
create: { orgRoles: ['owner', 'admin'], feature: 'reports' },
aggregate: { feature: 'analytics' },
}
An object no evaluator claims is denied.
Different rules for reading and aggregating
aggregate falls back to read; declare it to make totals a paid or privileged feature:
permissions: { read: ctx => !!ctx.user, aggregate: 'reports:view' }
Trash: who may see, restore and purge
permissions: {
update: 'posts:write',
delete: 'posts:write',
viewDeleted: 'posts:write', // ?includeDeleted / ?onlyDeleted
restore: 'posts:write',
purge: 'admin', // DELETE ?force=true — permanent
}
Unset, they fall back to update / delete / restore. See Soft Deletes.
Who may link what (many-to-many)
Linking needs update on the resource and read on the related one. Tighten it per relation — keys are
relation names, the :relation route segment (which is the related resource's registered name):
permissions: {
update: 'posts:write',
m2m: {
requireUpdateOnRelated: ['categories'], // must be allowed to edit a category to attach it
relations: {
tags: { check: ({ right, user }) => right.records!.every(t => !t.locked || user?.roles?.includes('admin')) },
},
},
}
Tighten a module's defaults from the app
A module ships its auth.ts; an app can override individual keys from nuxt.config — strings and arrays only
(config is serialized, so functions cannot travel):
// nuxt.config.ts
autoApi: {
authorization: {
posts: {
permissions: { delete: 'admin' },
custom: { export: { permissions: { read: 'admin' } } },
},
},
},
Checks inside custom endpoints
The resource gate decides whether the caller may use the operation; rows are still your query. Use the same helpers the generated routes use:
export default createEndpoint({
resource: 'projects',
operation: 'update',
async handler(ctx) {
const project = await findAuthorizedRow(ctx, 'projects', ctx.params.id) // tenant + listFilter + objectLevel
await assertResourcePermission('invoices', 'create', ctx) // a different resource's rule
if (!(await checkPermission('update', getAuthConfig(ctx, 'clients'), ctx))) { /* degrade gracefully */ }
// …
},
})
More in Custom Endpoints › Row access.
Reflect permissions in the UI
GET /api/permissions answers, for the current caller, what every resource allows — including function and
evaluator rules, evaluated on the server:
<script setup lang="ts">
const { canCreate, canUpdate, canDelete, permissions } = usePermissions('invoices')
const canEditTotal = computed(() => permissions.value?.fields?.total?.canWrite ?? false)
</script>
<template>
<UButton v-if="canCreate" label="New invoice" icon="i-lucide-plus" />
<UInput v-model="form.total" :disabled="!canEditTotal" />
<UTooltip v-if="!canDelete" text="Only owners can delete invoices">
<UButton label="Delete" color="error" variant="soft" disabled />
</UTooltip>
</template>
This is UX. The server enforces the same rules on every request, whatever the UI shows.
Test the rules
import { evaluatePermission } from '@websideproject/nuxt-auto-api/utils'
it('only owners delete invoices', async () => {
const ctx = (orgRole: string) => ({ user: { id: 1, orgRole }, permissions: [] }) as any
expect(await evaluatePermission(invoicesAuth.permissions!.delete, ctx('admin'))).toBe(false)
expect(await evaluatePermission(invoicesAuth.permissions!.delete, ctx('owner'))).toBe(true)
})
evaluatePermission is the pipeline's own function (undeclared → false, '*' → true). For row visibility and
tenancy, test through the routes — see Testing.
Schema Presets (Cross-engine)
nuxt-auto-api ships per-dialect column preset helpers so every table gets timestamps, softDelete, tenant, id, audit, and json columns with one import — and because auto-api's features are convention-driven (detect deletedAt by name; updatedAt self-manages via drizzle $onUpdate), importing a preset activates the feature automatically. No extra config.
Billing & Plan Gating
Subscriptions, plan-gated features and usage quotas on top of auto-api: the payment provider owns the money, a webhook keeps a subscriptions table in sync, a context extender puts the caller's plan on every request, and resources gate on it like on any other permission. The UI uses Nuxt UI's pricing components.