Authorised reads
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. The contract a read obeys
- 2.2. Where the decision is made
- 2.3. Self reads and privileged reads
- 2.4. Guarded unless allowed
- 2.5. The allow-list
- 2.6. How the code obeys it
- 2.7. One answer per screen
- 2.8. The access reads
- 2.9. The account read and the organisation read
- 2.10. The divergence this page does not settle
- 3. See also
1. Summary
A read is authorised by the service that answers it, per operation and per resource, before any of the work is done. Every read checks the read permission of the resource it answers about. A read that answers every signed-in member is an exception: it is allowed only when the allow-list on this page names its subject and the reason it is open. A read that is open and not on the list is a defect. Reading your own access and reading somebody else's are two different subjects rather than one subject with a parameter, because a self read that cannot name another account is safer than a self read that must compare one. A refusal is a payload: the response carries a `result` whose outcome is `denied`, with a code and a sentence, because an empty payload cannot be told apart from an account that holds nothing. This page states the rule and applies it to the access reads; the NATS Entity Protocol carries the response contract it leans on, and RBAC is the model underneath.
2. Detail
2.1. The contract a read obeys
Every operation answers with a `result`, and the `result` is the only place a
caller looks to learn whether the operation happened. The outcome is a closed
set — ok, invalid, denied, missing, conflict, unavailable, failed —
and ok is the only outcome under which the payload means anything. The type
exists: ores::utility::domain::result, which every generated entity response
already carries.
Three consequences follow, and each is easy to get wrong:
- A read that has nothing to say and a read that refuses to say it are different
answers. An empty list is
okwith no rows; a refusal isdeniedwith no rows. A response without aresultcannot express the second, so it has to lie. deniedis not authentication. A missing, malformed or expired credential is refused before the operation is reached, in the envelope, and never reaches a payload.- The caller is told, it does not ask. No client asks whether it may do something and then decides; it asks for the thing and reads the answer.
2.2. Where the decision is made
In the service, before the work, and not in the client. A screen that hides a control has hidden a hint, not a grant, and the database's row-level security is the backstop rather than the guard.
There are two permission checks in the tree and they are not the same check.
authorization_service::has_permission |
ores::service::messaging::has_permission |
|
|---|---|---|
| Reads | the effective permissions from the database | the token's roles claim, which holds permission codes |
| Empty permission list | denies | denies |
| No permission list | — | allows: the context is the service's own, not a caller's |
Bare * |
allows | allows |
Component wildcard iam::* |
not handled | handled |
| Cost | one query | free |
The access reads use the database-backed check, and the reasons are the resource rather than the cost. The token's list is a snapshot taken at sign-in, so a role revoked during a session still passes; and the empty-list pass-through means an account that holds no roles passes every check, which is an ordinary state rather than an attack. A read that guards the rows a stale grant would expose is the last place to prefer the cheaper check.
2.3. Self reads and privileged reads
An account reading its own access is the ordinary case. An account reading somebody else's is privileged. The two are separate operations with separate subjects, and the difference is what the request can say:
- The self read takes no account id. The account comes from the authenticated actor, exactly as tenant and party do, so there is no field in which to name somebody else. Its safety does not depend on a comparison being right.
- The privileged read takes an account id, because naming the resource is its purpose, and it requires the read permission before it looks anything up.
This is the same rule the protocol already applies to tenancy — a privileged operation is its own address rather than a widened ordinary one — and the same reason: there is nothing to override because there is no field to send. A single subject that allowed the caller's own account by comparison would work, and would be one guard weaker: the day a handler forgets the comparison, the subject reads anybody's access.
2.4. Guarded unless allowed
A read checks the :read code of its resource, such as
iam::account_contact_informations:read, before it does any work. The check is
the default. A model, a handler or a subject does not ask for it; it is there
unless this page allows the read to be open.
Who holds a read code is a role's business, not this page's. The reads every person's screens need are held by the Member role every person account holds, and a job role adds what its job needs; see Role-Based Access Control.
A read may be open for one of three reasons only:
- It is a self read. The subject takes no account id, so the answer can hold only the caller's own rows. The account comes from the authenticated actor.
- A member needs it before holding any role that grants it. The member reads
the role catalogue to choose a role to ask for, as
Ask for more access describes. A
read that a member needs because of the role they hold is not open: that role
grants its
:readcode. - It is read before a session exists. The sign-in and sign-up pages read the password and registration policies, a service reads the public key that verifies tokens, and the setup screen reads whether the system is in bootstrap mode. None of them has a session to present.
So "every member needs it" is not a reason to open a read. Grant the code to every role that needs it. An open read answers people who hold no role at all, and it hides the read from the roles that model what each person may do.
An allowed read is allowed by its subject. Other operations on the same
resource stay guarded: allowing iam.v1.roles.list does not allow
iam.v1.roles_versions.list.
A service acting as itself carries no permission list, and the check passes. A service that relays a caller's token is checked as the caller.
2.5. The allow-list
These subjects answer every signed-in member. Each row says why. A subject that is not in this table checks its read permission.
| Subject | Answers | Why it is open |
|---|---|---|
iam.v1.ops.get_my_roles |
The caller's roles, each with the permissions it grants and its assignment tail | Self read |
iam.v1.roles.list |
The tenant's roles: name, description and flags | A member chooses the role to ask for |
iam.v1.roles.get |
One role | A member reads the role to ask for |
iam.v1.ops.get_role_permissions |
The permission codes one role bundles | A member reads what a role grants before asking for it |
iam.v1.permissions.list |
The permission codes and their descriptions | A member reads a role's codes in words, and asks "Can I…?" |
inbox.v1.ops.list_my_approval_requests |
The requests the caller raised, and their decisions | Self read |
inbox.v1.ops.list_my_notifications |
The notifications addressed to the caller | Self read |
inbox.v1.ops.count_unread_notifications |
How many of those are unread | Self read |
iam.v1.ops.get_my_account_contact_information |
The caller's own contact record | Self read; the profile reads it, and list_by_account_id needs iam::account_contact_informations:read |
iam.v1.ops.get_my_account |
The caller's own account: name, picture, job title and reporting line | Self read; the shell, the profile and the person page read it for the signed-in person, and iam.v1.accounts.get needs iam::accounts:read |
iam.v1.ops.get_my_login_info |
The caller's own sign-in state: locked or not, last sign-in, failed attempts | Self read; Security reads it, and iam.v1.login_info.get needs iam::login_info:read |
iam.v1.ops.get_my_sessions |
The places the caller's own account is signed in | Self read; Security reads it, and iam.v1.ops.get_active_sessions needs iam::sessions:read |
iam.v1.ops.list_my_permissions |
One page of what the caller's own roles let them do, by resource | Self read; My access reads it a page at a time, and iam.v1.ops.list_account_permissions needs iam::roles:read |
iam.v1.ops.list_roles_page and iam.v1.ops.list_role_permissions |
The roles list, and the catalogue against one role for the editor | Guarded: both need iam::roles:read; no self version, because a role is not the caller's own |
iam.v1.ops.list_role_holders |
One page of the people who hold a role, with names and pictures | Guarded: needs iam::roles:read; no self version |
iam.v1.ops.get_password_policy |
The rules a password must meet | Read before a session exists |
iam.v1.ops.registration_policy |
Whether and where a person may sign up | Read before a session exists |
iam.v1.ops.public_key |
The key that verifies tokens | Read before a session exists |
iam.v1.ops.bootstrap_status |
Whether the system is in bootstrap mode | Read before a session exists |
A row is added to this table in the same change that opens the read, with its reason. Removing a row closes the read.
2.6. How the code obeys it
Codegen guards every generated read with its resource's :read code, as it
guards a write with its :write or :delete code. A model opens a read only
by naming its subject in :open_reads:, and the subject must be one of the
resource's own reads, or the generation fails. The open subjects in the models
are the generated rows of the allow-list above: iam.v1.roles.list and
iam.v1.roles.get on the role model, and iam.v1.permissions.list on the
permission model. The self reads are hand-written operations that take no
account id.
A history is guarded too. <component>.v1.history.get serves every version of
any entity of its component, so each history provider registers with its
entity's read code, and the dispatcher refuses a caller who does not hold it.
The history subject is never on the allow-list.
Until 2026-10-06 the default was the reverse: a read needed authentication alone unless its model asked for a guard, and 363 of 367 generated read handlers answered every signed-in member. Guard every read the security documentation does not allow turned it round.
2.7. One answer per screen
A journey that only reads wants the joined rows in one request, not a call per panel. The first screen that needed this was the access group's, and the shape it settled is worth stating because it recurs:
- one response per screen, carrying everything that screen renders;
- the permissions each role grants, attributed to the role rather than flattened into one list, so a person can see where a capability comes from;
- the assignment tail beside each role — who granted it, when, and why — which the junction table already stores and no subject returns.
The alternative — three thin reads that the screen joins — costs three round trips and still cannot attribute a permission to a role, because the flat list has thrown the attribution away.
2.8. The access reads
| Read | Subject | Who | Needs | Answers |
|---|---|---|---|---|
| The caller's own access | iam.v1.ops.get_my_roles |
any signed-in account | a session | the account's roles, each with the permissions it grants and its assignment tail |
| Another account's access | iam.v1.ops.get_account_roles |
a tenant administrator | iam::roles:read |
the same shape, for the account named |
| One role's bundle | iam.v1.ops.get_role_permissions |
any signed-in account | a session | the permission codes one role bundles |
| The old flat list | iam.v1.ops.get_role_permissions-by-account |
retired | — | folded into the composed read, because a flat list is what loses the attribution |
The three that remain each carry a result, which the contract above requires of
every operation. The first two answer denied when the caller may not have what
it asked for, with the permission that was required as the code, so a caller can
tell a refusal from an empty account without reading a sentence.
One role's bundle needs no permission, and that is deliberate rather than an omission: registration gives a person one role, asking for more is the journey Ask for more access runs, and a person deciding what to ask for reads what the role grants. The tenant's roles are listable by any member for the same reason, so gating the bundle would hide the half of the answer that makes the ask informed. Both are on the allow-list.
2.9. The account read and the organisation read
A person needs to know the people they work with: who they are, what they do,
and who they report to. Every member holds iam::accounts:read for this. The
read is not a read of the tenant, because accounts are a special case: row-level
security limits an account read to the accounts that work in a party the
caller's own account works in. The policy, the function it uses and the
reasons it is hand-written are in
Multi-Tenancy Architecture, under
"The accounts exception". No other table has this policy.
So the account code answers a member with the people of their parties, and it answers a tenant administrator with the whole tenant, because the administrator's account is linked to every party. The code is the permission to read an account. The policy is which accounts.
One rule does not depend on the party. A shared account is visible to every reader of every tenant who holds the account code. The shared accounts are the service accounts and the super admin. They work in no party, and a history must name the service that wrote an entry. They hold no private data, and an account never carries a credential, a contact record or a sign-in. The rule is one case of Data read across tenants, which states the building blocks and the checklist for any table that is read across tenants.
An account is not credentials. The password hash, the contact record, the sign-ins and the sessions are other tables. Each has its own permission code, and Member holds none of them. A member reads a colleague's account (name, email, job title, picture, status, parties) and nothing that signs in as them.
The organisation read has a code of its own: iam::organisation:read. It is
held by Member. It is a guarded read, not an open one, so it is not on the
allow-list above: it answers other people, and the reasons the allow-list gives
do not apply to it. The read is iam.v1.ops.get_reporting_tree. The handler
decides its scope before any work is done:
| Caller holds | Answer |
|---|---|
iam::accounts:read |
the people the account policy admits: those who work in a party the caller's own account works in |
iam::organisation:read and not that code |
the people who work in any party the caller's own account works in, and everyone who reports to the caller, directly or indirectly |
| neither | refused |
The party rule is the caller's own parties, because a person belongs to many parties and an account can work in several: the head of a holding group, linked to each of its parties, sees everyone in them. It is not the party the session is acting for, which would hide a head of group's other parties from them.
A node carries a name, a job title, a picture id, the manager, and the parties
the person works in. It carries no email and no contact detail. A manager the
caller may not see is not named: the node says reports_outside_scope, so a
person who has a manager is never drawn as one who has none. The parties are
named from IAM's party cache, and a party's parent is stated only when that
parent is in the answer too.
The screens that read the organisation open a person's page for a caller who
holds iam::accounts:read, because the page is an account read. Member holds
it, so a member opens the page of a colleague in their parties. The page shows
the tabs whose reads the member holds and no others.
2.10. The divergence this page does not settle
The protocol says authorisation answers denied in the payload. Most generated
handlers do something else: they check the permission before decoding and reply
with an empty body and X-Error: forbidden, so the refusal travels in the
envelope and carries neither a code nor a sentence. The access reads follow the
protocol rather than the handlers, because they have to distinguish a refusal
from an empty answer and because a denied authorisation is an answer about the
resource, not a failure to reach the operation.
That leaves two mechanisms in the tree for one concept. Reconciling them — either by moving the generated handlers onto the payload outcome or by stating that an operation-level permission failure is an envelope failure — is its own piece of work, and it is recorded rather than decided here.
3. See also
- Identity and Access — the structure note that orders this cluster, and where to read this page in it.
- Identity and Access Management — the identity lifecycle and the vocabulary this page uses.
- Role-Based Access Control — the role and permission model the check consults.
- NATS Entity Protocol — the response and outcome contract, and the authorisation rule this page applies.
- Journey execution — why a journey that only reads wants one joined answer.
- Implement the access journeys in ores.web — the group whose reads this page settles.
- Guard every read the security documentation does not allow — the story that makes the code obey the rule.
- Ask for more access — the journey the role catalogue reads are open for.
- Know what I may do and Change someone's access — the two journeys these reads carry.