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/meand 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_jsonanddeploy.py::_build_users_json— set both flags true forrole == "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.jsonalready 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-userstherefore merges too; it is no longer a destructive overwrite. - Reads reflect disk truth.
/auth/meand the IIS gate reloadusers.jsonbefore reading a flag.server._load_users()rebinds the module_usersglobal to a freshly-built dict (a single atomic assignment — never an in-placeclear()+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,iriset. - Target exists but is out of scope:
{"id": "...", "iri": "...", "label": "Restricted", "restricted": true}—iriis the row's owntoIri, never derived from the placeholder. - Cross-domain target (
to=None):{"id": null, "iri": null, "label": "Restricted", "restricted": true}— always restricted regardless of role,irialwaysnull;external_domainandexternal_refare 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— callsbuild_visible_tree; the response is pre-filtered before it leaves the server.GET /nodes— filters throughIamScope(_read_scope_for(...).filter_readable); only accessible nodes are returned.GET /node/{node_id}— checksIamScope.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 getsGET /node/{node_id}'s read gate (#954 S4a): after resolving the ref (unknown ref → 404 first) it checksIamScope.can_readon the resolved uuid and returns 403 (typedPermissionDeniedP-READ_ACCESS_REQUIRED→ canonical 403) when the node is not in scope. The proxy variantGET /api/proxy/{server}/node-by-refcarries the same domain-level gate as/api/proxy/{server}/node/{node_id}(deps.proxy_read: an explicit rule forserver). (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): ifIamScope.can_read(node_id)is false it returns 403 (typedPermissionDenied→ 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 withIamScope.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 viadescendantCount. Peripheral relation labels (targetLabel/originLabelof nodes outside the subtree) are read-filtered per-node.plan_deleteruns with the caller's context, soblockedByreflects 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 throughIamScope.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 localto) are kept. There is no restricted-target shape here, so an unreadable target drops the whole relation. Each returned row carries thefromIri/toIri/external_iricompanions (#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_seeIDOR 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 byinfra.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_readper emitted node, #1059). An inherited entry whose source ancestor is not readable keepsisInheritedbut carries an emptyinheritedFromLabel, 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 withIamScope.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 404node_not_foundas 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'snode_label. With IAM not enforced, an unknown node is still accepted as before, with an empty label.node_idmay 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) — callsbuild_exportunderiam_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→ 403permission_denied, andmust_exist_inmakes a missing node a 404NodeNotFoundErrorfirst (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 sameIamScopepredicate already grants them everywhere. DELETE /mutations/node/{uuid}carries an explicit route-level_get_user_role(user) == "admin"fast-path before the node-scopedrequire_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 aneditororadminrule 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'ssnapshotRefcannot 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 ineidos:snapshot.snapshotRef;OperationRecord.snapshot_refexposes it. No envelope change. - Before/after diff in the payload. Each event carries
payload.beforeandpayload.afterfor the specific rule changed (before=nullfor Create,after=nullfor 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 ofTfor every mutation that flows through/admin/iam/rules*AND for the user-delete cascade (#912). A date-onlyTis end-of-day. RaisesIAMHistoryFloorError(HTTP 404,error_code="iam_before_history_floor") ifTprecedes the first snapshot-bearing IAM event. Cascade coverage (#912): when a user is deleted viaDELETE /admin/users/{user_id},admin_users_router.admin_delete_userloops through each orphaned rule and calls the samecommit_delete_rule+_record_iampath the IAM router uses — oneDeleteIAMRuleevent per rule, each carrying a non-emptysnapshot_refand the additive markerpayload.cascade == 'user_deleted'(+payload.deleted_user_id). The requiredrule_id/before/afterkeys are unchanged, soiam_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 secondDELETE /admin/users/{user_id}finishes the remaining rules and finally removes the user. The race case (deleted=Falsereturn value, not an exception) is tolerated silently. - Guard.
tests/test_mutation_transaction_guard.pyGuard D scans BOTHadmin_iam_router.pyandadmin_users_router.py(#912); it fails if any_record_iam()/record()call there reaches an audit sink without a non-emptysnapshot_ref=kwarg, or with a payload dict literal missingbefore/afterkeys (#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:
- Authenticate:
auth: CurrentUser(the canonical role inapi/deps.py) extractsuser_id. - IAM gate: obtain the request's
IamScope(viascope: IamGuardorscope_from_server) and callscope.require_write(target_node_id)before any mutation — the ONE predicate (hærdning 3.2). - Read filtering: call
scope.can_read(...)or the relevantiam.*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}andGET /node-by-ref: typedPermissionDenied(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
/treeendpoint returns only the accessible subtree plus restricted path ancestors. - The
/nodesendpoint returns only accessible nodes. - The
/relationsendpoint 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"
}