Litestore · Developer guide

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:

  • view
  • create
  • edit
  • delete
  • publish
  • export
  • import
  • manage

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_admin
  • view_all
  • manage_settings
  • process_financials
  • danger_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:

  • ownersuper_admin, danger_zone, process_financials, manage_settings. Nothing else is listed because super_admin bypasses every check.
  • admin — every action on every module, plus process_financials and manage_settings, with an explicit denial of danger_zone.
  • manager — full catalog, orders, returns, shipping, customers, content and tickets; refunds limited 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.
  • analystview_all, plus analytics:view, analytics:export and activity: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:

lib/permissions/check.ts
// 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:

  1. Explicit denial — if the permission is in deniedPermissions, deny, and report whether the denial came from the user override or the role.
  2. super_admin — allow anything.
  3. view_all — allow any permission whose string contains :view.
  4. Direct grant — allow, attributed to user or role.
  5. module:manage — holding orders:manage allows every orders:* action.
  6. 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:

  • hasPermission
  • hasAllPermissions
  • hasAnyPermission
  • requirePermission (throws)
  • canAccessAdmin (role is not null)
  • isAdmin
  • isOwner
  • getUserRole

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:

server/admin/products/actions.ts
const productPermission = modulePermission(AdminModule.products)

Each action then chains off it. server/admin/orders/actions.ts uses the pre-built orderPermission the same way:

server/admin/orders/actions.ts
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:

  • productPermission
  • orderPermission
  • customerPermission
  • contentPermission (bound to AdminModule.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:

server/admin/categories/actions.ts
export const upsertCategory = withAnyPermission(["categories:create", "categories:edit"])

The rest of the set completes the shapes:

  • withPermission(permission)
  • withAllPermissions(permissions)
  • requireSuperAdmin
  • requireFinancials
  • requireDangerZone

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:

app/admin/blocks/[id]/page.tsx
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:

app/admin/blocks/[id]/page.tsx
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:

components/admin/layout/sidebar-with-badges.tsx
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.ts is marked server-only and re-exports the types, the checks and the procedures.
  • lib/permissions/client.ts exports 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 the Customer row when a logged-in user has none
  • Exposes ctx.customerId and ctx.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
owner

The 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 owner or admin.
  • 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:
server/admin/users/actions.ts
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, admin and custom.
  • 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:

server/admin/users/actions.ts
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?

On this page