Skip to content

Roles→permissions matrix — web ↔ api sync & drift analysis

Status: 2026-09-09 (merchants TL, canvas-review row 94); updated 2026-09-19 (PlatformRoles foundation — added §5 Platform roles). Companion to roles-permissions-and-labels.md. This note answers Alfredo’s W40 review item: “analyze whether the roles/permissions matrix is correctly implemented and reflects the actual user capabilities.” Read-only analysis against origin/main of both repos — no code change.

  1. The web W40 matrix (terminales-web/src/components/PosRoles/posRoles.ts) and the api canonical (terminales-api/src/auth/roles/pos-roles.ts — ROLE_PERMISSIONS) are currently identical — no drift.
  2. But ROLE_PERMISSIONS is a descriptive/documentation matrix, not the runtime gate. The api enforces with role-rank minimums (assertStoreRole(ctx, storeTeamId, minRole) → roleSatisfies/ROLE_RANK), chosen per endpoint by hand — and there is a third hand-mirror (role-scopes.ts). So “actual capabilities” are the per-endpoint minRole gates, which are aligned to the matrix, not derived from it.
  3. Three copies of one truth (api ROLE_PERMISSIONS, web posRoles.ts, api role-scopes.ts) + N hand-chosen assertStoreRole(minRole) call-sites = a standing drift risk. Recommendation: generate the web mirror from the api canonical (shared package / codegen), and add a contract test that each endpoint’s minRole matches ROLE_PERMISSIONS. Details in §4.

1. The two matrices are identical (no drift today)

Section titled “1. The two matrices are identical (no drift today)”

Both define the same roles, the same 14-permission union, and the same cumulative composition:

Role Permissions (cumulative)
cashier catalog:read, inventory:read, sales:read, sales:create, shifts:open, shifts:close, cash:movement (7)
supervisor + sales:void, inventory:write, reports:read, employees:read (11)
manager + catalog:write, employees:write, store:manage (14)
  • api: terminales-api/src/auth/roles/pos-roles.ts — CASHIER_PERMISSIONS / SUPERVISOR_PERMISSIONS / MANAGER_PERMISSIONS → ROLE_PERMISSIONS (pos-roles.ts:60-94).
  • web: terminales-web/src/components/PosRoles/posRoles.ts — the same three arrays → ROLE_PERMISSIONS (posRoles.ts:60-108), explicitly documented as a hand-copied mirror of the api (see its header comment).

