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 againstorigin/mainof both repos — no code change.
- 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. - But
ROLE_PERMISSIONSis 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-endpointminRolegates, which are aligned to the matrix, not derived from it. - Three copies of one truth (api
ROLE_PERMISSIONS, webposRoles.ts, apirole-scopes.ts) + N hand-chosenassertStoreRole(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’sminRolematchesROLE_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 iffroleSatisfies(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_PERMISSIONSis consulted only byrole-scopes.ts(whose comment says its scopes “mirrorROLE_PERMISSIONS” —role-scopes.ts:8,26) and referenced inshift.service.ts:84,286(a comment about “reconciling this gate withROLE_PERMISSIONS(issue #288)” +CLOSE_MIN_ROLE). No guard doesROLE_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.
3. Row-94 sub-items (W40 screen)
Section titled “3. Row-94 sub-items (W40 screen)”- “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ónbutton (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).
4. Recommendation
Section titled “4. Recommendation”Ranked, cheapest-first:
- Contract test (do first, cheap): a test that asserts the web
ROLE_PERMISSIONSequals the apiROLE_PERMISSIONSverbatim. 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 existingposRoles.specpin. - Single source (recommended): generate/share the matrix. Publish
pos-roles.ts’sROLE_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 + reducerole-scopes.tsto 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. - Enforcement-alignment test (closes the §2 gap): derive or test each endpoint’s
assertStoreRole(minRole)againstROLE_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.
5. Platform roles (admin / operator)
Section titled “5. Platform roles (admin / operator)”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.
References
Section titled “References”- 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.