IAM handlers skip their permission checks

Table of Contents

This page is a capture in the inbox bucket of the product backlog — a pre-sprint idea, not yet pulled into a sprint as a story.

1. What

Several IAM message handlers never call has_permission, even though the permission code they should check exists and is seeded. The most serious is role assignment: iam.v1.roles.assign and iam.v1.roles.assign-by-name grant any role to any account with no permission check at all, while iam.v1.roles.revoke directly beside it does check iam::roles:revoke. Any authenticated account can make itself a TenantAdmin.

2. Why

Found on 2026-09-23 while documenting the IAM user journeys (User Journeys): the access, directory and tenancy groups each ran into it independently.

The sites, all verified in source:

  1. Role assignment is unguarded. projects/ores.iam/core/include/ores.iam.core/messaging/authorization_handler.hpp:66 is assign: decode, validate the caller, call svc.assign_role(...), reply. No has_permission. assign_by_name at line 218 is the same. revoke at line 94 checks perms::roles_revoke at line 113. The asymmetry is the bug: taking a role away is guarded, handing one out is not. Nothing needs to be added to the permission catalogue; iam::roles:assign is already seeded.
  2. The account and login reads are unguarded, in a different file from the writes. The six guarded writes live in account_operations_handler.hpp, which checks has_permission for create, delete, lock, unlock, reset_password and update at lines 250, 306, 336, 371, 406 and 494. The reads live in a separately named file, account_handler.hpp: list_accounts at line 75 and get_account at line 116. That file declares using ores::service::messaging::has_permission; at line 51 and never calls it. login_info_handler.hpp has the same shape — list_login_info at line 75 and get_login_info at line 116, with no call. So the guard was not forgotten in one place: the read handlers were written separately from the write handlers and never got it. iam::accounts:read and iam::login_info:read exist in permission_codes.hpp and are seeded, and no handler consults them. Any signed-in account can read every account in the tenant and every account's sign-in state.
  3. The two combine with a third defect. The account wire shape carries password_hash, password_salt and totp_secret (Account secrets reach the browser in the accounts wire shape). Because the reads are ungated, any authenticated account can list the tenant and receive every account's password hash and TOTP seed. The privilege escalation in item 1 turns that into full compromise of any account.

This is one root cause with several sites: a handler that forgets the guard its neighbours have. Fixing only assign leaves the reads open, and fixing only the reads leaves the escalation.

Suggested shape of the fix: make the guard structural rather than remembered — either a shared wrapper every handler passes through, or a test that asserts each declared read or write subject has a matching has_permission call. The repo favours the second kind of check elsewhere, and it is the one that cannot be forgotten twice.

3. References

  • projects/ores.iam/core/include/ores.iam.core/messaging/authorization_handler.hpp — assign (66), revoke (94), assign_by_name (218).
  • projects/ores.iam/core/include/ores.iam.core/messaging/account_operations_handler.hpp — the six guarded writes.
  • projects/ores.iam/core/include/ores.iam.core/messaging/account_handler.hpp — list_accounts (75) and get_account (116), the unguarded account reads.
  • projects/ores.iam/core/include/ores.iam.core/messaging/login_info_handler.hpp — list_login_info (75) and get_login_info (116), the unguarded login reads.
  • projects/ores.iam/api/include/ores.iam.api/domain/permission_codes.hpp — the codes that exist but go unchecked.

4. See also

Emacs 29.3 (Org mode 9.6.15)