Litestore · Developer guide
Authentication

Permissions

This page shows how Litestore decides what each admin user may do, and how you protect a new action, page or component.

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 understand 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, lib/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.

Roles
MLSC
2 people
Each role gives the people in it a set of things they are allowed to do.

The sections below cover the roles that exist, how per-person overrides work, the rule that stops users from raising their own access, and how to write a check in a server action, a page and a component.

Permission strings

lib/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
  • manage

These are the modules, grouped by area:

  • 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 out of date on purpose. The admin screen is called Messages and lives at /admin/messages, but its module id is still tickets.

Nobody forgot to rename it. Saved role grants store strings like "tickets:view". 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
  • 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. Every value has to pass that check before Litestore treats it as a permission.

Roles

ROLE_META and ROLE_PERMISSIONS in lib/permissions/roles.ts define eight roles.

ADMIN_ROLES in lib/db-enums.ts lists the seven roles that can open the admin panel.

Each role starts with these 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 it can only view, create and edit, and the same goes for marketing. It can view and edit theme and feed, and it can view analytics and activity.
  • 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, can view the theme, 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, ads, collabs, coupons, links, social media 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.
  • custom has no permissions.

The two roles you cannot store

custom exists in AdminRole and ROLE_META. It is not a value of the UserRole enum in prisma/schema.prisma, so you cannot save it on a user.

It is a placeholder name, not a role you can assign. To give one person a tailored set of permissions, you use the override columns instead.

The Prisma 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.

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)
  }
}

The order decides 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.

Two ways to build the context

getPermissionContext builds the context for the user in the current session. 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 store owner using an API key and has no session to read.

How a check reaches its answer

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:

  1. 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.
  2. super_admin. The check allows anything.
  3. view_all. The check allows any permission whose string contains :view.
  4. Direct grant. The check allows it and reports whether the grant came from the user or the role.
  5. module:manage. A user with orders:manage is allowed every orders:* action.
  6. Nothing matched. The check denies with deniedBy: "not_granted".

Everything depends on step 1 running before step 2. Because the denial check comes before super_admin, an explicit denial still blocks an owner.

That is how Litestore keeps admin out of the danger zone: the role has denials: [SpecialPermission.dangerZone]. Without this order, there would be no way to keep an all-powerful role out of anything.

These small wrappers call that function:

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

can.view(module) and its siblings build permission strings for you, so you never join strings by hand. A typo therefore cannot turn into a permission that nobody is ever granted.

In a server action

Admin server actions never call hasPermission themselves.

Each action is built on a zsa procedure from lib/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.

The common form

modulePermission takes a module and returns a function that turns an action into a procedure:

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

Each action then builds on it. server/admin/orders/actions.ts uses the ready-made orderPermission in 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 provides five of these ready-made:

  • productPermission
  • orderPermission
  • customerPermission
  • contentPermission (bound to AdminModule.blog)
  • settingsPermission

Actions that accept more than one permission

withAnyPermission takes a list of permissions. Upsert actions use it, because one form both creates and edits:

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

These procedures cover the remaining cases:

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

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.

The checks fail an unprotected action

scripts/check-discipline.ts runs an unwrapped-server-actions rule over server/. If a createServerAction() chain does not start from a permission builder, the check fails.

The rule also understands the aliased form, where you write const perm = modulePermission(…) and then perm("view").createServerAction().

Storefront actions are on an allowlist. They are meant to be tied to the shopper's session and cart, not to admin permissions.

In a page and a component

You export an admin page through withAdminPage from components/admin/auth-hoc.tsx. Its second argument, the permission, is required:

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. 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.

A second check rule, admin-page-auth, fails any app/admin/**/page.tsx that does not export through withAdminPage. This includes pages that only redirect, which are exactly the files someone might think are too small to protect.

Checks inside a component

In a server component, you write a single conditional check as 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 for each one without awaiting. The sidebar does this to decide which menu entries to show:

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 a third form, withAdminAuth(handler, permission) from lib/auth/auth-middleware.ts. It answers 403 instead of redirecting.

The 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.

That is why there are two separate entry files:

  • lib/permissions/server.ts is marked server-only. It re-exports the types, the checks and the procedures.
  • lib/permissions/client.ts exports only static types, constants and pure helpers, such as ROLE_META, ROLE_PERMISSIONS, getRolePermissions, getAssignableRoles and isAnyPermission. It must never import auth, the database, next/headers or any server-only module.

This split lets the build enforce the rule instead of relying on people to follow it. A client component cannot call a check, because the module it would need to import is not available on the client.

The storefront version

Shopper actions do not check permissions. A shopper has no role and no module grants.

lib/permissions/customer.ts exports customerProcedure, which does for the storefront what permissionProcedure does for the admin. It does three things:

  • It finds the signed-in customer through getSessionCustomerId. If a logged-in user has no Customer row yet, it creates one.
  • It passes ctx.customerId and ctx.userId to the handler.
  • It throws "Please sign in to continue" when nobody is signed in.

Because storefront actions build on it, they return the same zsa [data, error] tuple as admin actions. A caller does not need to know which side of the store it is calling.

No escalation

updateUser in server/admin/users/actions.ts is wrapped in withPermission("users:edit"). It also stops users from raising their own privileges.

ROLE_HIERARCHY in that file lists the roles from least to most privileged:

user
collab
support
editor
marketing
analyst
manager
admin
owner

updateUser 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. You can only assign a role equal to your own if you are the owner.
  • Only an owner can assign owner or admin.
  • 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:
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 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. You can deny a permission you do not have.

Which roles a person can assign

getAssignableRoles(assignerRole) in lib/permissions/roles.ts separately limits the choices in role pickers:

  • An owner can assign any role.
  • An admin can assign any role except owner, admin and custom.
  • Every other role gets an empty list.

getInvitableRoleOptions() builds the options for the invite dialog and the user filter from ROLE_META, leaving out owner and custom.

Deleting users is even stricter. deleteUsers is wrapped in requireSuperAdmin. It refuses to delete your own account, and it refuses any batch that contains a user with the admin or manager role.

The audit entry

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:

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 not awaited (void), and emitActivity never throws. If the audit write fails, the permission change still goes through.

config/activity-registry.ts registers user_permissions_changed with the label "Changed access", module admin and priority 8. That is the same priority as user_impersonated.

Which guard to use

When you write a server action, use a permission procedure. Start with modulePermission or one of the ready-made module procedures. Use withAnyPermission when one form covers two permissions, and a special procedure when the operation involves money, needs a super-admin or touches the danger zone.

When you add a page under app/admin, use withAdminPage. You cannot opt out, and a page that only redirects still exports through it.

When you add a route handler, use withAdminAuth. A fetch should get a 403, not a redirect.

In a server component, use an awaited hasPermission for a single condition. When you test many permissions, load the context once and use checkPermissionWithContext.

When the caller is a shopper and not an operator, use customerProcedure.

When you add a new admin screen, don't ask "did I remember to check?" Ask this instead:

Does the way this code is built make it impossible to forget the check?

On this page