Authorised reads

Table of Contents

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 ok with no rows; a refusal is denied with no rows. A response without a result cannot express the second, so it has to lie.
  • denied is 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 :read code.
  • 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

Emacs 29.3 (Org mode 9.6.15)