Skip to content

Roles, permissions & labels

Purpose: the authoritative what of authorization — every role, the permission matrix, label usage, and when to use each Appwrite auth feature. The why and the login/account flows live in auth-and-multitenancy.md; this doc is the structured reference that sits beside it. Status: as of 2026-09-26 (verification resync recovery + cleanup-script rule, terminales-api #965/#966).

Authorization splits into two planes; a future third plane is commercial, not authorization. Keeping them separate is the load-bearing idea of this doc.

Plane Appwrite mechanism Audience Governs
Platform Labels (Role.label(...)) InspiraCode staff (Admin / Operator) Cross-merchant operations & support
Store (tenant) Teams + memberships (Role.team(...)) Merchant owner + employees Per-store operational data (RLS)
Network (future) Graph data (not Teams, not Labels) Distributors Recruitment & commission relationships — see § Network / distributor plane

Merchant straddles platform and store: identified at the platform plane by merchants.ownerId, it operates at the store plane via manager membership on each owned store team. It is deliberately not a label (see § Merchant).

Platform staff are cross-merchant and are granted via Appwrite Labels — never store-team memberships. Labels are project-scoped (see § Tenancy isolation tiers). Every platform action is audited.

There are two platform roles, and only two (PO brainstorm 2026-09-18 — multi-platform-roles-admin-operator; decisions). Admin ⊇ Operator: an admin can do everything an operator can, plus the dangerous and structural powers. The single server guard is assertPlatformRole(labels, ...allowed) and platformAdmin satisfies any operator-allowed route.

Role Label Can do Notes
Operator platformOperator The day-to-day queues: merchant & bank-account verification, deposit review, liquidaciones (incl. the on-demand rollup), device-enablement diagnosis, read-mostly merchant directory / basic assistance, VAS catalog sync. Read-mostly on the merchant directory; the dangerous/structural actions in the Admin row are withheld. Every action audited.
Admin platformAdmin Everything Operator can, plus the dangerous & structural powers: granting/revoking platform roles, platform config & feature flags (including the realm switch merchant.unverifiedCanOperate), PSP credentials, verification attest (verify on behalf) / revoke / unlock, merchant lifecycle (create / suspend / reactivate / delete — suspension is the kill-switch since allowUnverifiedSales was retired, see merchant-verification-policy.md). “Manage only their own merchants” is not a blanket label — scope it via merchants.ownerId / an explicit admin↔merchant assignment, checked server-side. A bare platformAdmin label is global; ownership scoping is data, not a role.

Deferred — not roles today. A support role (platformSupport), a finance role, and a developer role (platformDeveloper) are explicitly out of scope for now — the 2026-09-18 decision is two roles only. Impersonation is post-MVP — no platform role impersonates a user today. These are listed so the model stays forward-compatible, not because anything exercises them.

Granting is a product action, not a script. Platform roles are granted and revoked from the product by an admin (no script-only path), and every grant/revoke is written to audit_log. The kill switch for any platform capability is revoking the label from the user.

Labels-only — the known restore caveat. Platform authorization is labels-only (Option A). The one accepted caveat: Appwrite backup restore does not replay user labels, so a restore can silently drop platform grants — tracked and owned by api-backup-restore-does-not-replay-user-labels-authz.

Every platform route resolves through assertPlatformRole. operator = the caller carries platformOperator or platformAdmin; admin = platformAdmin only. The source of truth for the code change is the API ticket (inspiraCode/terminales-api#767); this table mirrors its decided target.

Route Required role
GET /pos/settlements/ops/requests, GET /pos/settlements/ops/bank-accounts operator
POST /pos/settlements/ops/rollup operator
PATCH /pos/settlements/requests/:id/status operator
GET /pos/settlements/bank-account/admin, PATCH /pos/settlements/bank-account/:id/verification operator
POST /pos/admin/merchants/:id/verification/confirm-business, .../verification/resync operator
POST /pos/admin/merchants/:id/verification/attest, .../verification/revoke, .../verification/unlock admin (the old .../override route is gone — merchant-verification-admin.controller.spec.ts asserts it is not registered)
GET /platform/settings operator (read)
PUT /platform/settings/:key (incl. merchant.unverifiedCanOperate) admin
POST /vas/admin/catalog/sync operator
GET /platform/merchants*, GET /platform/summaries/* (cross-tenant read) operator
GET /workflows/:id, /steps (any-workflow read) operator
POST|PATCH|GET /admin/psp-credentials admin
POST /pos/merchants, DELETE /pos/merchants/:id, merchant status edits admin
POST /platform/impersonations admin (post-MVP; untouched)

…/verification/resync is the recovery path. It re-derives the merchant by calling recomputeMerchant directly — not through a DBOS workflow id — so it always runs. Use it for a merchant stuck lapsed or wrongly verified, and after a recompute trigger exhausted its 10 retries (verification.recompute.retries_exhausted, terminales-api #966). attest answering 409 on an already-VERIFIED bank account is expected; resync is the path. Cleanup scripts that delete or rewrite bank-account rows directly in the database must call resync for each affected merchant afterwards — a direct write bypasses the bank service, so no per-event recompute (terminales-api #965) fires and the merchant’s isVerified goes stale.

Service-level re-asserts must match the controller’s role — a controller widened to operator while its service still asserts admin is a silent 403.

The ADMINISTRACIÓN navigation group is for platform staff only — admins and operators. Merchant-plane roles (owner / manager / supervisor / cashier) use a separate group, MI NEGOCIO. A user carrying only a platform label sees ADMINISTRACIÓN; a merchant user never does. This resolves the TODO(human) in terminales-api/src/navigation/navigation-seed.ts.

The Merchant is an organization owner, modeled as a record, not a label:

  • Owns a merchants record; groups stores via stores.merchantId (see pos-domain-model.md § foundations).
  • Operates each store by holding the manager team membership on that store’s team — the same role a salaried store manager holds. This is what makes store-data RLS “just work” for the owner; no special-case code.
  • Manages stores, employees (invites/roles), and white-label themes (screens W48–W54 in frontend-context).
  • Why a record, not a label: the owner anchors the future distributor network graph (sponsorId hangs off this record). A flat label could not express ownership scope or recruitment relationships.

The POS role is the Appwrite team-membership role — manager / supervisor / cashier (team roles are arbitrary strings). These three are the real, assignable staff roles, grounded in the built product: the employee role enum is cashier | supervisor | manager (EPosRoleType in pos-role.entity.ts; PosEmployeeDto in the web generated types), the PIN badge shows role (screens M02/W04), W38 assigns a role, and W40 renders the role matrix.

Role Granted by RLS expression Scope Approvals
manager Merchant/Admin (and held by the owner) Role.team(storeTeamId, 'manager') Full control of the store (catalog, inventory, staff, stores/terminals, reports). Approves everything a supervisor can.
supervisor manager Role.team(storeTeamId, 'supervisor') Manage inventory, products, services, store staff. Whole-store reporting. Approves cashier discounts, refunds, returns.
cashier manager/supervisor Role.team(storeTeamId, 'cashier') Sales & transactions; own profile, own shift, own receipts. Requests approvals from a supervisor.

Report scopes (cashier = own shift, supervisor = store, manager/owner = portfolio) are defined in reporting.md and must match this table.

The 88 designed screens are entirely staff-facing POS. “Customer” exists today only as a data record (customers collection + sales_orders.customerId, assigned to a sale by a cashier — the Clientes module, frontend-context §5), and there is no public storefront / anonymous flow. These two roles are documented so the model stays forward-compatible, but they are not active — no screens exercise them today:

  • customer — an authenticated Account with no team membership; reaches only its own rows via Role.user(id). Activated by a future customer portal.
  • guest — anonymous/unauthenticated session; public reads only via Role.guests. Activated by a future public storefront.

Rows = collection groups (pos-domain-model.md); columns = active store roles. W = read+write, R = read, — = none. Each write maps to a Role.team(storeTeamId, <role>) grant on the row and the corresponding AuthScope in auth.scopes.ts.

Domain / collections manager supervisor cashier Scope (AuthScope)
catalog — categories,products,variants,barcodes,tax_rates W W R POS_CATALOG_READ/WRITE
inventory — stock_levels,stock_movements W W R POS_INVENTORY_READ/WRITE
sales — carts,line_items,sales_orders,order_payments,transactions,receipts W W W POS_SALES_READ/WRITE, TRANSACTIONS_*
discounts / approvals — discounts,approvals W (approve) W (approve) R + request POS_SALES_*
shifts — shifts,cash_movements W W W (own) POS_SHIFTS_READ/WRITE
staff — employees W R R (own) POS_EMPLOYEES_READ/WRITE
stores — stores,terminals W R R POS_STORES_READ/WRITE, TERMINALS_*
reporting — shift_reports,daily_sales_rollups R (portfolio) R (store) R (own shift) POS_REPORTS_READ
themes — merchant_themes (planned, BG-7) W R R —
audit — audit_log R (scoped) R (scoped) — system-written

Reserved roles: customer reaches only its own future-portal rows (Role.user); guest gets public reads only (Role.guests). Neither appears in the active matrix.

Cashiers cannot self-approve exceptions. A cashier requests and a supervisor (or manager) approves:

  • Discount on a line or cart.
  • Refund of a line or cart.
  • Return of a line or cart.

Each approval is an approvals row (linked from discounts.approvalId, pos-domain-model.md) and is written to audit_log. Screens: M+1/M+2 (mobile), W13/W31/W36 (web).

  • Impersonation is post-MVP — no platform role impersonates a user today (see § Platform roles). This section records the design so that audit already accounts for it when it lands.
  • Every sensitive action — including future impersonated ones — writes to the audit_log collection (action, entity, entityId, before, after, userId; already implemented in terminales-api/src/audit-log).
  • Audit is the accountability backstop for impersonation: an impersonated write must record both the acting identity and the impersonated subject.

merchant_themes (white-label branding: colors, typography, logoFileId, dark mode — planned BG-7) is manager/Merchant-editable; supervisor and cashier are read-only. A platform Operator may assist with theme setup as part of basic merchant assistance. Runtime application is covered in theming; editor screen is W54.

Feature Use it for Don’t use it for
Account The logged-in user’s own session, profile, MFA, recovery — self-service flows. Server-authority admin operations.
Users (server) Admin user CRUD, PIN custom-token minting, invites — anything needing the API key. Routine reads/writes (those go client-direct under RLS).
Teams + Memberships The store tenant boundary; membership role = POS role; invites add a user to a store team. Cross-merchant platform staff (use Labels). Project-scoped: does not cross Appwrite projects.
Labels Cross-merchant platform staff (platformAdmin / platformOperator) and the future distributor capability flag. Per-store POS roles. Project-scoped: does not cross Appwrite projects.
Permissions Per-row document permissions (read/write) granting Role.team/user/guests. Coarse, app-wide gates.
Roles (Role.* helpers) The expression language inside Permissions: team, member, user, label, guests. —
Impersonation Post-MVP platform debugging — always audited (deferred; no role impersonates today). Routine access; never silent.
Preferences Non-authorization user settings (locale, UI prefs, notification toggles). Roles or permissions — prefs are not a security boundary.
Row-Level Security (document permissions) Default for all tenant data — the primary isolation mechanism, portable across every tenancy tier. —
Table/Collection-Level Security Collections where every row shares identical access (global reference data, e.g. shared tax_rates templates). Tenant-owned data — that needs row-level scoping.

Account / Users / Teams / Labels are all project-scoped. A project-per-merchant split (tier T2 below) therefore fragments platform-staff identity and needs a control-plane project.

The role model must survive a later split into separate databases or projects (for DB growth or billing isolation) without redesign. Appwrite’s multi-tenancy guidance prescribes the Teams + Role.team(...) model we use but is silent on single-vs-separate DB/project — that is our architectural call.

Tier Shape Isolation Trade-off
T0 (current) one project, one shared database, RLS by team Role.team(...) document permissions Cheapest; cross-merchant reporting trivial; platform Labels/impersonation reach everything.
T1 one project, database-per-merchant per-databaseId separation, same Teams/Labels Size/migrate a heavy tenant easily; cross-merchant queries now span DBs.
T2 project-per-merchant full billing/quota/blast-radius isolation Teams/Labels/Users/Accounts are project-scoped → platform identity, impersonation, cross-merchant admin fragment; needs a control-plane project + identity replication + a routing map.

Portability rules to follow now (even at T0):

  • Never hard-code a single databaseId/projectId in role or RLS logic. Resolve them per-merchant through a merchants routing record (merchants.databaseId, future merchants.projectId) so a tenant relocates by config, not code.
  • Keep collection / team / label names stable across tiers.
  • A tier migration is a data move + re-grant of the same Role.team/Role.label templates — not a model change.
  • RLS is the one isolation primitive portable across all tiers.

Network / distributor plane (forward compatible)

Section titled “Network / distributor plane (forward compatible)”

The system must be forward-compatible with a future MLM-style network where a user becomes a distributor that recruits others for benefits/commissions (rules TBD). This is the third plane — commercial, not authorization — and the current model accommodates it additively.

  • Distributor capability = a platform-grantable Label distributor (sits alongside the platform roles above). It gates who may recruit, not who can read which store data.
  • The network is graph data, not Teams or Labels (both are flat and cannot nest): a sponsorId/uplineId adjacency on the merchant/distributor record, plus a closure table or materialized path for N-level upline/downline rollups. New collection(s) TBD (distributors / network_edges / network_closure) — not built now. The Merchant-as-record decision is what makes this possible.
  • Hard isolation rule: downline visibility grants read on commission/network-summary collections scoped by graph position only — never Role.team(downlineStore) on operational store data. A distributor sees money/relationship aggregates, never their recruits’ sales rows.
  • Enrollment is a recruit-invite flow distinct from the store-team employee invite: it creates/links a new merchant/distributor node under the inviter’s downline, not a team membership. Sensitive → audit_log.
  • Commissions will be a future append-only ledger (reusing the offline-and-idempotency.md ledger pattern); existing balance-movement/transfers are the money-movement precedent. Out of scope to define here.
  • Tenancy interaction: commission rollups need cross-merchant reads → keep network/commission data in a shared control-plane database, regardless of how store data is later partitioned.
  • Open question: eligibility — open to any merchant vs platform-granted vs sponsored-only. Recommendation: platform-grantable label + sponsorship-driven enrollment; defer the benefit/commission rules.
  • RLS first for all CRUD: Appwrite document permissions are the enforcement point. No @RequireRole on CRUD.
  • A lightweight assertStoreRole() runs only inside the few server endpoints that remain (PIN mint, invites, user/team admin, durable workflows).
  • Single source of truth for the role→permission mapping is the code matrix src/auth/roles/pos-roles.ts — not yet created. Today roles live in EPosRoleType and scopes in auth.scopes.ts; the explicit mapping between them is still to be written (the current BetterAuth auth-access-control.ts covers only system roles, not POS roles).
  • Server code resolves {databaseId, projectId} from the merchant routing record (see tiers above), never a constant.
  • BetterAuth → Appwrite gap: the pos-roles.ts matrix file does not exist yet; this doc describes the target.
  • T2 control-plane design (identity replication / SSO across projects) is unspecified.
  • Distributor eligibility & commission rules are deferred (see network plane above).

Related: auth-and-multitenancy.md · pos-domain-model.md · reporting.md · offline-and-idempotency.md · appwrite · architecture-decisions · ../overview/glossary.md · theming · Appwrite multi-tenancy.