Story: Give a person's token the permissions they hold
Table of Contents
This page documents a story in Sprint 26. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
1. Goal
Make a person's token carry the permissions they hold, and stop the token
check from passing an empty list. Then a handler's has_permission means what
it says for a person as well as for a service.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 26 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing; the story is closed. |
| Last touched | 2026-10-03 |
3. Acceptance
- Login, party selection, party switching and refresh issue tokens that carry the account's effective permissions, read from the database.
- A change to an account's roles reaches its next token, so a revoked permission stops working within one access lifetime.
has_permissionrefuses a token with an empty permission list.- Each role (SuperAdmin, TenantAdmin, an ordinary member) is walked through its journeys against the running deployment, and every refusal it meets is one it should meet.
- Every new behaviour is proved by a test.
4. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Decide what a person's token carries | DONE | 2026-10-03 | 2026-10-03 | Design note: the permission list in each token, its size, how a role change reaches the next token, and how party switching and the tenant session fit. |
| Issue tokens with their permissions | DONE | 2026-10-03 | 2026-10-03 | Login, party selection, party switching and refresh fill the token's permissions from the database, and a role change reaches the next token. |
| Refuse a token with no permissions | DONE | 2026-10-03 | 2026-10-03 | has_permission refuses an empty permission list instead of passing it, with tests; any caller that relied on the pass-through is found and fixed. |
| Walk each role through its journeys | DONE | 2026-10-03 | 2026-10-03 | SuperAdmin, TenantAdmin and an ordinary member are walked through their journeys against the running deployment; every refusal is one that should happen, and missing seeded permissions are fixed. |
5. Permission matrix
What each role a person can hold grants, and what the BFF answered each role
when the walk signed in as it and called the same routes, on 2026-10-03 after
tokens carry their permissions and an empty list refuses. The grants come from
projects/ores.sql/populate/iam/iam_roles_populate.sql; the walk is
evidence/walk_roles.py and its raw output evidence/walk_roles.out. Every
walk account except the two administrators works in Acme's top operational
party. Service roles are left out: they belong to service accounts, not people.
5.1. Grants
| Role | Tenant | Grants |
|---|---|---|
| SuperAdmin | system | *, and the system codes iam::system:reset, iam::system:reset-tenant, iam::tenants:create, :delete, :impersonate, :read, :suspend, :terminate, :update |
| TenantAdmin | a tenant | * |
| Viewer | a tenant | refdata::currencies:read, variability::flags:read, workspace::workspaces:read |
| Trading | a tenant | Viewer's three, and workspace::workspaces:write, :archive, :delete |
| Sales | a tenant | refdata::currencies:read, variability::flags:read |
| Support | a tenant | iam::accounts:read, iam::login_info:read, iam::roles:read, refdata::currencies:read, variability::flags:read |
| Operations | a tenant | iam::accounts:read, variability::flags:read, and read, write and delete on currencies, currency calendars, currency countries, currency groups and currency pair calendars |
5.2. What each role was answered
| Route | SuperAdmin | TenantAdmin | Viewer | Trading | Sales | Support | Operations | Why |
|---|---|---|---|---|---|---|---|---|
| Sign in | 200 | 200 | 200 | 200 | 200 | 200 | 200 | |
GET /api/session |
200 | 200 | 200 | 200 | 200 | 200 | 200 | |
GET /api/parties |
403 | 200 | 200 | 200 | 200 | 200 | 200 | A party read checks no permission; system administration reads a tenant's parties from inside it |
GET /api/accounts |
200 | 200 | 403 | 403 | 403 | 200 | 200 | Needs iam::accounts:read |
GET /api/login-info |
200 | 200 | 403 | 403 | 403 | 200 | 403 | Needs iam::login_info:read |
GET /api/sessions |
200 | 200 | 403 | 403 | 403 | 403 | 403 | Needs iam::sessions:read, which no narrow role holds |
GET /api/change-reasons |
200 | 200 | 200 | 200 | 200 | 200 | 200 | Checks no permission; answered 502 for every role until this task fixed the request |
GET /api/seed-profiles |
200 | 200 | 200 | 200 | 200 | 200 | 200 | Checks no permission |
GET /api/tenants |
200 | 403 | 403 | 403 | 403 | 403 | 403 | The roster is system administration's |
POST /api/accounts/:id/lock |
200 | 200 | 403 | 403 | 403 | 403 | 403 | Needs iam::accounts:lock; SuperAdmin's 200 changed nothing (see below) |
POST /api/accounts/:id/unlock |
200 | 200 | 403 | 403 | 403 | 403 | 403 | Needs iam::accounts:unlock |
POST /api/provision-party |
200 | 200 | 403 | 403 | 403 | 403 | 403 | Needs refdata::parties:write |
Every refusal is one the role's grant calls for, and no role holding a permission was refused it. Two findings the walk turned up are captured rather than fixed here: SuperAdmin's lock of an Acme account answers success but changes nothing, because row-level security keeps the system session out of the tenant's rows (Locking an account reports success when it changed nothing), and reading a session that still has to choose a party answers 500 (Reading a session that still has to choose a party answers 500).
6. Decisions
- Every person's token carries their permissions. Login with one party,
party selection, party switching and refresh read the account's effective
permission codes from the database and put them in the token. Refresh reads
them again rather than copying the old token's, so a role change reaches the
token at the next refresh. The token that only lets a person choose a party
carries none. It needs none: the party selection handler checks no
permission and accepts only that token's
select_party_onlyaudience. Refresh refuses that audience, so it cannot be renewed into a full token. No signer or verifier sets an audience, so a service's request context accepts such a token as an ordinary tenant-scoped one; refusing that audience there belongs with refusing an empty list. The tenant session keeps what it carries now: the administrator's read codes, read from the database when the tenant is entered. - The codes are carried as granted, wildcards included.
*andx::*stay as they are, so a token holds at most a handful of codes: the largest seeded role grants 17. - A role change takes effect within one access lifetime. With the default of 1800 s, a revoked permission stops working within 30 minutes. The maximum session of 28800 s does not lengthen this, because every refresh reads the permissions again. Pushing the change to live sessions is out of scope; the in-process event that announces a change has no listener outside the service.
- One rule matches a permission. The token check understands
*andx::*; the database checkauthorization_service::check_permissionunderstands only*, so a role grantedrefdata::*passes one and fails the other. Both use one matching function that understands both. - An empty list refuses; no token trusts, by name. The request context's
permissions become optional. A context built from a token carries the
token's list, and an empty list refuses every check. A context that carries
no list passes, as it does now. Only
context_factory::make_context, the one place a service builds its base context, produces one, and narrowing a context to a tenant or a party keeps the list it had, so a request's context never becomes trusted for want of one. The request path never reaches a trusted context in production, because every domain service builds a verifier and so validates a token, and it refuses theselect_party_onlyaudience by name. - Order. Issue tokens with their permissions first, then refuse an empty list, then walk each role. A token issued before the change carries an empty list; its next refresh reads the permissions, so nobody is locked out longer than one access lifetime.
7. Out of scope
8. Promoted from capture
Captured 2026-09-30 in the product backlog; promoted preserving the UUID.
8.1. What
Make a person's token carry the permissions they hold, and stop the token
check from passing an empty list. Then a handler's has_permission means what
it says for a person as well as for a service.
8.2. Why
The gap is wider than an account with no roles. Login never fills the token's
roles claim: auth_handler::login builds the claims without them, and only
service login, internal impersonation and refresh copy a list. So every
person's token carries an empty list, and has_permission passes it for every
code. Every permission check a generated handler makes against the token passes
for every signed-in person. The BFF's mode checks are what keep a member out of
system administration routes today.
Found while building Act inside a tenant:
its entry checks iam::tenants:impersonate against the database for this
reason, and the tenant session carries an explicit read-only list.
8.3. References
projects/ores.iam/core/include/ores.iam.core/messaging/auth_handler.hpp– login builds claims without rolesprojects/ores.service/include/ores.service/messaging/handler_helpers.hpp– the empty-list pass-through
9. Result
All tasks done.