Permissions
Learn how Litestore resolves an admin role into effective permissions, how grantedPermissions and deniedPermissions override it, and which procedure wraps a server action versus a page.
Every admin surface in Litestore is gated by a module:action permission string.
Three things combine to produce a decision. A role supplies a baseline set, two JSON columns on User add and subtract from it per person, and lib/permissions/check.ts resolves the two into an effective set once per request.
The per-request resolution is the important part. There is no stored "effective permissions" column anywhere — the set is derived fresh, so a change to a role or an override is live on the next request rather than at the end of a session.
This page covers the roles that exist, the override model and its no-escalation rule, and the exact way a check is written in a server action, a page, and a component.
Permission strings
lib/permissions/types.ts defines the vocabulary.
A Permission is `${AdminModule}:${Action}` — one of the modules in AdminModule joined to one of eight actions:
viewcreateeditdeletepublishexportimportmanage
The modules cover:
- Catalog —
products,categories,tags,collections,options - Orders —
orders,returns,refunds,shipping customers- Marketing —
campaigns,ads,collabs,coupons,links,social_media,reviews - Content —
blocks,media,blog,pages - Storefront —
theme,feed - Insights and tools —
analytics,activity,tickets,emails,ai - System —
settings,users,integrations,api_keys
One of those ids is deliberately stale. The admin surface is Messages at /admin/messages, but the module id is still tickets.
That is not an oversight left to be tidied up. Stored role grants persist strings like "tickets:view", and renaming the key would silently revoke them — the permission would stop matching, and nobody would get an error saying so.
Special permissions
Five SpecialPermission values sit outside the module grid:
super_adminview_allmanage_settingsprocess_financialsdanger_zone
AnyPermission is the union of both kinds. ALL_PERMISSIONS is the flattened list, and isAnyPermission(value) is the runtime guard used when validating stored overrides.
That guard exists because the override columns are Json?. Anything the database hands back has to be checked before it is treated as a permission.
Roles
ROLE_META and ROLE_PERMISSIONS in lib/permissions/roles.ts define eight roles.
ADMIN_ROLES in lib/db-enums.ts names the seven that grant admin panel access.
The baseline each role carries in ROLE_PERMISSIONS:
owner—super_admin,danger_zone,process_financials,manage_settings. Nothing else is listed becausesuper_adminbypasses every check.admin— every action on every module, plusprocess_financialsandmanage_settings, with an explicit denial ofdanger_zone.manager— full catalog, orders, returns, shipping, customers, content and tickets;refundslimited to view/create/edit; marketing limited to view/create/edit; theme and feed view/edit; analytics and activity view.editor— products view/edit/publish, categories view/edit, tags and collections up to edit, full media/blog/pages, theme view, tickets view/create/edit.support— orders view/edit, full returns, refunds view/create, customers view/edit, products view, reviews view/edit/publish, full tickets, emails view/create, activity view.marketing— full campaigns, ads, collabs, coupons, links, social media and reviews; products and categories view; media view/create; blog up to publish; analytics view.analyst—view_all, plusanalytics:view,analytics:exportandactivity:view.custom— empty.
The two roles you cannot store
custom is defined in AdminRole and ROLE_META but is not a value of the UserRole enum in prisma/schema.prisma, so it cannot be stored on a user.
It is a placeholder in the vocabulary rather than an assignable role. Per person tailoring goes through the override columns instead.
The Prisma enum also carries user and collab. Both are outside ADMIN_ROLES and resolve to a null role — no admin access at all.
Grants and denials
User.grantedPermissions and User.deniedPermissions are Json? columns.
buildPermissionContextForUser layers them over the role baseline, reading the row fresh from the database through getFreshUserById — never the cookie-cached session role, which lags a demotion by the cookie cache TTL:
// Apply user-specific granted permissions (override role denials)
if (user?.grantedPermissions && Array.isArray(user.grantedPermissions)) {
for (const perm of user.grantedPermissions as AnyPermission[]) {
effectivePermissions.add(perm)
userGrantedPermissions.add(perm)
deniedPermissions.delete(perm)
}
}
// Apply user-specific denied permissions (override role grants)
if (user?.deniedPermissions && Array.isArray(user.deniedPermissions)) {
for (const perm of user.deniedPermissions as AnyPermission[]) {
deniedPermissions.add(perm)
userDeniedPermissions.add(perm)
effectivePermissions.delete(perm)
}
}Order decides the outcome. Grants are applied first and clear a role denial; denials are applied last and clear any grant.
A user denial always wins, because it is applied last and nothing runs after it.
The context keeps userGrantedPermissions and userDeniedPermissions as separate sets purely so a check result can attribute itself — so the answer can say whether it came from the role or from an override on this person.
Two entry points, one derivation
getPermissionContext is the cache()-wrapped entry point for the session user.
buildPermissionContextForUser(userId) is the same derivation for a caller that authenticates outside the cookie session. The operator MCP endpoint is the case that needs it: it acts as the store owner off an API key, with no session to read.
How a check resolves
checkPermissionWithContext(ctx, permission) returns a PermissionCheckResult with allowed, a reason, and either grantedBy ("role", "user", "special") or deniedBy ("role", "user", "not_granted").
It tests in this order:
- Explicit denial — if the permission is in
deniedPermissions, deny, and report whether the denial came from the user override or the role. super_admin— allow anything.view_all— allow any permission whose string contains:view.- Direct grant — allow, attributed to
userorrole. module:manage— holdingorders:manageallows everyorders:*action.- Otherwise deny with
deniedBy: "not_granted".
Step 1 running before step 2 is the load-bearing detail. Because denial is checked before super_admin, an explicit denial on an owner still blocks.
That is how admin is kept out of the danger zone: the role carries denials: [SpecialPermission.dangerZone]. Without the ordering, an all-powerful role would have no way to be fenced out of anything.
The thin wrappers over that function are:
hasPermissionhasAllPermissionshasAnyPermissionrequirePermission(throws)canAccessAdmin(role is not null)isAdminisOwnergetUserRole
can.view(module) and its siblings build permission strings without string concatenation at the call site, which keeps a typo from becoming a permission that is simply never granted.
In a server action
Admin server actions never call hasPermission directly.
They are built on a zsa procedure from lib/permissions/procedures.ts, so the check runs before the handler body exists. There is no branch in the handler that could be skipped, because the guard is not in the handler.
The base is permissionProcedure. It requires a session, requires a non-null admin role, and calls assertAccountNotBanned().
The ban re-check matters here specifically. A server action is a direct POST that never passes through the admin layout, so the layout's ban check does not run — it has to be re-enforced at the procedure.
The common form
modulePermission curries a module into an action-to-procedure factory:
const productPermission = modulePermission(AdminModule.products)Each action then chains off it. server/admin/orders/actions.ts uses the pre-built orderPermission the same way:
export const deleteOrders = orderPermission("delete")
.createServerAction()
.input(
z.object({
ids: batchIdsSchema,
force: z.boolean().default(false),
}),
)
.handler(async ({ input, ctx }) => {
const result = await orderService.deleteOrders(input)
// …
})lib/permissions/procedures.ts pre-builds five of these:
productPermissionorderPermissioncustomerPermissioncontentPermission(bound toAdminModule.blog)settingsPermission
Actions that satisfy more than one permission
withAnyPermission takes a list. Upsert actions use it because a single form both creates and edits:
export const upsertCategory = withAnyPermission(["categories:create", "categories:edit"])The rest of the set completes the shapes:
withPermission(permission)withAllPermissions(permissions)requireSuperAdminrequireFinancialsrequireDangerZone
Every one of them throws Forbidden: … rather than returning a flag, and every one exposes ctx.permissions and ctx.user to the handler.
Throwing rather than returning is the point. A returned flag is something a handler can ignore.
The harness fails an unwrapped action
scripts/check-discipline.ts runs an unwrapped-server-actions rule over server/: a createServerAction() whose chain does not start from a permission builder is a failure.
The rule recognises the aliased form — const perm = modulePermission(…) followed by perm("view").createServerAction().
Storefront actions are allowlisted, because they are session- and cart-scoped by design rather than admin-gated.
In a page and a component
An admin page is exported through withAdminPage from components/admin/auth-hoc.tsx, which takes the permission as a required second argument:
export default withAdminPage(BlockDetailPage, "blocks:view")The guard first rejects a session whose role is not in ADMIN_ROLES, then requires the named permission, redirecting to / on either failure.
The sentinel "admin:any" skips the permission check but still requires an admin role. It exists for the dashboard landing page and pure section indexes that carry no module data.
A second discipline rule, admin-page-auth, fails any app/admin/**/page.tsx that does not export through withAdminPage — including redirect-only shims, which are exactly the files someone would think are too trivial to guard.
Checks inside a component
Inside a server component, a conditional check is an awaited hasPermission:
const showSignals = kind === "badges" && (await hasPermission("settings:edit"))When a component tests many permissions, it loads the context once and calls checkPermissionWithContext synchronously. The sidebar does exactly that to decide which nav entries to render:
const visibleModules = permissions
? SIDEBAR_MODULES.filter(
module => checkPermissionWithContext(permissions, `${module}:view` as Permission).allowed,
)
: []A route handler uses the third form, withAdminAuth(handler, permission) from lib/auth/auth-middleware.ts, which answers 403 instead of redirecting.
The client boundary
There is no client-side permission check. A client component receives the decision as a prop from a server component; it never evaluates one itself.
The two entrypoints are separate for that reason:
lib/permissions/server.tsis markedserver-onlyand re-exports the types, the checks and the procedures.lib/permissions/client.tsexports only static types, constants and pure helpers —ROLE_META,ROLE_PERMISSIONS,getRolePermissions,getAssignableRoles,isAnyPermission. It must never import auth, the database,next/headers, or a server-only module.
The split is what makes the rule enforceable rather than merely stated. A client component cannot reach a check, because the module it would have to import does not exist on that side of the boundary.
The storefront equivalent
Customer-facing actions are not permission-gated. A shopper has no role and no module grants.
lib/permissions/customer.ts exports customerProcedure, the storefront mirror of permissionProcedure. It does three things:
- Resolves the signed-in customer through
getSessionCustomerId, auto-creating theCustomerrow when a logged-in user has none - Exposes
ctx.customerIdandctx.userId - Throws
"Please sign in to continue"when nobody is signed in
Building on it means storefront actions return the same zsa [data, error] tuple the admin layer uses, so a caller does not have to know which side of the store it is talking to.
No escalation
server/admin/users/actions.ts guards updateUser — itself wrapped in withPermission("users:edit") — against privilege escalation.
ROLE_HIERARCHY in that file orders the roles from least to most privileged:
user
collab
support
editor
marketing
analyst
manager
admin
ownerThe checks are:
- You cannot change your own role.
- You cannot modify a user whose role sits above yours.
- You cannot assign a role above your own, nor equal to your own unless you are the owner.
- Only an owner can assign
owneroradmin. - Only an owner or admin can touch permission overrides at all.
- You cannot modify your own overrides.
- You cannot grant a permission you do not hold yourself:
for (const permission of data.grantedPermissions ?? []) {
const result = checkPermissionWithContext(permissionContext, permission)
if (!result.allowed) {
throw new Error(`Cannot grant ${permission}; you do not hold that permission.`)
}
}That last check runs against the live getPermissionContext(), not the stored role. So a denial applied to the actor also removes their ability to hand the permission on — you cannot route around a denial by granting the permission to a second account you control.
There is no matching restriction on deniedPermissions. You can deny a permission you do not hold.
Which roles a person can hand out
Role pickers are narrowed separately by getAssignableRoles(assignerRole) in lib/permissions/roles.ts:
- An owner may assign any role.
- An admin may assign anything except
owner,adminandcustom. - Every other role gets an empty list.
getInvitableRoleOptions() builds the invite dialog and user filter options from ROLE_META, excluding owner and custom.
Deleting users is stricter still. deleteUsers is wrapped in requireSuperAdmin, refuses to delete your own account, and refuses any batch containing a user with the admin or manager role.
The audit entry
A role change or an override change writes its own activity entry, separate from the generic user-update record.
The reason is that prevention alone is not enough for this particular operation. "Who granted whom what, when" has to be answerable from the activity log afterwards, not merely prevented at the time:
const roleChanged = Boolean(data.role) && data.role !== targetUser.role
const permissionsChanged =
data.grantedPermissions !== undefined || data.deniedPermissions !== undefined
if (roleChanged || permissionsChanged) {
void emitActivity({
type: "user_permissions_changed",
userId: actor.id,
description: roleChanged
? `Role changed from ${targetUser.role ?? "user"} to ${data.role}`
: "Permission overrides updated",
metadata: {
module: "user",
action: "permissions_changed",
actorId: actor.id,
targetUserId: userId,
// roleFrom / roleTo, grantedPermissions / deniedPermissions
},
})
}The call is void-ed and emitActivity never throws, so a failed audit write cannot block the security operation it records.
user_permissions_changed is registered in config/activity-registry.ts with the label "Changed access", module admin and priority 8 — the same tier as user_impersonated.
Which guard to use
Use a permission procedure when you are writing a server action. Reach for modulePermission or one of the pre-built module procedures first, withAnyPermission when a single form covers two permissions, and a special procedure when the operation is financial, super-admin or danger zone.
Use withAdminPage when you are adding a page under app/admin. There is no opt-out; a redirect-only shim still exports through it.
Use withAdminAuth when you are adding a route handler, because a 403 is the right answer to a fetch and a redirect is not.
Use an awaited hasPermission for a single conditional inside a server component, and load the context once with checkPermissionWithContext when you are testing many.
Use customerProcedure when the caller is a shopper rather than an operator.
The question to ask when adding a new admin surface is not "did I remember to check?" It is:
Is this guarded by construction, so that forgetting the check is not something the code allows?
Related
Overview
Learn how Litestore signs users in with Better Auth magic links, when Google OAuth registers, how sessions are re-validated, and what the impersonation hooks write to the activity log.
Actions
Learn how admin and storefront writes are declared as zsa server actions, how permission procedures gate them, and how the CRUD layer generates the standard six.
Keys
Learn how service API keys are minted, hashed and revoked, and what each of the six enforced scopes actually permits.
Overview
Learn how Litestore signs users in with Better Auth magic links, when Google OAuth registers, how sessions are re-validated, and what the impersonation hooks write to the activity log.
Checkout
Learn how a cart becomes a Stripe Checkout Session, how long stock is reserved, and why the reservation window must outlive the payment window.