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_permission refuses 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

  1. 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_only audience. 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.
  2. The codes are carried as granted, wildcards included. * and x::* stay as they are, so a token holds at most a handful of codes: the largest seeded role grants 17.
  3. 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.
  4. One rule matches a permission. The token check understands * and x::*; the database check authorization_service::check_permission understands only *, so a role granted refdata::* passes one and fails the other. Both use one matching function that understands both.
  5. 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 the select_party_only audience by name.
  6. 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 roles
  • projects/ores.service/include/ores.service/messaging/handler_helpers.hpp – the empty-list pass-through

9. Result

All tasks done.

Emacs 29.3 (Org mode 9.6.15)