Verified line-by-line on origin/main 2026-09-09: no divergence. The W40 screen therefore renders the documented capability model correctly, and the web posRoles.spec pins the exact per-role set as a web-side guardrail (AC2 of #299).

2. …but ROLE_PERMISSIONS is not what the API enforces

Section titled “2. …but ROLE_PERMISSIONS is not what the API enforces”

The runtime authorization gate is role-rank, not permission-set membership:

  • terminales-api/src/auth/roles/assert-store-role.ts:49-58 — assertStoreRole(ctx, storeTeamId, minRole) passes iff roleSatisfies(role, minRole) (i.e. ROLE_RANK[role] >= ROLE_RANK[minRole]).
  • Controllers/services call it with a hand-chosen minRole, e.g. invites.service.ts:366,563 (assertStoreRole(ctx, storeTeamId, 'manager')).
  • roleHasPermission / ROLE_PERMISSIONS is consulted only by role-scopes.ts (whose comment says its scopes “mirror ROLE_PERMISSIONS” — role-scopes.ts:8,26) and referenced in shift.service.ts:84,286 (a comment about “reconciling this gate with ROLE_PERMISSIONS (issue #288)” + CLOSE_MIN_ROLE). No guard does ROLE_PERMISSIONS[role].includes(permission) at request time.

Implication: the matrix is consistent with enforcement today because every capability’s real gate is a rank minimum and the matrix is cumulative-by-rank — but each endpoint’s minRole is an independent hand decision. Nothing mechanically guarantees, say, that the endpoint behind sales:void actually requires supervisor (the matrix’s claim). The #288 reference in shift.service.ts is evidence such a reconciliation has already been needed once.

  • “Reflects actual capabilities?” — Yes for the documented model, with the caveat in §2: it mirrors ROLE_PERMISSIONS, and enforcement is rank-based and hand-aligned to it. No mismatch found today.
  • “Remove Ver documentación button (does nothing)” and “canvas checkmark colors” — pure web UI, not part of this analysis; they belong in the W40 parity slice (web-parity-staff-roles-invite, FD-7 draft).

Ranked, cheapest-first:

  1. Contract test (do first, cheap): a test that asserts the web ROLE_PERMISSIONS equals the api ROLE_PERMISSIONS verbatim. Today they live in separate repos, so this needs the api matrix reachable to the web test — which motivates (2). As an interim, keep the existing posRoles.spec pin.
  2. Single source (recommended): generate/share the matrix. Publish pos-roles.ts’s ROLE_PERMISSIONS (+ POS_ROLES, PosPermission) from a shared package (or a codegen step the api emits and the web imports), and delete the web hand-mirror + reduce role-scopes.ts to a derivation. This removes three copies down to one. The static matrix does not warrant a runtime read-endpoint (it is compile-time-known; an endpoint adds a network round-trip and a cache to display a constant) — prefer the shared package unless a future need to toggle permissions at runtime appears.
  3. Enforcement-alignment test (closes the §2 gap): derive or test each endpoint’s assertStoreRole(minRole) against ROLE_PERMISSIONS, so the gate provably matches the matrix (not just the two matrices matching each other). This is the deeper guarantee row 94 is really asking for.

No code shipped from this note (row 94 said “no code unless drift is found”; no matrix drift found). Items (1)–(3) are proposals for the PO to route as follow-up slices; (2) is the durable fix and would let the web posRoles.ts mirror be deleted.

The sections above cover the store plane (POS cashier < supervisor < manager). The platform plane — InspiraCode staff granted via Appwrite Labels — has, as of the 2026-09-18 PO brainstorm, exactly two roles (see the companion roles-permissions-and-labels.md § Platform roles and ../../backlog/ideation/multi-platform-roles-admin-operator.md):

Role Label Scope
Operator platformOperator Day-to-day platform queues (verification, deposits, liquidaciones incl. rollup, device diagnosis, merchant directory read-mostly, VAS catalog sync).
Admin platformAdmin Operator ⊇ everything Operator can, plus dangerous/structural powers (grant/revoke roles, config & feature flags, PSP credentials, overrides, verification revoke/unlock, merchant lifecycle).

Support / finance / developer roles are deferred, and impersonation is post-MVP. Unlike the store matrix, the platform gate is not rank-vs-permission-set: one guard, assertPlatformRole(labels, ...allowed), admits a caller carrying any allowed label, and platformAdmin satisfies every operator route.

Drift note (why this belongs here): the full platform route matrix — which route requires operator vs admin — is decided in the API ticket inspiraCode/terminales-api#767 and mirrored in the companion doc’s § Platform route matrix. To avoid a third hand-copy of one truth (the §4 lesson), it is not re-tabulated here — cite the companion doc and the API ticket, not a copy.

Navigation audience: the ADMINISTRACIÓN group is platform-staff-only (admins + operators); merchant-plane roles use the separate MI NEGOCIO group. Resolves the TODO(human) in terminales-api/src/navigation/navigation-seed.ts.

  • api canonical: terminales-api/src/auth/roles/pos-roles.ts (ROLE_PERMISSIONS, ROLE_RANK, roleSatisfies).
  • api enforcement: terminales-api/src/auth/roles/assert-store-role.ts; role-scopes.ts; shift.service.ts (#288).
  • web mirror + screen: terminales-web/src/components/PosRoles/posRoles.ts (+ posRoles.i18n.ts), src/core/auth/roles.ts.
  • roles-permissions-and-labels.md — the role/label model this matrix serves.