Appearance
Permissions Reference
How roles and permissions control access to back-office features.
For the full Team Management feature (inviting users, assigning roles, location access), see Team.
How Access Works
Access is permission-based, not a fixed tier list. The model has three layers:
- Permissions: Granular
verb-nounaction strings (for exampleview-transactions,edit-items,delete-customers). These are the atomic units of access. - Roles: Named sets of permission strings. A role is just a list of permissions. The
*wildcard grants every permission. - Users: Each user is assigned one or more roles. A user's effective permissions are the union of all their roles' permissions.
When a user attempts an action, the backend checks whether any of their roles contains either the required permission string or the * wildcard. If neither is present, the request is denied (403 Forbidden).
The platform ships default roles, and merchants can also create their own custom roles by picking from the granular permission list. So access can be customized per role; it is not limited to a fixed set of tiers. One naming constraint applies — see Reserved role names.
Default Merchant Roles
When a merchant account is created, six merchant-scoped roles are provisioned — Manager, Staff, Accountant, and the three POS access roles Cashier, Shift manager and Owner — all editable starting points. The Merchant Owner role is a platform-level role that is looked up by slug and assigned to the signing-up user, not created per merchant. (Verified: app/Services/Common/MerchantService.php — createMerchantDefaultRole(), which ends by calling ensurePosDefaultRoles(); assignMerchantOwnerRole().)
| Display Name | Slug | Access |
|---|---|---|
| Merchant Owner | vendor-owner | Full access via the * wildcard. Always has all-location access. Cannot be deleted. |
| Manager | vendor-manager | Broad day-to-day operations access (see below). |
| Staff | vendor-staff | Mostly view access, plus limited customer and loyalty actions. |
| Accountant | vendor-accountant | Finance-focused, view-only access. |
| Cashier | pos-cashier | Sells on the register — open prices, line discounts, void a line before payment, open own till. |
| Shift manager | pos-shift-manager | Runs the shift — refunds, voids, drawer, till close, approves escalations; also reads POS reports. |
| Owner | pos-owner | Full POS authority — every register capability plus POS configuration and reports. |
The three POS roles are seeded for every merchant, not only those on the first-party Upvendo POS — the call is unconditional. They are ordinary merchant roles (
is_system = false), so they show in the role picker and the merchant can edit or delete them; seeding is idempotent, so a customized bundle survives. Their names are not reserved slugs —Str::slug('Cashier')iscashier, notpos-cashier, so a merchant can still mint their own "Cashier". Full capability breakdown: Team Management.
Note: a per-merchant
adminrole exists, but nobody can be assigned it. TheRolesRepairdata-repair backfill mints anadminrole scoped to each merchant (vendor_id = <merchantId>,is_system = true,level = global, display name "Administrator"). It is not a platform-only role, and it is not created by ordinary merchant signup. Its permission list is the full expansion ofPermissions::getAllPermissions('admin', true)— 144 explicit permission strings, not the*wildcard — and it includes platform-level permissions such asview-field-ops,manage-field-ops, andedit-global-settings. (Verified:app/Console/Commands/DataRepair/RolesRepair.phplines 116-125;app/Constants/Roles.php—self::ADMIN => Permissions::getAllPermissions(self::ADMIN, true).)Because
roleOptions()filters out everyis_systemrole, thisadminrole does not appear in the role picker, andstoreUser()/assignRoles()reject it with "Invalid role" (HTTP 400) —roleOptions()is the only allow-list they validate against. That is deliberate: returning it would let a merchant owner self-assignadminand clear the field-ops permission gate. (Verified:app/Services/BackOffice/Settings/TeamService.phplines 43-53, 114-119, 239-243.)Genuinely platform-level roles —
global-administrator,global-support,global-developer,global-rep,field-ops,reseller-owner,reseller-administrator,reseller-rep— carry novendor_idand are not part of a merchant's team.
Some back-office surfaces gate on a role slug in addition to permissions. Nav items and route meta can carry an optional
role, which is ANDed with the usual CASL/permission gate;hasRole()readsuserData.role/userData.rolesand fails closed whenuserDatais absent. This exists becauseview global-settingsis also held byglobal-supportandglobal-developer, and CASL cannot tell those apart fromglobal-administrator— all three carrymanage:all. The current example is the Async Processing monitor (async-monitoring), which is restricted toglobal-administratoron both the nav entry and the route, matching theglobal-adminmiddleware on the backend prefix.
Merchant Owner (vendor-owner)
json
{ "slug": "vendor-owner", "permissions": ["*"] }Full access to everything. Always forced to all-location access and cannot be deleted.
Manager (vendor-manager)
Broad operational access. Granted permissions include:
- Items:
view-items,create-items,edit-items,delete-items,export-items - Inventory:
view-inventory,create-inventory,edit-inventory,delete-inventory,view-unit-cost,view-inventory-history,export-inventory-history - Menus:
view-menus,create-menus,edit-menus,delete-menus - Categories:
view-categories,create-categories,edit-categories,delete-categories - Modifier Groups:
view-modifier-groups,create-modifier-groups,edit-modifier-groups,delete-modifier-groups - Upsell Groups:
view-upsell-groups,create-upsell-groups,edit-upsell-groups,delete-upsell-groups - Variant Groups:
view-variant-groups,create-variant-groups,edit-variant-groups,delete-variant-groups - Customers:
view-customers,create-customers,edit-customers,delete-customers,export-customers,merge-customers,view-customer-orders,view-customer-gift-cards,view-customer-rewards - Transactions:
view-transactions,view-transaction-details,send-transaction-receipt,download-transaction,refund-transactions,view-transaction-payment-details,view-transaction-order,export-transaction-reports,view-transaction-items - Loyalty:
view-loyalty,create-loyalty,edit-loyalty,delete-loyalty,view-loyalty-points,edit-loyalty-points,view-rewards,create-rewards,edit-rewards,delete-rewards - Offers:
view-offers,create-offers,edit-offers,delete-offers - Devices:
view-devices,create-devices,edit-devices,delete-devices - Device Profiles:
view-device-profile,create-device-profile,edit-device-profile,delete-device-profile - Tax:
view-tax-rates,view-custom-tax-rates,create-custom-tax-rates,edit-custom-tax-rates,delete-custom-tax-rates - Sales Channel:
view-sales-channel,edit-in-house-channel,edit-online-channel - Online Ordering:
view-online-ordering,edit-online-ordering - Receipts:
view-receipts,edit-receipts - Branding:
view-branding-profile,create-branding-profile,edit-branding-profile - Localization:
view-language,create-language,edit-language,delete-language,view-translation,create-translation,edit-translation,delete-translation - Content:
view-content,create-content,delete-content - Users:
view-users,create-users,edit-users,delete-users,view-roles - Locations:
view-locations,view-location-details,create-locations,edit-locations,delete-locations,export-locations,edit-opening-hours - Notifications:
create-notifications
Managers do not get payment/billing profile management by default, and they do not get manage-location-billing — that one is owner-only, because deactivating a location cancels its Stripe subscriptions and payment test mode makes real orders free.
(Verified against Roles::defaultPermissionsMap()[Roles::VENDOR_MANAGER] in app/Constants/Roles.php — 111 permissions.)
Note:
view-location-detailsandedit-opening-hoursare granted to Manager but are not exposed in the role builder — they are commented out ofPermissions::locationManagement(). A custom role therefore cannot be given them through the UI.
Staff (vendor-staff)
Limited operational access, mostly view-only with a few customer and loyalty actions:
json
{
"slug": "vendor-staff",
"permissions": [
"view-customers",
"create-customers",
"edit-customers",
"view-customer-orders",
"view-transactions",
"view-transaction-details",
"send-transaction-receipt",
"view-transaction-order",
"view-transaction-items",
"view-loyalty",
"edit-loyalty-points",
"view-loyalty-points",
"view-rewards",
"view-inventory",
"view-unit-cost",
"view-inventory-history",
"view-items",
"view-categories",
"view-modifier-groups",
"view-variant-groups",
"view-menus"
]
}Accountant (vendor-accountant)
Finance-focused, view-only access:
json
{
"slug": "vendor-accountant",
"permissions": [
"view-tax-rates",
"view-custom-tax-rates",
"view-customers",
"view-customer-orders",
"view-transactions",
"view-transaction-details",
"download-transaction",
"view-transaction-payment-details",
"view-transaction-order",
"export-transaction-reports",
"view-transaction-items",
"view-payment-profiles",
"view-billing-profiles",
"view-inventory",
"view-unit-cost",
"view-inventory-history"
]
}Permission Groups
These are the permission groups shown in the merchant role builder (from Permissions::getAllPermissionsGrouped()), with their exact permission strings, in the order the builder renders them. (Verified: Permissions::getAllPermissionsGrouped() at app/Constants/Permissions.php:1188, with each group's contents from the corresponding method — e.g. locationManagement() at :792.)
| Group | Permissions |
|---|---|
| Device Management | view-devices, create-devices, edit-devices, delete-devices |
| Device Profile Management | view-device-profile, create-device-profile, edit-device-profile, delete-device-profile |
| Item Management | view-items, create-items, edit-items, delete-items, export-items |
| Inventory Management | view-inventory, create-inventory, edit-inventory, delete-inventory, view-unit-cost, view-inventory-history, export-inventory-history |
| Menu Management | view-menus, create-menus, edit-menus, delete-menus |
| Category Management | view-categories, create-categories, edit-categories, delete-categories |
| Modifier Management | view-modifier-groups, create-modifier-groups, edit-modifier-groups, delete-modifier-groups |
| Variant Group Management | view-variant-groups, create-variant-groups, edit-variant-groups, delete-variant-groups |
| Loyalty Program Management | view-loyalty, create-loyalty, edit-loyalty, delete-loyalty, view-rewards, create-rewards, edit-rewards, delete-rewards |
| Offer Management | view-offers, create-offers, edit-offers, delete-offers |
| Gift Card Management | view-gift-cards, create-gift-cards, edit-gift-cards, delete-gift-cards |
| Customer Management | view-customers, create-customers, edit-customers, delete-customers, export-customers, view-customer-orders, view-customer-gift-cards, view-customer-rewards, merge-customers, view-invoice-collection, manage-invoice-collection |
| Transaction Management | view-transactions, view-transaction-details, send-transaction-receipt, download-transaction, refund-transactions, view-transaction-payment-details, view-transaction-order, export-transaction-reports, view-transaction-items |
| Kitchen Reports | view-kitchen-reports |
| KDS Settings | manage-kds-settings |
| Payment Settings | view-payment-profiles, create-payment-profiles, edit-payment-profiles, delete-payment-profiles, view-billing-profiles, create-billing-profiles, edit-billing-profiles, delete-billing-profiles |
| Online Ordering | view-online-ordering, edit-online-ordering |
| User Management | view-users, create-users, edit-users, delete-users, view-roles, create-roles, edit-roles, delete-roles, assign-roles, edit-user-location-access |
| Location Management | view-locations, create-locations, edit-locations, delete-locations, export-locations, manage-location-billing, create-notifications |
| Content Management | view-content, create-content, delete-content |
| Tax Management | view-tax-rates, view-custom-tax-rates, create-custom-tax-rates, edit-custom-tax-rates, delete-custom-tax-rates |
| Sales Channel Management | view-sales-channel, edit-in-house-channel, edit-online-channel |
| Receipt Management | view-receipts, edit-receipts |
| Branding Management | view-branding-profile, create-branding-profile, edit-branding-profile, delete-branding-profile |
| Localization Management | view-language, create-language, edit-language, delete-language, view-translation, create-translation, edit-translation, delete-translation |
| Integration Management | manage-integration-settings |
| POS Management | view-pos-configuration, manage-pos-configuration, manage-pos-staff, manage-pos-layouts |
| POS Reports | view-pos-reports |
| POS Register (ungated) | apply-pos-manual-price, apply-pos-line-discount, void-pos-line, void-pos-order, refund-pos-sale, open-pos-drawer, manage-pos-cash-movements, open-pos-till, close-pos-till, approve-pos-variance, close-pos-business-day, approve-pos-as-manager |
| Business Details Management | view-business-details, edit-business-details |
Note: Permission strings are flat
verb-nounslugs (for exampleview-transactions), not dot-notation. Integrations are governed by a singlemanage-integration-settingspermission, not one permission per provider (there is no separatestripe,square,deliveroo, etc. permission).Note: Additional permission strings exist in the codebase (global-admin, reseller, and system-administration permissions) but are not exposed in the standard merchant role builder.
Note:
view-activity-logsgates the Settings → Activity Log surface. The nav entry carries the literal permission (src/navigation/settings/index.tsline 98) and the datatable API is gatedpermission:view-activity-logs(routes/api/backoffice/settings/activity-logs.phpline 9); the Vue route itself is ungated (src/plugins/1.router/additional-routes.tslines 109-113), so navigating directly to/settings/activity-logloads the page and the data call 403s. The permission lives inPermissions::systemAdministration()(Permissions.phpline 939), which is merged intogetAllPermissions()(line 1052) but not intogetAllPermissionsGrouped()(line 1070) — so it is never rendered in the role builder, even thoughStoreRoleRequest(line 49) would accept it if set directly via the API. In practice the only assignable merchant role holding it is Merchant Owner via*; the unassignable per-merchantadminrole (see the note above) holds it explicitly.Note: POS Management and the POS pages behind it are additionally gated by the
first-party-posmiddleware — a merchant whosepos_provideris notupvendogets a 403 on those routes no matter which permissions their role holds.Note:
view-pos-reportsis its own group, not part of POS Management. The approved "Shift manager" bundle reads POS reports without holding any POS-configuration permission (the Square shape: one permission set spans dashboard and register, configuration stays with the owner). While reports rode the POS Management group they were gated onview-pos-configurationin the role editor, which made that bundle unrepresentable.Note: The POS Register group is ungated (
gated: false, group idpos-register-group). Its twelve entries are register capabilities — what a person may do standing at the till — carried by ordinary platform roles and mapped 1:1 onto the register's internalPosPermissionenum viaPosPermission::platformPermission(). There is no "view the register" read to gate them on, so the role builder renders them as a flat action-only list. They used to ride POS Management, which forced any role wanting a single register capability to also takeview-pos-configurationand made the four-capability Cashier bundle literally unrepresentable. The group id itself is not a permission: it is absent fromgetAllPermissions(), soStoreRoleRequeststrips it and it can never be persisted onto a role.Note: Register capabilities are enforced server-side. Every gated POS route 403s with
{message, permission}and the register escalates on the 403. The register app currently stores its capability array and hides nothing — least-privilege register UI is a tracked follow-up, and would be an affordance on top of the 403s, not a replacement for them.Note: A reseller's sellable POS catalog can never contain the first-party Upvendo POS.
GET /back-office/pos-providers— the picker that fills the POS-providers field on the reseller create/edit dialog (upvendo-backoffice src/views/global-settings/resellers/ResellerFormDialog.vueline 249) — drops every provider whose config carriesfirst_party: truebefore it builds the response:array_filter($providers, fn ($provider) => ! ($provider['first_party'] ?? false))(app/Http/Controllers/Api/BackOffice/ResellerController.phpline 608). The filter is unconditional — nothing about the environment, the caller's role, or the provider'sactiveflag gates it, and it runs beforeactiveis even read (it is only copied into the payload at line 619). So it will still hold afterconfig/pos-providers.php'supvendo => activeis flipped totrue.upvendois the only entry carryingfirst_party => true(config/pos-providers.phpline 40).That guarantee comes from the picker, not from validation.
StoreResellerRequest(lines 34-35) andUpdateResellerRequest(lines 25-26) validatepos_providers.*as a barestringwith no allow-list, so a hand-crafted API call could still writeupvendoonto a reseller record. For provider selection that write is inert:ResellerService::getMerchantResellerProviders()drops any provider withactive => falsefirst (lines 726-728), then applies thetest_onlystaging filter (line 731), and, forfirst_partyproviders, returns early before the reseller-key check (lines 736-738), so the reseller's own list is never consulted for one.
It is not inert everywhere, though — do not treat
pos_providersas display-only.SubscriptionCommissionHandlerreads$reseller->getPosProviders()[0]at:216and:583and keys revenue-split and integration-fee resolution on it (Reseller.php:329getPosProviderRule,:360getProductRevenueRuleForProvider). Writing an unexpected slug into first position changes which commission rule resolves, or whether one resolves at all. The picker's filtering is a UI guarantee, not a validation guarantee.Who can reach that picker: the Resellers nav and its pages are gated on
view global-settings(upvendo-backoffice src/navigation/vertical/index.tslines 343-361), whichglobal-administrator,global-supportandglobal-developerall hold. The endpoint itself carries only the shared back-office stack (auth,type:backoffice,tenant:backoffice,check-user-activity) —routes/api/backoffice/resellers.phpline 9 adds nopermission:orglobal-adminmiddleware of its own.
Location-Based Access
Each user's access can be scoped to locations independently of their role:
| Setting | Effect |
|---|---|
All Location Access (all_location_access = true) | Access to all current and future locations. |
| Specific Locations | Only the locations explicitly assigned to the user. |
The Merchant Owner is always forced to all-location access. For other users, when all_location_access is off, the backend restricts data to the assigned location list.
Example: A user restricted to "Downtown" cannot see or manage "Airport" data, regardless of which role they hold.
Permission Check Flow
User attempts an action
│
▼
Collect the user's roles (no roles at all → Deny)
│
▼
Action scoped to another merchant?
→ Allow only if the user holds a reseller role
AND that merchant is in their merchant list; else Deny
│
▼
Global Administrator role WITH merchant_id = null? → Allow (full bypass)
│
▼
Location-bound role, and the location isn't assigned? → Deny
│
▼
Any role has wildcard "*"? → Allow
Any role has the exact string? → Allow
│
▼
Otherwise → Deny (403 Forbidden)The global-administrator bypass requires both the global-administrator slug and merchant_id === null. A role carrying that slug but scoped to a merchant is rejected — role creation is gated only by create-roles, which merchant owners hold, so the slug alone would otherwise be spoofable into a cross-tenant escalation. The same filter is applied to hasRole() and isGlobalUser(). (Verified: app/Services/PermissionService.php lines 46-52, 104-107, and 153-156.)
Location-bound roles are additionally checked against the user's assigned locations; Merchant Owner, reseller, and any role whose level is global, reseller, or partner are not location-bound.
Common Scenarios
"Staff can't edit menu items"
Expected: The default Staff role only includes view-items / view-menus (and similar view permissions). To allow editing, either assign a role that includes edit-items / edit-menus, or add those permissions to a custom role.
"Manager can't manage payment settings"
Expected: The default Manager role does not include payment/billing profile permissions (view-payment-profiles, edit-payment-profiles, etc.). Only the Merchant Owner (via *) has them by default. Add the relevant permissions to a custom role if a manager needs them.
"A manager can't see the Activity Log"
Expected: view-activity-logs is not offered in the role builder (it lives in the system-administration group, which getAllPermissionsGrouped() omits), so only Merchant Owner holds it via *. It cannot be added to a custom role from the UI.
"A user can't see a feature they should"
Check: Whether any of the user's roles includes the required permission string, and whether the feature's location is within the user's assigned locations.
"A new team member can't see other locations"
Check: The user's location assignment. Enable All Location Access, or add the specific location.
"This role name is reserved by the platform"
Expected: The name you chose slugifies onto a platform role slug (for example "Admin" → admin, "Field Ops" → field-ops). Pick a different display name — "Administrator", "Manager", "Kitchen Staff" and similar are all fine. See Reserved role names.
"I can see an Administrator role but can't assign it"
Expected: That is the system-managed per-merchant admin role. System roles are filtered out of the role picker, and assigning one is rejected with "Invalid role". Create a custom role with the permissions you need instead.
Changing What Someone Can Do
There are two ways to adjust access:
- Reassign roles: In Settings → Team → Users, edit the user and change which role(s) they hold. A user's effective permissions are the union of all assigned roles.
- Edit or create a role: In Settings → Team → Roles, edit an existing role's permission list or create a custom role with exactly the permissions you need (for example "Kitchen Staff" or "Marketing"). Custom roles are scoped to your merchant account. Role names must be unique within your account, and a small set of names is reserved — see below.
Note: You cannot assign roles to yourself or change your own location access (the backend rejects both). The Merchant Owner (
vendor-owner) role cannot be deleted, and you cannot delete your own account.Permission updates take effect on the user's subsequent requests.
Reserved Role Names
A role's slug is derived from its display name (lower-cased, spaces to hyphens), and creating or renaming a role onto a slug that matches a platform role is rejected:
This role name is reserved by the platform. Please choose a different name.
The response status is 422. This guard exists because role creation is gated only by create-roles / edit-roles, which merchant owners hold — without it a merchant could name a role "Global Administrator", land on the global-administrator slug, and satisfy every slug-based authority check.
The reserved slugs are exactly the keys of Roles::defaultPermissionsMap():
global-administrator · global-support · global-developer · global-rep · field-ops · reseller-owner · reseller-administrator · reseller-rep · vendor-owner · admin · vendor-manager · vendor-staff · vendor-accountant
So the display names that trip this are the ones that slugify onto that list — "Admin", "Field Ops", "Global Administrator", "Reseller Owner", "Vendor Manager", and so on.
Names that merchants often reach for are not reserved, because they slugify to something else: "Administrator" → administrator, "Manager" → manager, "Staff" → staff, "Owner" → owner, "Kitchen Staff" → kitchen-staff, "Marketing" → marketing. Location-level roles the merchant fully customizes are deliberately left unreserved.
Note that a plain "Manager", "Staff", or "Accountant" will usually still be refused, but for a different reason and with a different message — the default roles already occupy those display names, so you get "Name should be unique" (400) instead.
(Verified: app/Services/BackOffice/Settings/TeamService.php — guardReservedSlug() lines 375-384, called from storeRole() line 362 and updateRole() line 410; uniqueness check at lines 352-357; app/Constants/Roles.php lines 84-87.)