Role-based access control
Who can see what, and what they can do about it. The consoles (operator.kiosk-x.ai,
/admin, /superadmin) do not invent a second permission model — they show
whatever the Partner API grants the signed-in principal. Operator-OS labels
such as field_tech are product segments, not API roles.
The source of truth for scopes is app/config.py (ALL_SCOPES,
OPERATOR_SCOPES, FACTORY_SCOPES). How a request becomes a Principal is
in authentication.md. Tenant narrowing lives on
Principal.tenant_scope() / Principal.browse_scope() in app/auth.py.
The identities
These are independent gates. Holding one does not confer another.
| Identity | How you get it | What it is |
|---|---|---|
| Operator | Sign-in at /api/v1/auth/login, or an operator API key. operatorEmail is pinned. Scopes = OPERATOR_SCOPES. |
Owns a fleet. Sees only that fleet's machines, books, factory paperwork and shipping. Cannot write a build record or accept delivery of someone else's cabinet. |
| Full admin | Admin API key, Cognito group admin / Admins / super_admin with accounts:admin, or an */all M2M client. Principal.full_admin = admin ∧ accounts:admin. |
Cross-tenant reads and writes. Enumerates operators. Can emulate any operator for reads. Can write factory data (same PUT the plant uses). Cannot take the operator acceptance signature — that requires principal.operatorEmail. |
| Browse-admin | A Cognito identity with admin=True but no accounts:admin. |
Sandbox browsing of non-money surfaces. Money, PII, accounts list, and the operator picker all 403. Cannot POST /sandbox/reset or POST /api/v1/orders/reconcile-stranded — those are full-admin money writes. Do not treat admin on /api/v1/auth/me as full admin. |
| MFA super-admin | Operator login (factor 1a) whose email is on KIOSKX_SUPERADMIN_ALLOWLIST, plus KIOSKX_SUPERADMIN_PASSWORD, plus TOTP. Session cookie kioskx_superadmin → /api/v1/superadmin/*. |
Autopilot capital, deal-level manufacturer orders, and the per-serial factory/shipping ledger. This is not the Cognito super_admin group. A Cognito super_admin without the MFA session cannot hit /superadmin. An MFA super-admin without accounts:admin still cannot impersonate writes on the operator API. |
| Factory / manufacturer | Plant key (vtm_kioskx_factory_zhzn_…, vtm_kioskx_factory_hotbox_…, or the live KIOSKX_FACTORY_KEY). Scope is exactly machines:build, pinned to one manufacturer name. |
Writes build, tests, factory sign-off, and shipping on serials named as that plant's. Cannot list operators, read books, claim machines, see acquisition cost, or accept delivery. This is the manufacturer.kiosk-x.ai contract — there is no second frontend; the plant talks to these endpoints. |
Consoles
| Surface | URL | Who | What they see |
|---|---|---|---|
| Operator console | operator.kiosk-x.ai |
Any signed-in operator. Full admins land here too. | Own fleet. Machine detail includes Build & factory QA (tests, both sign-offs, crate shipping). |
| Operator picker | Top bar (desktop) / More (phone) / Admin sidebar | Full admin only (canSwitchOperator = admin ∧ accounts:admin) |
Sets kx.actingOperator. Subsequent reads append ?operatorEmail= on SCOPED_PREFIXES in lib/acting-operator.ts. Writes stay on the admin's own account. Reload on change. Banner is the reminder. |
| Admin hub | /admin |
Same session as the operator console; pages assume a signed-in user. Cross-tenant data only if the principal is full admin. | Fleet ops. Factory (/admin/factory) is GET /api/v1/machines/build/summary — auto-scoped by the picker. Machine detail mounts the same build card, without the Accept button. |
| Super-admin | /superadmin |
MFA session on top of a signed-in allowlisted account. | Orders = deal-level manufacturing status. Factory = every serial's QA, sign-off timestamps, carrier, tracking, ship-to. Links through to /admin/machines/{no}. |
Manufacturer sign-off and shipping
There is one record per serial (app/machine_build.py), not a parallel
"manufacturer portal database". The plant (or a full admin) writes it:
PUT /api/v1/machines/{machineNo}/build
{
"manufacturer": "zhzn",
"shippedAt": "2026-08-20",
"shipping": {
"carrier": "DHL",
"trackingNumber": "DHL1234567890",
"consignmentRef": "ZH-CRATE-014",
"shipToName": "Superior Vape",
"shipToAddress": "4100 S Dairy Ashford",
"shipToCity": "Houston",
"shipToRegion": "TX",
"shipToPostal": "77082",
"shipToCountry": "US"
}
}
Then the bench posts tests and the factory QA signature (POST …/build/tests,
POST …/build/signoff). The operator — and only the operator who owns the
cabinet right now — accepts delivery (POST …/build/accept). Ownership is
derived from STORE.machines; a resale does not orphan the test history.
Who can read that record:
| Caller | Own serial | Another operator's serial |
|---|---|---|
| Owning operator | Yes, including shipping. Acquisition cost is theirs. | 404 |
| Full admin / MFA super-admin | Yes, every serial. Admin card hides Accept. | Yes |
| Factory key for that plant | Yes, acquisition stripped. | 404 / 403 for other plants |
| Anyone else | 404 | 404 |
Deal-level tracking on a DFY manufacturing order (/superadmin Orders, Autopilot
deal page) answers "where is my purchase?". Serial-level shipping answers
"where is this crate?". They are allowed to disagree; they are not the
same field.
Plant runbook: zhzn-factory-install.md §4.
Acting-operator is a read preference, not impersonation
kx.actingOperator is stored in the browser and attached as ?operatorEmail=
on listed prefixes. The API honours that parameter only for full_admin.
An operator who forges the query still sees their own tenant. Creates, patches,
accepts, payouts and payroll writes use the authenticated principal, which is
why the picker footnote says so.
Operator-OS "roles"
POST /api/v1/operator-os/roles stores field_tech, refiller, etc. as
osRoles on an email — the signed-in operator, a Team member, or (full admin)
any mailbox, including plus-tags and corporate domains. Adding that person to
Team with role: field_tech writes the same label. Those unlock agent flavours
in the Operator OS product when that email later signs in. They confer
no Partner API scope. Do not use them as RBAC. A crew mailbox is not an
operator tenant: it does not appear in GET /api/v1/accounts and cannot sign
in with the sandbox demo password.
POST /api/v1/accounts accepts roles: ["field_tech", …] on create (the old
integer roleId was never stored).
Scope cheat sheet
| Scope | Operator | Full admin | Factory |
|---|---|---|---|
machines:read / write |
own fleet | all fleets | no |
inventory:* |
own fleet | all fleets | no |
orders:* |
own sales | all sales | no |
commands:* |
own cabinets | all cabinets | no |
ads:* |
own campaigns | all campaigns | no |
machines:build |
no (cannot write provenance) | yes | only this |
accounts:admin |
no | yes | no |
A credential missing a scope gets 403 Insufficient scope. A credential
asking for another tenant's row gets 404 on operator-scoped resources so
existence is not leaked.
What to click
- Operator who just got a crate: Machines → that serial → Build & factory QA. Shipping, factory signature, Accept.
- Full admin checking a plant: Admin → Factory, or pick the operator then open the serial. Same card, no Accept.
- MFA super-admin reconciling a late shipment:
/superadmin→ Factory (the crate) and Orders (the purchase). - Plant bench: manufacturer key against
PUT /api/v1/machines/{no}/buildandPOST /api/v1/machines/{no}/build/tests. That is manufacturer.kiosk-x.ai.