Litestore · Developer docs
Authentication

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.

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

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.

AreaModules
Catalogproducts, categories, tags, collections, options
Ordersorders, returns, refunds, shipping
Customerscustomers
Marketingcampaigns, collabs, coupons, links, reviews
Contentmedia, blog, pages
Storefrontfeed
Insights and toolsanalytics, activity, tickets, emails, ai
Systemsettings, 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.

RoleStarting permissions in ROLE_PERMISSIONS
ownerHas super_admin, danger_zone, process_financials and manage_settings. Nothing else is listed, because super_admin passes every check.
adminCan do every action on every module and also has process_financials and manage_settings. It is explicitly denied danger_zone.
managerHas 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.
editorCan 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.
supportCan 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.
marketingHas 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.
analystHas 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.

platform/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 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:

platform/permissions/procedures.ts
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:

modules/orders/admin/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 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:

modules/catalog/admin/categories/actions.ts
export const upsertCategory = withAnyPermission(["categories:create", "categories:edit"])
ProcedureWhen you use it
withPermission(permission)The action needs one named permission.
withAllPermissions(permissions)The action needs every permission in a list.
requireSuperAdminThe action needs a super-admin.
requireFinancialsThe action involves money.
requireDangerZoneThe 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:

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

app/admin/pages/[id]/page.tsx
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
roles.ts
check.ts
procedures.ts
customer.ts

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
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. Assigning a role equal to your own is only allowed for 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.
modules/team/admin/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. 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.

AssignerRoles they can assign
OwnerAny role
AdminAny role except owner and admin
Every other roleNone

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:

modules/team/admin/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.

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.

On this page