Skip to content

IAM Engine

Verified against the code · 9e119ef0950aa1f8c8f87a640132addf0109872c · 2026-10-20

956 D1b-3 (2026-10-20): 7 finding(s) fixed — the IAM-filtered export is GET /api/export (api_misc_router.export_graph), GET /nodes and GET /node/{id} read through IamScope (filter_readable / can_read), per-route 403 bodies (No access to domain / Access denied / typed P-READ_ACCESS_REQUIRED), ten modules incl. scope.py, write gates live in api/mutations_router.py (_iam_reject_if_locked) and routes/documents.py (_gate_item_write), /admin/iam/* uses require_admin = deps.admin_user, iam_rules_as_of(t, mutations_dir=None). Re-verified against main 9e119ef0. #1120 WP9 S5c (2026-10-23): GET /admin/iam/rules, /admin/iam/domain/{domain}/tree and /admin/iam/users/{subject}/access drop the bare id / parent_id / subtree_root where the IRI companion is set; the domain functions keep their shape. Checked against backend/api/admin_iam_router.py on the #1120 branch (base fc560689).

Layer: domain

Overview — What IAM Governs

The IAM Engine controls which nodes in a domain a user can read, write, or administer. It operates on a graph that is organized as one or more trees, and it enforces access at the subtree level: an administrator grants a user a role on a root node, and that grant propagates downward through all descendants of that node.

The engine is composed of ten focused modules:

Module Responsibility
iam/__init__.py Pure evaluation core — no I/O, no HTTP
iam/config.py IAM_ENABLED feature flag
iam/admin.py SQLite persistence and rule lifecycle (CRUD + load_access_rules)
iam/enforcement.py Bulk and single-node access checks used on read paths
iam/tree.py Builds the IAM-filtered tree response
iam/relations.py Builds the IAM-filtered relation list
iam/export.py Builds an IAM-filtered export snapshot
iam/preview.py Evaluates a rule's effective scope without persisting it
iam/overview.py Composes per-rule previews and a merged total-access view
iam/scope.py IamScope — the ONE node-scoped read/write predicate every route gate calls (hærdning 3.2)

IAM is an opt-in feature. Node-scoped enforcement is guarded by the topology-aware iam_enforced() = is_iam_enabled() and _IS_PRIMARY (the primary is the IAM authority — see the write-paths section). When it is false — IAM disabled, or a secondary backend — every caller is treated as having full access, so the engine adds zero overhead to deployments that do not need it.


Permission Model

Roles

Three roles are defined. Each role is a strict superset of the one below it.

Role Permitted actions
viewer read
editor read, write
admin read, write, admin

The mapping is a frozenset constant _ROLE_PERMISSIONS defined in iam/__init__.py and is the single source of truth for what each role may do.

Data model for assignments — AccessRule

@dataclass(frozen=True)
class AccessRule:
    subject:      str           # user_id
    role:         str           # "viewer" | "editor" | "admin"
    domain:       str           # e.g. "product"
    subtree_root: Optional[str] # None = full-instance grant
    granted_by:   str           # admin user_id who created this rule
    granted_at:   str           # ISO 8601 timestamp

subtree_root=None is a full-instance grant that matches every node in the domain and is treated as the lowest-priority match (depth -1) during evaluation. When subtree_root is set to a node ID, the grant covers that node and every descendant.

Rules are immutable frozen dataclasses in memory. Persistence is in a SQLite table access_rules (columns: id, subject, role, domain, subtree_root, granted_by, granted_at).

AuthContext

@dataclass(frozen=True)
class AuthContext:
    user_id: str
    email:   str = ""   # display/logging only; never evaluated

AuthContext is the sole auth input for all enforcement calls. It is produced by backend/auth.py::resolve_identity() and passed through the FastAPI dependency chain.

Service-token trust model

api/auth.py::get_auth_context is the one place an AuthContext is built from a request (#1057). Nothing gets around it to IAM: the shared X-Service-Token proves that the caller is a peer backend. It never proves who the user is.

Sender → backend Headers Result
Browser / API client → any backend Authorization: Bearer <session or API key> That user. Every IAM decision applies.
Anyone → primary X-Service-Token only 401 on every route whose identity comes from get_auth_context (current_user, admin_user, iam_scope, admin_or_service, data_read). No peer calls those routes on the primary with the bare token. The service_caller routes (/internal/reconstruct, documents blocklist) do not go through get_auth_context and still accept it. The portal routes do not accept it at all (see Portal SSO trust model).
Peer → secondary X-Service-Token only __service__ (SERVICE_PRINCIPAL). It is never admin: server._get_user_role returns editor for it even if users.json has an entry with that name.
Anyone → primary X-Service-Token + X-Eidos-Username 401 on every route. Nothing upstream of the primary forwards identities, so on the primary the pair can only be a forgery. A Bearer token sent alongside does not help.
Primary → secondary X-Service-Token + X-Eidos-Username: <user id> That user, only if the id exists and is active (_user_status == "active") in the shared users.json, reloaded once on a miss; otherwise 401. A missing or wrong service token is 401.

The primary sets X-Eidos-Username only through DomainClient.service_headers(on_behalf=…) and the proxy routes, and always with the user_id of a caller that it has just authenticated. resolve_forwarded_identity carries out the check on the secondary. server._proxied_username (the documents-instance upsert) also uses it, so a forwarded identity is checked the same way wherever it is read. The topology is read at request time from request.app.state.server_mod._IS_PRIMARY. When it is unknown, a forwarded identity is refused.

Every refusal of an identity behind a valid service token, forwarded or bare, writes a structured security log entry service_token_identity_refused (logger eidos.auth.security) with the route, the claimed username (none for a bare token), whether this backend is the primary, and the reason: primary, unknown topology, unknown user or inactive user. A valid token with a refused identity means the token has leaked. The token itself is never logged.

The routes that peers call on purpose with a bare service token are listed in api/deps.py::SERVICE_TOKEN_PEER_ENDPOINTS. The list is not an exhaustive reachability list, because the guarantee is primary-scoped: on a secondary, a bare token still resolves to __service__ on every get_auth_context route, for example /nodes in the fan-out, not only on the listed routes. The listed routes are the ones gated by service_caller, admin_or_service or data_read, or by _proxied_username in the handler: the full-model export, the audit and provenance fan-out, snapshots, tombstones and the other data reads, domain and project export/import, /internal/reconstruct and the documents-instance endpoints. The portal endpoints are not on this list. On the primary, the bare token is refused before admin_or_service and data_read run, so those routes need a real admin or a real user there; only the service_caller routes accept it. tests/test_service_token_trust.py fails when a route gains or loses one of these gates without a matching change to the list.

Portal SSO trust model

The KnowY portal signs a user in to an instance in three steps (#1072). Each step needs a credential that only the previous step can produce, so the portal flow is never a path around a password check.

Step Caller → backend Credential Result
POST /auth/validate-credentials portal → instance X-Portal-Token + email + password On a correct password, the user's info plus a login_ticket. A wrong password, unknown, inactive or expired user gives 401 and no ticket.
POST /auth/launch-token portal → instance X-Portal-Token + user_id + ticket A 60-second one-shot launch token. The ticket must exist, be unexpired, be unused and be bound to that user_id; otherwise 401. The ticket is consumed.
POST /auth/launch browser → instance the launch token A session for the user the launch token was minted for. An admin invite token gives 400 and no session. A request that carries X-Service-Token gets 403.

login_ticket. api/auth.py::issue_login_ticket creates 32 random bytes (hex) and stores them in the login_tickets table of sessions.db with the user's id and an expiry of LOGIN_TICKET_TTL (600 s, about 10 minutes). redeem_login_ticket(ticket, user_id) consumes the ticket in one atomic DELETE … WHERE ticket = ? AND user_id = ? AND expires_at >= ?, so two concurrent redemptions cannot both succeed. A ticket presented with another user's id is not consumed and not accepted. Tickets, launch tokens and sessions are never written to a log.

Portal token. X-Portal-Token carries PORTAL_TOKEN, a secret separate from SERVICE_TOKEN. api/deps.py::portal_caller (via api/auth.py::verify_portal_token) accepts only that header; the peer X-Service-Token and Bearer tokens are ignored there. The routes it gates are listed in api/deps.py::PORTAL_TOKEN_ENDPOINTS: /auth/validate-credentials and /auth/launch-token. The portal token opens no other route, because no other dependency reads it. POST /auth/launch is the third step of the flow, but the browser calls it after the portal's redirect and so cannot hold the portal secret; the launch token is its credential, and it refuses the peer service token outright. Holding only the peer service token therefore gets no ticket, no launch token and no session for any user, an admin included. tests/test_portal_auth_hardening.py covers acceptance A–G of #1072, including that bite test and an end-to-end run of the real portal against the real backend.

Portal side. portal/server.py keeps each instance's login_ticket in memory, keyed by a random single-use launch_ref that expires with the ticket. The browser gets only the launch_ref, never the ticket or the user_id, and sends it in the body of POST /launch/{instance_id}; the portal then redeems the kept ticket for the kept user. An expired or used launch_ref or ticket is 401, and the login page asks for the password again. The portal holds PORTAL_TOKEN, not SERVICE_TOKEN.

Launch tokens and invite tokens. Both kinds are one-shot rows in the invite_tokens table, typed by a purpose column (#1079): launch for the portal's 60-second launch token, invite for the admin's 24-hour invite. api/auth.py::validate_invite_token(token, purpose=…) matches only a row of the requested purpose. /auth/launch asks for launch, while /auth/invite-check and /auth/set-password ask for invite. A token of the other kind fails like an unknown one (400) and stays in place. An invite link therefore cannot become a session without the user setting a password, and a launch token cannot set a password. init_db adds the column to an older sessions.db, and rows from before the change read as invite.

Login throttle. Failed logins are throttled with one policy: RATE_MAX (5) failures within RATE_WINDOW (300 s), after which the reply is 429 Too many login attempts. Try again later. The instance's own /auth/login counts per client IP. That IP is the real client address: host nginx overwrites X-Forwarded-For with $remote_addr, and backend/run.py runs uvicorn with proxy_headers and forwarded_allow_ips from TRUSTED_PROXY (infra/config.py::trusted_proxy, resolved by infra/proxy_trust.py). The generated compose sets TRUSTED_PROXY: "gateway", which resolves at start-up to the container's default gateway, the Docker bridge address through which host nginx reaches it. The target update (deploy.py <target>) does not re-render an existing compose; it adds TRUSTED_PROXY: "gateway" to each backend service that lacks it, lists the added keys in its output, and removes the dead EIDOS_FILE line (deploy.py::_patch_compose_sync_service_env, #1097). Without that key the throttle bucket is the bridge gateway, so five failures from anyone lock out every user. A forwarding header from any other peer is ignored. The login log and the audit/admin IP field read the same request.client.host; no handler reads X-Forwarded-For itself (#1088). The portal's POST /auth/login counts per client IP and per normalised email (portal/server.py::_check_login_rate). The check runs before any instance is called, so a throttled attempt reaches no instance and gets no ticket. The instance's /auth/validate-credentials counts per email (api/auth.py::check_email_rate) as a backstop, because its caller IP is always the portal's. The check runs before the password check, so a correct password during the throttle still gets no login_ticket. Only failures count, and each key has its own bucket, so other users' failures do not slow a successful login. Each bucket is an in-memory, LRU-bounded map (4096 keys). A throttle event is logged as login_throttled with the bucket and a 12-character SHA-256 reference of the email (email_ref), never the email itself, the password or any token: on the instance through logger eidos.auth.security, on the portal through knowy.portal.security, which also logs the client IP. tests/test_login_throttle_token_purpose.py covers acceptance A–H of #1079. tests/test_login_throttle_trusted_proxy.py covers acceptance A–D of #1088.

Provisioning. deploy.py writes PORTAL_TOKEN into the instance's 0600 .env next to SERVICE_TOKEN (_render_env_file). A re-deploy or add_domain keeps an existing value (_portal_token_from_env); an .env written before #1072 gets a newly generated one. The operator sets the same value as PORTAL_TOKEN in the portal's environment.

Super Admin

The sentinel subject __super__ represents the instance's built-in super-admin account. It bypasses all IAM enforcement silently. create_rule, list_rules, get_rule, update_rule, and delete_rule all reject or omit __super__ so the account is never visible in the admin UI and cannot be mutated through the rule-management surface. The visibility check lives in a single private helper (_is_super_subject) called from both get_rule and delete_rule, so the two cannot drift.

User capability flags — can_view_iis, can_view_heatmap

Two per-user boolean flags on the users.json record gate access to T2+ product surfaces that are not node-scoped IAM (they are feature visibility, not subtree access):

  • can_view_iis — the IIS Interfaces panel and the /iis/* routes (iis/routes.py::_check_flag → 403 when false). The frontend also hides the Insights → Interfaces button when the flag is false (app.js, driven by the /auth/me and login responses).
  • can_view_heatmap — the activity-heatmap surface.

Rules of the flags:

  • Seeded on for admins. Both user-seed builders — tools/bootstrap/bootstrap.py::generate_users_json and deploy.py::_build_users_json — set both flags true for role == "admin" (admin is a T2+ core-product user) and false for plain users. A missing flag reads as false in every backend reader, so an admin seeded without them silently loses Interfaces (the #584 / #545 regression).
  • A seed never lowers an existing user's rights. When a users.json already exists, the seed is merged, not overwritten: bootstrap.merge_seed_users(existing, seed) (the single implementation, reused by deploy's _put_users_json_merged) keeps every existing field — flags, role, password_hash — and only adds missing users or fills absent fields. Users are matched by email (case-insensitive) before uid, so a UI-created admin is not duplicated by the deterministic seed uid. --force-reseed-users therefore merges too; it is no longer a destructive overwrite.
  • Reads reflect disk truth. /auth/me and the IIS gate reload users.json before reading a flag. server._load_users() rebinds the module _users global to a freshly-built dict (a single atomic assignment — never an in-place clear()+update(), which would race the reloads now issued on every request from threadpool threads). Consumers that must stay fresh read through a getter (get_users=lambda: server._users), not a reference captured at startup, so they always see the current dict. An Admin→Users flag change is therefore honoured immediately across all workers, with no container restart and no shared-mutation race.

Core Operations

is_ancestor_or_equal

def is_ancestor_or_equal(
    candidate_root: str,
    target: str,
    parent_of: dict[str, str],
) -> bool:
Param Type Description
candidate_root str Node being tested as an ancestor
target str Node whose ancestry is walked
parent_of dict[str, str] Maps child_id → parent_id; roots are absent

Returns True if candidate_root equals target or is an ancestor of target. Walks up the parent_of chain until the root is found or the chain ends without a match. This is the tree traversal primitive that all subtree-scope checks delegate to.


get_effective_role

def get_effective_role(
    user_id:   str,
    domain:    str,
    node_id:   str,
    rules:     list[AccessRule],
    parent_of: dict[str, str],
) -> Optional[str]:
Param Type Description
user_id str Subject being evaluated
domain str Domain to filter rules by
node_id str Node to evaluate access on
rules list[AccessRule] All loaded rules for the instance
parent_of dict[str, str] Tree topology

Returns the role string ("viewer", "editor", "admin") or None if no rule matches.

Algorithm: 1. Filter rules to those whose subject == user_id and domain == domain. 2. For each matching rule, check whether subtree_root is an ancestor-or-equal of node_id (using is_ancestor_or_equal). Full-instance rules (subtree_root=None) always match at depth -1. 3. Among all matching rules collect (depth, role) pairs where depth is the number of hops from subtree_root to the root of the tree. 4. Return the role from the pair with the greatest depth (most-specific wins).

Most-specificity ensures that a narrower subtree-editor grant overrides a broader subtree-viewer grant on the same node.


evaluate_access

def evaluate_access(
    user_id:   str,
    domain:    str,
    node_id:   str,
    action:    str,
    rules:     list[AccessRule],
    parent_of: dict[str, str],
) -> bool:
Param Type Description
action str "read", "write", or "admin"

Returns True if the user's effective role permits action. Default-deny: returns False when no rule matches (contract IAM-CCR-3).


access_granted_for_context

def access_granted_for_context(
    auth:      AuthContext,
    domain:    str,
    node_id:   str,
    action:    str,
    rules:     list[AccessRule],
    parent_of: dict[str, str],
) -> bool:

The public entry point that accepts AuthContext instead of a raw user_id. All enforcement in iam/enforcement.py routes through this function. It simply unpacks auth.user_id and calls evaluate_access.


filter_nodes_by_access

def filter_nodes_by_access(
    all_nodes:   dict[str, dict],
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
) -> dict[str, dict]:
Param Type Description
all_nodes dict[str, dict] Full node map keyed by node_id

Returns the subset of all_nodes for which access_granted_for_context(..., action="read") returns True. Used by the /nodes listing endpoint when IAM is enabled.


is_node_accessible

def is_node_accessible(
    node_id:     str,
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
) -> bool:

Single-node read check. Delegates to access_granted_for_context with action="read".


has_write_access

def has_write_access(
    node_id:     str,
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
) -> bool:

Single-node write check. Delegates to access_granted_for_context with action="write". Reached through the ONE predicate IamScope.can_write / require_write (hærdning 3.2) — the mutation, import, and document-attach gates all call it there.


build_visible_tree

def build_visible_tree(
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    all_nodes:   dict[str, dict],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
    roots:       list[str],
) -> list[dict]:
Param Type Description
roots list[str] Root node IDs for this domain

Returns a list of root-level node dicts each containing embedded children. Two node shapes are produced. Both carry iri (node_iri(id)) next to id (#1101 WP9 S2, additive):

Allowed node — full data, restricted=False, effective_role set:

{
  "id": "...",
  "iri": "https://id.ontoteq.com/...",
  "label": "...",
  "restricted": false,
  "effective_role": "editor",
  "class": "...",
  "productType": null,
  "properties": {},
  "children": [...]
}

Restricted path node — navigation skeleton, restricted=True, no class, only description property preserved:

{
  "id": "...",
  "iri": "https://id.ontoteq.com/...",
  "label": "...",
  "restricted": true,
  "effective_role": null,
  "class": null,
  "properties": {"description": "..."},
  "children": [...]
}

Path ancestors (nodes that are not in the user's allowed set but have at least one accessible descendant) appear as restricted nodes so the frontend can render a navigable path to the accessible subtree. Branches with no accessible content are omitted entirely.


build_visible_relations

def build_visible_relations(
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    relations:   list[dict],
    all_nodes:   dict[str, dict],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
) -> list[dict]:

Returns relations whose from node is in the user's allowed set. Every returned relation row also carries fromIri, toIri and, where the row has an external uuid, external_iri, built by infra.target_ref.relation_node_iris (#1101 WP9 S2, additive). toIri is set only for an in-model target; an external or unresolved row's to is a placeholder and gets null. For each such relation the target field is enriched:

  • Target in allowed set: full data, restricted=False, iri set.
  • Target exists but is out of scope: {"id": "...", "iri": "...", "label": "Restricted", "restricted": true} — iri is the row's own toIri, never derived from the placeholder.
  • Cross-domain target (to=None): {"id": null, "iri": null, "label": "Restricted", "restricted": true} — always restricted regardless of role, iri always null; external_domain and external_ref are preserved at the relation level.

build_export

def build_export(
    user_id:     str,
    domain:      str,
    rules:       list[AccessRule],
    all_nodes:   dict[str, dict],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
    relations:   list[dict],
) -> dict:

Returns:

{
  "nodes": [{"id", "iri", "label", "class", "properties"}, ...],
  "relations": [...],
  "meta": {
    "domain": "...",
    "user_id": "...",
    "exported_at": "2026-...",
    "node_count": 42,
    "relation_count": 7
  }
}

Export differs from the tree response: path ancestors are not included. Only nodes in the allowed set appear in nodes; each node row carries iri next to id (#1101 WP9 S2). Relations follow the same visibility rules as build_visible_relations, including its fromIri/toIri/external_iri companions.


preview_access_scope

def preview_access_scope(
    subject:      str,
    domain:       str,
    rules:        list[AccessRule],
    all_nodes:    dict[str, dict],
    parent_of:    dict[str, str],
    children_of:  dict[str, list[str]],
    relations:    list[dict],
    subtree_root: Optional[str] = None,
    include_ids:  bool = False,
) -> dict:
Param Type Description
subtree_root Optional[str] The root being previewed; used to look up the subtree label
include_ids bool When True, includes the full node_ids list in the scope dict

Calls build_export once and derives counts, a 50-node preview sorted by tree depth, and the most-specific role. The result carries subtree_root_iri next to subtree_root (null for a full-instance grant), and each nodes_preview row carries iri next to id (#1101 WP9 S2, additive). Zero new policy logic — all filtering is delegated. Used by both the hypothetical-rule preview endpoint and the existing-rule preview endpoint.


build_user_access_overview

def build_user_access_overview(
    subject:     str,
    domain:      str,
    rule_dicts:  list[dict],
    all_nodes:   dict[str, dict],
    parent_of:   dict[str, str],
    children_of: dict[str, list[str]],
    relations:   list[dict],
    include_ids: bool = False,
) -> dict:

Composes per-rule isolation previews (each rule evaluated in isolation via preview_access_scope) with a merged total-access view (via build_export). Returns:

{
  "subject": "...",
  "rules": [
    {
      "id": "...", "role": "...", "domain": "...",
      "subtree_root_iri": "...", "subtree_root_label": "...",
      "granted_by": "...", "granted_at": "...",
      "preview": {"node_count": 12, "relation_count": 3, "nodes_preview": [...]}
    }
  ],
  "total_access": {
    "node_count": 20, "relation_count": 5,
    "domains": ["product"], "nodes_preview": [...]
  },
  "overlap_hint": {
    "has_overlap": true,
    "sum_of_rule_node_counts": 25,
    "note": "..."
  }
}

When include_ids=True the response additionally includes node_ids on each rule preview, on total_access, and expands overlap_hint with overlapping_node_count and overlapping_nodes. Each total_access.nodes_preview row carries iri next to id and parent_iri next to parent_id, and each overlapping_nodes row carries iri (#1101 WP9 S2, additive; node_ids stays a plain id list).

GET /admin/iam/users/{subject}/access sends this result through infra.iri.without_bare_ids (#1120 WP9 S5c), as do GET /admin/iam/rules and GET /admin/iam/domain/{domain}/tree: a row's id, parent_id and subtree_root are dropped wherever their IRI companion is set (a full-instance grant keeps subtree_root: null). The example above shows that shape. These routes read a secondary's /nodes and /tree answers through the IRI companions (infra.iri.node_rows_by_id, companion_id(t, "rootIri"), #1119 WP9 S5b), never the bare id / rootId, which S5c dropped from those responses.


Persistence — admin.py

init_db

def init_db(db_path: str) -> None:

Creates the access_rules table with WAL journal mode if it does not already exist. Called once at server startup.

create_rule

def create_rule(
    db_path:        str,
    *,
    subject:        str,
    role:           str,
    domain:         str,
    subtree_root:   Optional[str],
    granted_by:     str,
    known_domains:  Optional[set[str]] = None,
    valid_node_ids: Optional[set[str]] = None,
) -> dict:
Param Type Description
known_domains Optional[set[str]] If provided, domain must be a member
valid_node_ids Optional[set[str]] If provided and subtree_root is set, subtree_root must be a member

Validates subject (not __super__), role, domain, and subtree_root, then inserts the row with a UUID v4 id and a UTC granted_at timestamp. Returns the full rule dict.

list_rules

def list_rules(
    db_path: str,
    subject: Optional[str] = None,
) -> list[dict]:

Returns all rules, optionally filtered by subject. Super Admin rules are always excluded.

get_rule

def get_rule(db_path: str, rule_id: str) -> Optional[dict]:

Returns a single rule by rule_id, or None if not found or if subject == __super__.

update_rule

def update_rule(
    db_path:        str,
    rule_id:        str,
    *,
    role:           Optional[str] = None,
    subtree_root:   object = _SENTINEL,
    known_domains:  Optional[set[str]] = None,
    valid_node_ids: Optional[set[str]] = None,
) -> Optional[dict]:

Updates role and/or subtree_root on an existing rule. The _SENTINEL default distinguishes "caller did not pass subtree_root" from "caller explicitly passed subtree_root=None" (the latter is a valid full-instance grant). Returns the updated dict, or None if the rule does not exist or its subject is __super__ (reached via get_rule, which hides the Super Admin sentinel — same invisibility shape as the read path).

delete_rule

def delete_rule(db_path: str, rule_id: str) -> bool:

Deletes a rule by ID. Returns True if the row was deleted, False if it was not found or its subject is __super__ — same invisibility shape as get_rule, so the Super Admin sentinel cannot be reached through any rule-management operation.

load_access_rules

def load_access_rules(
    db_path: str,
    domain:  Optional[str] = None,
) -> list[AccessRule]:

The bridge between SQLite and IAM core. Reads rows and constructs AccessRule dataclass instances. The optional domain filter reduces I/O for single-domain evaluations. Every enforcement call in server.py calls load_access_rules at request time.


Rule Evaluation

How rules are checked at request time

At the start of any request that needs IAM enforcement, server.py calls _get_iam_rules():

def _get_iam_rules() -> list:
    return load_access_rules(_IAM_RULES_DB)

This loads all rules fresh from SQLite. The rules are then passed — together with the graph topology (parent_of, children_of) from the effective graph — into whichever IAM function the endpoint needs.

The evaluation is always stateless and pure. No rules are cached between requests (cache invalidation happens for the tree shape cache, not for IAM rule data).

Which endpoints enforce IAM

Read paths — filtered silently

  • GET /tree — calls build_visible_tree; the response is pre-filtered before it leaves the server.
  • GET /nodes — filters through IamScope (_read_scope_for(...).filter_readable); only accessible nodes are returned.
  • GET /node/{node_id} — checks IamScope.can_read (_read_scope_for(...).can_read); returns 403 if the node is not in scope. Also gates cross-domain relation pills through _has_domain_read_access.
  • GET /node-by-ref?ref= — the ref is an alternate address for the same node, so it gets GET /node/{node_id}'s read gate (#954 S4a): after resolving the ref (unknown ref → 404 first) it checks IamScope.can_read on the resolved uuid and returns 403 (typed PermissionDenied P-READ_ACCESS_REQUIRED → canonical 403) when the node is not in scope. The proxy variant GET /api/proxy/{server}/node-by-ref carries the same domain-level gate as /api/proxy/{server}/node/{node_id} (deps.proxy_read: an explicit rule for server). (Before S4a the route was auth-only — a caller 403'd on /node/{id} could read the node by ref, an IDOR.)
  • GET /children/{node_id} — gates BOTH the parent and the children (#599): if IamScope.can_read(node_id) is false it returns 403 (typed PermissionDenied → canonical 403) BEFORE the children are resolved, so the parent's id/label/uri never leaks; the children list is then filtered per-node through the same predicate. (Before #599 only the children were filtered — the parent block leaked, an IDOR.)
  • GET /mutations/node/{node_uuid}/impact — the delete-preview (#610). Gates the anchor with IamScope.require_read (on the effective graph, 404-first) BEFORE the plan is computed; then, because a delete preview is all-or-nothing like the cascade delete it previews, it rejects the whole preview with 403 if any descendant in the computed subtree is unreadable — rather than filter it, which would still leak the true size via descendantCount. Peripheral relation labels (targetLabel/originLabel of nodes outside the subtree) are read-filtered per-node. plan_delete runs with the caller's context, so blockedBy reflects what the caller cannot delete (not a synthetic "preview" user). (Before #610 the route was auth-only — it leaked the whole subtree's id+label, an IDOR.)
  • GET /relations — on the primary with IAM enforced (#1013), filters through IamScope.can_read (the same predicate as /node/{node_id} and /children/{node_id}): a relation is returned only when the caller can read its source node AND, if the target is a local node, its target node. Cross-domain relations (no local to) are kept. There is no restricted-target shape here, so an unreadable target drops the whole relation. Each returned row carries the fromIri/toIri/external_iri companions (#1101 WP9 S2, additive). Admins (full-domain rule) get every relation. On a secondary the route is service-token only and unfiltered, so the proxy fan-out and the domain export get the full set. (Before #1013 the route returned every relation to any authenticated caller.)
  • GET /api/excel/relations — follows the same rule: sources come from the IAM-filtered node map, and a row whose local target is not readable is dropped.
  • GET /nodes/meta?ids= — the batch label lookup filters the id list through the _filter_ids_caller_may_see IDOR guard (IamScope.can_read) before any lookup (#1059). An id the caller cannot read is left out exactly like an unknown id, so the response never tells "exists but hidden" from "unknown". (Before #1059 it returned label, description and ref for any id an authenticated caller named.) The ids are parsed first by infra.iri.parse_public_node_id (#989 S1), so a node IRI is gated exactly like its uuid.
  • GET /nodes-referencing-external — returns only the referencing nodes the caller can read (IamScope.can_read per emitted node, #1059). An inherited entry whose source ancestor is not readable keeps isInherited but carries an empty inheritedFromLabel, so the ancestor's label never leaks.
  • POST /notifications/node — before any email or stored record, the node must be readable (#1059). A node in the local graph is checked with IamScope.can_read. A node owned by a secondary domain is resolved through the id→domain index, which applies the cross-domain read rule (has_domain_read_access). An unreadable node gets the same 404 node_not_found as an unknown node. The notified label is always taken from the graph (local node, or the owning domain's /nodes/meta), never from the client's node_label. With IAM not enforced, an unknown node is still accepted as before, with an empty label. node_id may be the node IRI or the bare uuid (#989 S1); both are gated, linked and stored as the bare uuid.
  • GET /api/export (api_misc_router.export_graph) — calls build_export under iam_enforced(); IAM disabled or a secondary backend gets the unfiltered export.

Write paths — hard gate before mutation (the ONE predicate, hærdning 3.2)

Node-scoped write/read enforcement runs through a single predicate, IamScope (backend/iam/scope.py). It binds a caller + the rules/graph once and answers:

  • can_read(node_id) / can_write(node_id) → bool (row filtering, no raise);
  • require_read(node_id) / require_write(node_id, must_exist_in=loader) → raise the canonical typed domain errors (trin 3.1): PermissionDenied → 403 permission_denied, and must_exist_in makes a missing node a 404 NodeNotFoundError first (the document-attach contract).

scope_from_server(server_mod, user_id) is the one builder; api.deps.iam_scope (alias IamGuard) is its FastAPI-wired entry. The predicate/graph load is lazy — a disabled scope, or a check on an empty node id, never touches the store.

Enablement is one topology-aware condition — iam_enforced() (backend/server.py): is_iam_enabled() and _IS_PRIMARY. IAM is enforced only on the primary, where the access-rules DB lives. A secondary container is configured IAM_ENABLED=true but its rules DB holds only the bootstrap admin (rules are written to the primary and never fanned out), so enforcing there would deny every non-admin by default — the primary is the IAM authority. scope_from_server and every is_iam_enabled() and _IS_PRIMARY gate in server.py read this one predicate. Edge (declared): a secondary running SECONDARY_MUTATIONS performs no local node-scoped enforcement (it was already deny-by-default-for-non-admins because the rules do not exist there).

A secondary's rules DB holds only the bootstrap admin (#1064). The rule that tools/bootstrap/bootstrap.py writes (role="admin", granted_by="bootstrap") is the only rule a secondary's own access_rules.db may hold. /admin/iam/rules is mounted on every backend, so POST and PUT refuse on a secondary with the canonical 403 (code: P-IAM_PRIMARY_ONLY). DELETE stays open there because removing a dead grant never widens access. Grants on a secondary domain live in the primary's DB (domain="location" and so on), so the user-delete cascade over the primary's DB reaches every rule an admin can still write. Before this guard, an admin session or a service-token call aimed straight at a secondary wrote into that instance's DB. Such a rule is unenforced, has no IAM history on the primary, and no cascade reaches it (the #946 M6 finding on DEV's location backend). The migration refcheck (tools/migration/refcheck.py, M3-C) still reports such rows. Tests: tests/test_iam_secondary_rules_db.py.

The duplicated predicate logic (the get_iam_rules + loader._load + has_write_access/is_node_accessible + raise dance) lives in exactly one place. _require_iam_write / _has_iam_read and the mutations/io inline blocks were deleted; _gate_item_write / _gate_item_read (documents) and _iam_reject_if_locked (mutations router) remain only as thin delegators that build the request's scope via scope_from_server and call require_write / require_read — no rules/graph/predicate of their own.

Gated endpoints: POST /mutations/node/validate · /preview · /node-update/* · /relation/preview · /relation/commit (the relation checks target the source node — a relation is owned by its source; the commit gate runs on the server-stored preview command so sourceId cannot be swapped), all /mutations/import/* (excel + relations + properties, per-row via the effective graph), DELETE /mutations/node/{node_uuid} (fase 3 — closes the former role-only gap: the anchor is write-gated against the EFFECTIVE graph, since an overlay-only node is absent from loader._load. Under enforcement a missing node is 404-first (NodeNotFoundError, like the document gates — no deny-by-default 403 that leaks existence); account-admins bypass at the route), DELETE /mutations/relation/{rel_uuid} and PATCH /mutations/relation/heal-uuid (source node, resolved from the overlay file else the effective graph — gated only under iam_enforced() so it never precedes the engine's canonical 404/already-deleted on non-IAM deployments), the node/relation validate/preview/commit routes (the commit re-gates the consumed preview command — defence-in-depth), and document attach (single + mass-assign; mass-assign declares ONE scope at the route boundary and skips a denied/missing target per-row, never a whole-request abort).

A guard freezes this: test_iam_node_write_guard enumerates the app's routes at runtime and fails if ANY mutating route (POST/PUT/PATCH/DELETE) under /mutations/**, /documents**, or /api/write/** ships without a node-scoped write gate — proven by an AST Call to a write helper (_iam_reject_if_locked / _gate_item_write) or a require_write / can_write method on a scope receiver. A read gate (scope.can_read / require_read / _gate_item_read) does NOT count for a write route, and neither does the mere presence of a Depends(deps.iam_scope) dependency (a handler can keep the param and drop the call — only the write call proves it) — unless the route is in a two-sided allowlist with a reason (the write-engine proxies, which enforce at the target; the internal service-token documents-instance commit primitive).

Read paths route their visibility filters through the SAME predicate (hærdning 3.2.1): server.py's GET /node/{id} (403), GET /node-by-ref (403, #954 S4a), get_children, list_nodes, the two search routes, GET /nodes-referencing-external (#1059), and the _filter_ids_caller_may_see IDOR guard (/metrics/batch, /nodes/meta) call IamScope.can_read / IamScope.filter_readable instead of a direct is_node_accessible / filter_nodes_by_access hand-call. The two bulk read filters in the io and excel routers (products/export, _apply_iam) route through IamScope.filter_readable too. A handcall ratchet freezes the result: every direct predicate call now lives inside iam/, so the baseline is empty and any new direct is_node_accessible / filter_nodes_by_access / has_write_access call anywhere else in the backend fails the guard. Note the "~25 → 0" history is about the per-node predicate in routes: write routes reached 0 at 3.2, the server.py read routes (and the two bulk filters) at 3.2.1. The four IIS interface read-filters (iis/routes.py) are a declared exception: they load rules under is_iam_enabled(), but the IIS router is mounted primary-only (server.py, if _IS_PRIMARY:), so there is_iam_enabled() == iam_enforced(), and they are reads — folding them would need scope plumbing into the IIS subsystem, out of 3.2 scope.

Account-admin bypass — two intentional axes (hærdning 3.2.1 decision). IamScope answers exactly one question — does this node's subtree grant this subject access, per the AccessRules? It is deliberately node-scoped only and never reads the account role. The account-admin authority (a role == "admin" user in users.json) is a separate, coarse, global axis: an admin has full access regardless of any subtree rule. These two axes are resolved in two places by design:

  • Reads need no route-level admin check: an admin holds the bootstrap full-instance AccessRule (subtree_root = None, BL-IAM-006), so the same IamScope predicate already grants them everywhere.
  • DELETE /mutations/node/{uuid} carries an explicit route-level _get_user_role(user) == "admin" fast-path before the node-scoped require_write — a belt-and-suspenders on the one destructive path, so an admin is never blocked by a transient rule-state gap.

Folding the account role into IamScope (so every gate shares one role-aware bypass) was considered and deferred: it would make every scope build read the users store and would change admin read semantics (an admin with no rule would suddenly read everything), a security-semantic change that warrants its own focused step, not this read-side S-series. Until then, the single source of the admin decision is the account role (_get_user_role), and IamScope stays purely rule-driven.

Cross-domain proxy gate

Proxy endpoints that forward requests to secondary backends use two FastAPI dependencies:

  • _gate_proxy_domain — requires any IAM rule for the proxied domain (read floor).
  • _gate_proxy_write — requires an editor or admin rule for the proxied domain.

Both are no-ops when IAM is disabled or when the current instance is not a primary.

Admin rule management endpoints

/admin/iam/* endpoints are guarded by require_admin = deps.admin_user (the AdminUser role; the user's account-level role must be admin; api/admin_iam_router.py:169, formerly server_mod._require_admin). These endpoints do not go through the standard IAM data gate — admin access to rule management is independent of data-access rules. A rule as answered by POST/PUT /admin/iam/rules, GET /admin/iam/rules and GET /admin/iam/rules/{rule_id} carries subtree_root_iri next to subtree_root (null for a full-instance grant; #1101 WP9 S2, response-only — the stored row and the rules snapshot keep their shape). The /admin/iam/* annotated tree rows carry iri next to id.

IAM rule provenance (#605)

The three /admin/iam/rules* mutation routes — Create/Update/DeleteIAMRule — carry full provenance under the same model as #592 for rulebook events. On each such mutation:

  • Content-addressed snapshot. The full post-state rule set (all rules, __super__ excluded) is serialized as canonical JSON and written to <mutations_dir>/iam_history/<sha256>.json (idempotent — same content ⇒ same file). The snapshot is durable inside the IAM write lock, before the audit event is appended, so a committed event's snapshotRef cannot dangle AND a concurrent request's rule change cannot bleed into the snapshot (#605 r1, review point 4).
  • Best-effort audit. If the snapshot write itself fails (disk full, …), the mutation is still durable in SQLite and the audit event is still emitted with snapshot_ref="". The failure is logged (iam_snapshot_write_failed) and the HTTP request succeeds. "A failing snapshot write must not fail the request or lose the event" (#605 r1, review point 2).
  • The pointer rides the existing field. record(..., snapshot_ref="sha256:…") puts it in eidos:snapshot.snapshotRef; OperationRecord.snapshot_ref exposes it. No envelope change.
  • Before/after diff in the payload. Each event carries payload.before and payload.after for the specific rule changed (before=null for Create, after=null for Delete). The full rule set is never inlined — always a reference. (Inline-vs-reference decision: inlining all rules per event would balloon the audit log; identical states deduplicate via the same hash; the payload stays scannable. Same rationale as #592.)
  • Point-in-time reconstruction — scope. persistence.iam_history.iam_rules_as_of(T) returns the rule set as of T for every mutation that flows through /admin/iam/rules* AND for the user-delete cascade (#912). A date-only T is end-of-day. Raises IAMHistoryFloorError (HTTP 404, error_code="iam_before_history_floor") if T precedes the first snapshot-bearing IAM event. Cascade coverage (#912): when a user is deleted via DELETE /admin/users/{user_id}, admin_users_router.admin_delete_user loops through each orphaned rule and calls the same commit_delete_rule + _record_iam path the IAM router uses — one DeleteIAMRule event per rule, each carrying a non-empty snapshot_ref and the additive marker payload.cascade == 'user_deleted' (+ payload.deleted_user_id). The required rule_id / before / after keys are unchanged, so iam_rules_as_of(T) replays the cascade through the same pointer-lookup path as a direct /admin/iam/rules* mutation. Failure behaviour (#912 round 2): the cascade loop runs BEFORE the user record is removed, so a listing failure or a per-rule commit failure propagates out of the handler with the user still present on disk; events for rules already committed stay durable and a second DELETE /admin/users/{user_id} finishes the remaining rules and finally removes the user. The race case (deleted=False return value, not an exception) is tolerated silently.
  • Guard. tests/test_mutation_transaction_guard.py Guard D scans BOTH admin_iam_router.py and admin_users_router.py (#912); it fails if any _record_iam() / record() call there reaches an audit sink without a non-empty snapshot_ref= kwarg, or with a payload dict literal missing before / after keys (#605 r1 review point 6, extended by #912).

Integration

How mutation-engine uses IAM

The mutation engine (domain/mutation_engine.py) does not import from iam/ directly. IAM enforcement happens in the route layer as a pre-flight check before any call into the mutation engine: server.py holds no write gate; the mutation routes gate in backend/api/mutations_router.py (_iam_reject_if_locked, mutations_router.py:195) and the document-attach routes in routes/documents.py (_gate_item_write).

resolve_owner from the mutation engine is conceptually separate from IAM — it resolves the node-level ownership assignment used by the collaborative editing system, not the IAM access-rule system:

def resolve_owner(node_id: str, eff: dict, assignments: list) -> str | None:

It walks the ancestry chain in eff["parent_of"] and returns the username of the most-specific active ownership assignment, or None if the node is unassigned. This is checked by GET /me/can-edit and determines whether the user's lock on a node is exclusive.

validate_add_node enforces domain-level structural rules (schema, duplicate labels, parent ownership, cycle prevention):

def validate_add_node(cmd: dict, username: str, eff: dict, assignments: list) -> ValidationResult:

IAM write access is checked in api/mutations_router.py (_iam_reject_if_locked) before validate_add_node is called. The mutation engine receives a request only if IAM has already passed.

How the API layer enforces permissions

The enforcement pattern in the route modules (server.py, api/mutations_router.py, routes/documents.py) is consistent:

  1. Authenticate: auth: CurrentUser (the canonical role in api/deps.py) extracts user_id.
  2. IAM gate: obtain the request's IamScope (via scope: IamGuard or scope_from_server) and call scope.require_write(target_node_id) before any mutation — the ONE predicate (hærdning 3.2).
  3. Read filtering: call scope.can_read(...) or the relevant iam.* builder and return only the filtered result.

The _THIS_DOMAIN server variable (set from EIDOS_DOMAIN env var) is always passed as the domain argument so all enforcement is scoped to the correct domain.


Error Responses

IAMValidationError — rule management (400)

Raised by validation functions in iam/admin.py when a rule payload is invalid. Caught in the /admin/iam/rules POST and PUT handlers and converted to a 400:

{
  "error": "invalid_request",
  "message": "Invalid role 'superuser'. Must be one of: ['admin', 'editor', 'viewer']"
}

Triggers: invalid role value, unknown domain, subtree_root not a known node ID, attempt to assign a rule to __super__.

Permission denied on write — 403

Raised when the user lacks write access on the target subtree. IamScope.require_write raises the canonical PermissionDenied (trin 3.1), so every write gate now emits the same envelope — no per-gate body divergence:

{ "error": "permission_denied", "detail": "P-WRITE_ACCESS_REQUIRED: Insufficient write access on target", "trace_id": "…" }

The stable machine code is error (permission_denied); the detail carries the P-WRITE_ACCESS_REQUIRED code + message. A write to a missing node surfaces the canonical 404 first: { "error": "node_not_found", … }. HTTP status: 403 (write denied) / 404 (missing node).

Permission denied on read — 403

Raised when IamScope.can_read returns False on a direct node fetch, or when _gate_proxy_domain blocks a cross-domain proxy request. The body depends on the route:

  • _gate_proxy_domain (server.py:819-822): {"detail": "No access to domain 'secondary-domain'"}.
  • GET /node/{node_id}: {"detail": "Access denied"}.
  • GET /children/{node_id} and GET /node-by-ref: typed PermissionDenied (P-READ_ACCESS_REQUIRED) → the canonical 403 envelope.
{
  "detail": "No access to domain 'secondary-domain'"
}

HTTP status: 403 Forbidden.

Filtered responses (no error)

When a user has partial access — for example, a viewer grant on one subtree of a larger tree — IAM does not return an error. The response is silently filtered:

  • The /tree endpoint returns only the accessible subtree plus restricted path ancestors.
  • The /nodes endpoint returns only accessible nodes.
  • The /relations endpoint returns only relations whose source is accessible and whose local target (if any) is accessible; other relations are left out.

The frontend receives a valid but narrowed response and should not present an error state to the user. The restricted flag on tree nodes and relation targets signals that content exists but is not visible to the current user.

Rule not found — 404

All /admin/iam/rules/{rule_id} endpoints return 404 when the rule ID does not exist or belongs to __super__:

{
  "detail": "Rule not found"
}