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
resyncrecovery + cleanup-script rule, terminales-api #965/#966).
The three planes
Section titled “The three planes”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 roles (Labels)
Section titled “Platform roles (Labels)”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.
Platform route matrix (operator vs admin)
Section titled “Platform route matrix (operator vs admin)”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.
Navigation audience
Section titled “Navigation audience”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.
Merchant (the owner)
Section titled “Merchant (the owner)”The Merchant is an organization owner, modeled as a record, not a label:
- Owns a
merchantsrecord; groups stores viastores.merchantId(see pos-domain-model.md § foundations). - Operates each store by holding the
managerteam 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 (
sponsorIdhangs off this record). A flat label could not express ownership scope or recruitment relationships.
Store-team membership roles
Section titled “Store-team membership roles”Active roles
Section titled “Active roles”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.
Reserved roles (not yet active)
Section titled “Reserved roles (not yet active)”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.
Permission matrix
Section titled “Permission matrix”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.
Approval workflow
Section titled “Approval workflow”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 & audit
Section titled “Impersonation & audit”- 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_logcollection (action,entity,entityId,before,after,userId; already implemented interminales-api/src/audit-log). - Audit is the accountability backstop for impersonation: an impersonated write must record both the acting identity and the impersonated subject.
White-labeling access
Section titled “White-labeling access”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.
When to use each Appwrite auth feature
Section titled “When to use each Appwrite auth feature”| 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.
Tenancy isolation tiers & portability
Section titled “Tenancy isolation tiers & portability”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/projectIdin role or RLS logic. Resolve them per-merchant through amerchantsrouting record (merchants.databaseId, futuremerchants.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.labeltemplates — 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/uplineIdadjacency 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.
Enforcement model
Section titled “Enforcement model”- RLS first for all CRUD: Appwrite document permissions are the enforcement point. No
@RequireRoleon 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 inEPosRoleTypeand scopes inauth.scopes.ts; the explicit mapping between them is still to be written (the current BetterAuthauth-access-control.tscovers only system roles, not POS roles). - Server code resolves
{databaseId, projectId}from the merchant routing record (see tiers above), never a constant.
Open questions & cross-links
Section titled “Open questions & cross-links”- BetterAuth → Appwrite gap: the
pos-roles.tsmatrix 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.