Permissions
Litestore checks which actions each admin user may take, and you protect a new action, page or component with the same check.
Litestore checks a permission before anyone can see or change something in the admin. Read this page when you add an admin screen or action, or when you need to find out why a user can or cannot do something.
Each permission is a string of the form module:action. Litestore combines three things to reach a decision. The user's role gives a starting set of permissions. Two JSON columns on User then add or remove permissions for that one person. Finally, platform/permissions/check.ts combines the two into the user's effective permissions, once per request.
The per-request step matters most. Litestore never stores the effective permissions in a column. It works them out fresh on every request, so a change to a role or an override applies on the next request instead of when the session ends.
Permission strings
platform/permissions/types.ts defines the possible permissions. A Permission is `${AdminModule}:${Action}`. It joins one of the modules in AdminModule to one of eight actions: view, create, edit, delete, publish, export, import and manage.
| Area | Modules |
|---|---|
| Catalog | products, categories, tags, collections, options |
| Orders | orders, returns, refunds, shipping |
| Customers | customers |
| Marketing | campaigns, collabs, coupons, links, reviews |
| Content | media, blog, pages |
| Storefront | feed |
| Insights and tools | analytics, activity, tickets, emails, ai |
| System | settings, users, integrations, api_keys |
One of those ids is out of date on purpose. The admin screen is called Messages and its URL is /admin/messages, but its module id is still tickets. Nobody forgot to rename it. Saved role grants store strings like "tickets:view", and renaming the module would quietly take that access away. The stored permission would stop matching, and no error would tell anyone.
Special permissions
Five SpecialPermission values are not tied to a module: super_admin, view_all, manage_settings, process_financials and danger_zone.
AnyPermission covers both kinds. ALL_PERMISSIONS is the flat list of every permission. isAnyPermission(value) checks at runtime that a value is a real permission, and Litestore uses it when it validates stored overrides. The override columns are Json?, so the database can return anything, and every value has to pass that check before Litestore treats it as a permission.
Roles
ROLE_META and ROLE_PERMISSIONS in platform/permissions/roles.ts define seven roles. ADMIN_ROLES in platform/db/enums.ts lists the same seven roles: every UserRole except user and collab.
| Role | Starting permissions in ROLE_PERMISSIONS |
|---|---|
owner | Has super_admin, danger_zone, process_financials and manage_settings. Nothing else is listed, because super_admin passes every check. |
admin | Can do every action on every module and also has process_financials and manage_settings. It is explicitly denied danger_zone. |
manager | Has full access to catalog, orders, returns, shipping, customers, content and tickets. For refunds, campaigns and coupons it can view, create and edit, and it can view, edit and publish reviews. It can view and edit feeds, view and create emails, and view analytics and activity. It has no access to collabs or links. |
editor | Can view, edit and publish products, view and edit categories, and work on tags and collections up to edit. It has full access to media, blog and pages, and can view, create and edit tickets. |
support | Can view and edit orders, has full access to returns, and can view and create refunds. It can view and edit customers, view products, and view, edit and publish reviews. It also has full access to tickets, can view and create emails, and can view activity. |
marketing | Has full access to campaigns, collabs, coupons, links and reviews. It can view products and categories, view and create media, work on the blog up to publish, and view analytics. |
analyst | Has view_all, plus analytics:view, analytics:export and activity:view. |
Built-in roles
There is no custom role. To give one person a tailored set of permissions, you use the override columns instead.
The Prisma UserRole enum also has user and collab. Neither is in ADMIN_ROLES, so both resolve to a null role with no admin access at all.
Grants and denials
User.grantedPermissions and User.deniedPermissions are Json? columns. buildPermissionContextForUser applies them on top of the role's permissions. It reads the user fresh from the database through getFreshUserById. It never uses the role from the cookie-cached session, which can stay out of date after a demotion for as long as the cookie cache lasts.
// 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)
}
}The order determines the result. Litestore applies grants first, and a grant cancels a denial that came from the role. It applies denials last, and a denial cancels any grant. A denial on the user always wins, because Litestore applies it last and nothing runs after it.
The context keeps userGrantedPermissions and userDeniedPermissions as separate sets. Their only job is to let a check result say whether its answer came from the role or from an override on this person.
Permission context
getPermissionContext builds the context for the user in the current session, and it is wrapped in cache().
buildPermissionContextForUser(userId) does the same work for a caller who does not sign in with the session cookie. The operator MCP endpoint needs it, because it acts as the admin who created the API key and has no session to read.
Check order
checkPermissionWithContext(ctx, permission) returns a PermissionCheckResult. It contains allowed, a reason, and either grantedBy ("role", "user", "special") or deniedBy ("role", "user", "not_granted"). It runs these tests in order and stops at the first one that applies.
Explicit denial
If the permission is in deniedPermissions, the check denies it and reports whether the denial came from the user's override or from the role.
super_admin
The check allows anything.
view_all
The check allows any permission whose string contains :view.
Direct grant
The check allows it and reports whether the grant came from the user or the role.
module:manage
A user with orders:manage is allowed every orders:* action.
No match
The check denies with deniedBy: "not_granted".
Everything depends on the denial test running before super_admin. Because of that order, an explicit denial still blocks an owner. That is how Litestore keeps admin out of the danger zone, because the role has denials: [SpecialPermission.dangerZone]. Without this order, there would be no way to keep an all-powerful role out of anything.
Several small wrappers call that function: hasPermission, hasAnyPermission, requirePermission (throws), canAccessAdmin (the role is not null), isAdmin and isOwner.
can.view(module) and its siblings build permission strings, so nobody joins strings by hand. A typo therefore cannot turn into a permission that nobody is ever granted.
Server actions
Admin server actions never call hasPermission themselves. Each action is built on a zsa procedure from platform/permissions/procedures.ts, and the procedure runs the check before the handler starts. The handler has no branch that could skip the check, because the check is not inside the handler.
The base procedure is permissionProcedure. It requires a session and a non-null admin role, and it calls assertAccountNotBanned(). The ban check has to happen here. A server action is a direct POST that never goes through the admin layout, so the layout's own ban check does not run.
Standard form
modulePermission takes a module and returns a function that turns an action into a procedure:
export export const productPermission = modulePermission(AdminModule.products)Each action then builds on it. modules/orders/admin/actions.ts uses the ready-made orderPermission in 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 softDeleteOrders(input)
// …
})platform/permissions/procedures.ts provides five of these ready-made: productPermission, orderPermission, customerPermission, contentPermission (bound to AdminModule.blog) and settingsPermission.
Multiple permissions
withAnyPermission takes a list of permissions. Upsert actions use it, because one form both creates and edits:
export const upsertCategory = withAnyPermission(["categories:create", "categories:edit"])| Procedure | When you use it |
|---|---|
withPermission(permission) | The action needs one named permission. |
withAllPermissions(permissions) | The action needs every permission in a list. |
requireSuperAdmin | The action needs a super-admin. |
requireFinancials | The action involves money. |
requireDangerZone | The action touches the danger zone. |
Each of them throws Forbidden: … instead of returning a flag, and each passes ctx.permissions and ctx.user to the handler. Throwing is deliberate. A handler could ignore a returned flag, but it cannot ignore an error.
Every admin action starts from a permission builder
A createServerAction() chain in a module's admin/ folder should always start from a permission builder, either directly or through the aliased form, where you write const perm = modulePermission(…) and then perm("view").createServerAction(). Reviewers should reject an admin action that doesn't.
Storefront actions are on an allowlist. They are meant to be tied to the shopper's session and cart, not to admin permissions.
Pages and components
You export an admin page through withAdminPage from ui/admin/with-admin-page.tsx. Its second argument, the permission, is required:
export default withAdminPage(AdminPageDetail, "pages:view")The guard first rejects a session whose role is not in ADMIN_ROLES. It then requires the named permission. If either check fails, it redirects to /.
The special value "admin:any" skips the permission check but still requires an admin role. It is meant for the dashboard landing page and for section index pages that show no module data.
Every app/admin/**/page.tsx exports through withAdminPage, including pages that only redirect, which are exactly the files someone might consider too small to protect. No script checks this, so reviewers do.
Component checks
In a server component, you write a single conditional check as an awaited hasPermission:
const canEdit = await hasPermission("pages:edit")Client boundary
Litestore never checks permissions in the browser. A server component makes the decision and passes the result to a client component as a prop. The client component never works it out itself.
types.ts and roles.ts hold only static types, constants and pure helpers, such as AdminModule, ROLE_META, ROLE_PERMISSIONS, getRolePermissions, getAssignableRoles and isAnyPermission, so client code can import them. check.ts and procedures.ts read the session and the database, so they are server-only: a client component can't call a check, because the module it would need doesn't build for the browser.
Storefront permissions
Shopper actions do not check permissions, because a shopper has no role and no module grants. platform/permissions/customer.ts exports customerProcedure, which does for the storefront what permissionProcedure does for the admin.
It finds the signed-in customer through getSessionCustomerId, and if a logged-in user has no Customer row yet, it creates one. It passes ctx.customerId and ctx.userId to the handler. When nobody is signed in, it throws "Please sign in to continue".
Because storefront actions build on it, they return the same zsa [data, error] tuple as admin actions. A caller does not need to check which side of the store it is calling.
Privilege escalation
updateUser in modules/team/admin/actions.ts is wrapped in withPermission("users:edit"). It also stops users from raising their own privileges. roleRank in platform/permissions/roles.ts ranks the roles from least to most privileged:
user
collab
support
editor
marketing
analyst
manager
admin
ownerupdateUser enforces these rules:
- You cannot change your own role.
- You cannot change a user whose role is above yours.
- You cannot assign a role above your own. Assigning a role equal to your own is only allowed for the owner.
- Only an owner can assign
owneroradmin. - Only an owner or admin can change permission overrides at all.
- You cannot change your own overrides.
- You cannot grant a permission you do not have 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 rule checks against your live getPermissionContext(), not your stored role. If someone has denied you a permission, you also lose the ability to pass it on. You cannot get around a denial by granting the permission to a second account you control.
There is no matching rule for deniedPermissions. Denying a permission you do not have is allowed.
Assignable roles
getAssignableRoles(assignerRole) in platform/permissions/roles.ts separately limits the choices in role pickers.
| Assigner | Roles they can assign |
|---|---|
| Owner | Any role |
| Admin | Any role except owner and admin |
| Every other role | None |
getInvitableRoleOptions() builds the options for the invite dialog and the user filter from ROLE_META, leaving out owner.
Deleting users is even stricter. deleteUsers is wrapped in requireSuperAdmin and archives the accounts through archiveUsers in modules/team/archive-users.ts. It rejects a request to delete your own account, and it rejects any batch that contains a user ranked manager or above.
Audit log
When someone changes a role or an override, Litestore writes a dedicated activity entry, separate from the general user-update record. Blocking escalation is not enough on its own. Afterwards, you also need to be able to look up in the activity log who gave whom which access, and when:
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 not awaited (void), and emitActivity never throws. If the audit write fails, the permission change still goes through.
modules/operations/activity/registry.ts registers user_permissions_changed with the label "Changed access" under the admin module, next to user_impersonated.
Guards
When you add a new admin screen, check that the way the code is built makes it impossible to skip the permission check.
Related
Overview
Litestore signs users in with Better Auth magic links and optional Google OAuth, re-validates sessions, and logs impersonation in the activity log.
Actions
Admin and storefront writes are zsa server actions, permission procedures guard them, and the CRUD layer generates the standard actions.
Keys
Service API keys are minted, hashed and revoked by Litestore, and each enforced scope permits a specific set of calls.