Entity-Aware Authorization Control Map
Status: draft — design / control map (migration orientation, not implementation)
Truth class: descriptive
Canonical: no — current implementation truth lives in docs/STATE.md and docs/PHASE_PROGRESS.md
Last Reviewed: 2026-06-26
Source basis: map read against main @ e7f3fc02 (#2181); the observe-mode note re-verified against main @ 0ef541c5 (#2197) and the A2d target-verification update against main @ 08acb0e5 (#2199). Route-family counts were last verified at e7f3fc02 — re-verify before relying on exact numbers.
Related: RFC-0018 · ADR-0035 · ABUSE_CASE_HARDENING_STRATEGY.md · issues #2061, #2080, #1868
This document is a control map for an in-progress migration, not a description of a finished system. It explains where authorization is enforced today, what is observe-only, and what is not yet wired. It does not authorize any behavior change.
Purpose
ICN's request authorization currently spans four moving parts: a flat coop_id guard, an entity-aware
guard, optional token claims, and a trusted-issuance seam. Each is at a different stage of a single
migration, and the surface is easy to misread — both as "more enforced than it is" and as "broken."
This map fixes one durable picture of:
- what each authorization input actually proves today,
- the two coexisting authorization regimes (flat vs entity-aware),
- the observe-mode pattern being used to migrate safely,
- the fail-closed trusted-issuance seam,
- the single missing
coop_id → EntityIdresolver that gates further progress, and - how
#2061,#2080, and#1868relate.
It makes the migration surface explicit before any enforcement or code change.
Current authorization reality
A gateway request is authorized today by two things: an authenticated bearer token (the caller proves
key control of a DID) and a per-route guard. On the bulk of mutation routes that guard is a flat
coop_id string match. On entity routes it is an entity-aware membership check. On treasury routes the
entity-aware check runs alongside the flat guard but only as an observation. That is the whole
picture; everything below is detail.
The current system is not broken because it still uses flat coop_id guards. Those guards are the
current enforced safety baseline. The risk is treating flat coop_id as the final authority model.
DID, token, coop_id, EntityId: what each proves
| Input | Where | Enforced today? | What it proves |
|---|---|---|---|
sub (DID) |
TokenClaims.sub (icn-gateway/src/auth.rs) |
yes (authentication) | key control of a DID. Nothing institutional. |
coop_id |
TokenClaims.coop_id |
yes — the sole authZ gate on most routes | a flat namespace string. require_coop_access checks claims.coop_id == <route coop_id>. |
scopes |
TokenClaims.scopes |
yes | technical/capability permission (ledger:write, governance:write, …). Not institutional authority. |
entity_id / entity_type |
TokenClaims.entity_id? / entity_type? |
no — non-enforcing context | a typed EntityId context, settable only by a trusted issuance path. The dev / self-asserted path never sets it; the flat guard ignores it. |
EntityId + membership |
icn-entity (entity.rs, membership.rs, registry.rs) |
enforced only on entity routes | the typed institutional model: entity kind, membership, role, standing, capability. |
The load-bearing distinctions this map preserves:
A DID proves key control.
A DID is not membership.
A token is not voting power.
A capability scope is not a mandate.
A flat coop_id match is not entity-aware authorization.
Mapping is not authority.
Entity-aware authorization, even once enforced, remains subordinate to the mandate/authority spine where an institutional effect is involved (governance acts gate separately; see the operating model).
Regime A — flat coop_id authorization
- Guard:
require_coop_access(req, coop_id)inicn-gateway/src/middleware.rs. - Mechanism: string equality between the token's
coop_idclaim and the route'scoop_id. - Status: authoritative — this is the enforced gate on the bulk of mutation routes.
- Blind to: entity kind, membership graph, hierarchy, delegation, role, and standing.
- Deliberately ignores the optional
entity_idclaim (a regression test pins this: a token'sentity_idmust not influence the flat decision).
This is the safety baseline. It is coarse but real, and it must remain in force until a typed replacement is both available and measured.
Regime B — entity-aware authorization
- Guard:
require_entity_access(entity_mgr, caller, target, action)inicn-gateway/src/authority.rs(RFC-0018 / ADR-0035). - Mechanism: resolve the caller's membership in the target
EntityId, then check anEntityActionagainst role / active-standing / capability. The current action triad isModifyEntity,TreasuryRead,TreasuryWrite. - Status:
- Enforced on entity routes (
icn-gateway/src/api/entity.rs, viarequire_entity_write_access). - Observe-only on treasury routes (see next section).
- Absent on all other route families.
- Enforced on entity routes (
- This is the migration target: typed, membership-grounded authorization that the kernel never has to understand (meaning stays in the app; the kernel sees only the allow/deny).
Observe-mode migration pattern
Treasury routes are the migration frontier. There, the entity-aware decision is computed alongside the authoritative flat guard and recorded — never acted on:
observe_treasury_entity_access(...)inicn-gateway/src/authority.rsreturns anEntityAccessObservation(AgreesAllow/EntityDeny(reason)/Indeterminate(reason)/Error).- The observation is data, not a
Resultthe caller propagates — it is structurally incapable of denying a request. It emits a metric and a log line; the flat guard remains the sole enforced gate. - It records exactly the signal a future enforcement flip needs: where the entity-aware path would
deny (
NonMember/InactiveMember/MissingCapability) or cannot decide (data gaps such as a missingentity_idor unregistered entity).
The migration discipline this encodes:
Keep the flat guard as the enforced baseline.
Compute the entity-aware decision in observe mode and measure divergence.
Move one route family at a time to enforcement — and only after trusted
EntityId resolution and trusted token issuance exist.
Update (2026-06-26, 08acb0e5, #2199). The treasury observation consults a trusted, fail-closed store-backed coop_id → EntityId resolver (#2196, resolver result discarded — route outcomes byte-identical), with an explicit observe → measure → gate seam over it (#2197, "A2d"): TreasuryEntityAuthMode { ObserveOnly (default), EnforceTrustedResolver } plus a pure decide_treasury_gate(...) in authority.rs. #2199 then carried target evidence through the observe-only path (MembershipTargetSource / TreasuryMembershipObservation / CoopResolutionEvidence / TreasuryGateEvidence; decide_treasury_gate now takes &TreasuryGateEvidence) so the decision can verify the membership target against the trusted resolved/agreed target. This is still the "measure → gate-later" rung, not enforcement — the active mode is ObserveOnly, default route outcomes are unchanged, the flat require_coop_access guard stays the enforced baseline, and EnforceTrustedResolver is decision-only (wired to no route or config). Under that decision-only simulation it no longer fails closed on every resolution: Agree/ResolverOnly with a checked trusted target (trusted_target() — for Agree, legacy and resolver targets present AND equal; for ResolverOnly, a present resolver target) and a matching membership_target + AgreesAllow can return ProceedUnchanged. Malformed/missing target evidence fails closed as UntrustedResolution (before any target-unverified or membership reason); Disagree → ResolverConflict; LegacyOnly/NeitherResolved/source-unavailable/backend-error/UnknownLegacy/gossip → UntrustedResolution; ObserveOnly still always returns ProceedUnchanged. Not yet landed: A2e enforcement-cutover criteria and any per-route-family cutover. Canonical current-state record: docs/STATE.md and docs/PHASE_PROGRESS.md.
Trusted issuance seam
Whether a token may carry typed entity context is a separate, fail-closed boundary:
TokenAuthoritySource(trait) inicn-gateway/src/token_authority.rsanswers, by value, whether a subject DID may receive a token binding acoop_id/EntityIdwith given scopes (IssuanceAuthorityDecision::{Allow{basis}, Deny{reason}}).- The only source shipped is
DenyUntilWired: it denies every request and reads none of its inputs — no DID,coop_id,EntityId, or scope can produce an allow. Denial is a decision value, not an error, so a caller can never mistake an unwired/unhealthy source for an allow. IssuanceAuthorityBasisnames the future positive sources (Membership,Standing,Bootstrap,AuthorityGrant); only a test-only basis is ever constructed today. No production issuance path (/auth/verify, invites, sessions, SDIS enrollment, bootstrap) consults aTokenAuthoritySourceyet, andentity_idminting remains a raw primitive.
Self-asserted coop_id issuance at /auth/verify is fail-closed (RFC-0018, #2075): DID key control
alone never authorizes a cooperative.
The coop_id → EntityId resolver keystone
All three seams above — route enforcement, trusted issuance, and the token entity_id claim — converge
on the same missing piece: a trusted, governed resolver from the legacy flat coop_id namespace to
the typed EntityId model.
Today the only bridge is legacy_coop_id_to_entity_id_fallback in icn-gateway/src/entity_map.rs, a
best-effort reject-not-normalize projection used only inside observe-mode. It is not a governed
authority source.
The coop_id → EntityId resolver is the keystone because it joins the legacy route
namespace to the typed institutional entity model. Without it:
- #2061 cannot safely flip treasury observe → enforce (nor add observe elsewhere), and
- #2080 cannot safely mint positive entity-scoped authority.
It is the keystone precisely because both the enforcement side and the issuance side are designed to read from the same membership-grounded source once it exists.
Route-family enforcement map
Mechanically counted at e7f3fc02 (rg over icn-gateway/src); treat as a snapshot, not a contract.
| Route family (file) | Flat require_coop_access sites |
Entity-aware (Regime B) | Net state |
|---|---|---|---|
api/entity.rs |
0 | enforced (require_entity_write_access → require_entity_access, ModifyEntity) |
migrated |
api/treasury.rs |
10 (enforced) | observe-only ×2 (TreasuryRead / TreasuryWrite) |
migration frontier |
api/ledger.rs |
6 | none | flat-only |
api/coops.rs |
6 | none | flat-only |
api/registry.rs |
2 | none | flat-only |
api/invites.rs |
2 | none | flat-only |
api/escrow.rs |
2 | none | flat-only |
api/recurring_settlements.rs |
1 | none | flat-only |
middleware.rs |
guard definition + helpers + tests | — | the guard itself |
Relationship to #2061, #2080, and #1868
- #2061 — entity-aware request authorization (route side). Migrate route families off the flat
coop_idguard onto entity-aware checks. The next safe rung is to extend the observe pattern (already proven on treasury) to a flat-only family and measure divergence before any enforcement flip. Blocked on the resolver keystone for the enforce step. - #2080 — trusted positive token issuance (issuance side). Replace
DenyUntilWiredwith a positiveTokenAuthoritySource(likelyMembership-backed) plus a first-admin bootstrap path. Blocked on the same resolver; until then, fail-closedDenyUntilWiredis the correct shipped state. - #1868 — decompose broad
governance:write(scope side). Orthogonal but complementary: breaking a single broad scope into per-action capabilities / mandate-bundles shrinks the blast radius of any one token. It interacts with this map only in that scopes are technical permission, not authority.
These are three sides of one migration: routes (who is checked), issuance (what a token may claim), and scopes (how coarse the permission is). The resolver is the joint that lets the route and issuance sides converge on one membership-grounded authority source.
Migration principles
- The flat
coop_idguard is the current enforced safety baseline; do not remove it ahead of a measured, typed replacement. - Frame entity-aware authorization as a migration target, not a finished state.
- Migrate by observe → measure → enforce, one route family at a time.
- Trusted issuance stays fail-closed (
DenyUntilWired) until a governed source is wired. - Entity-aware authorization remains subordinate to the mandate/authority spine for institutional effects.
- Land the
coop_id → EntityIdresolver as the shared keystone before flipping enforcement or minting positive entity-scoped authority.
Non-claims
This document explicitly does not claim:
- that entity-aware authorization is fully enforced (it is enforced only on entity routes; observe-only on treasury; absent elsewhere);
- that trusted positive token issuance is live (only
DenyUntilWiredships); - that flat
coop_idguards are removed (they remain the enforced baseline); - that the
coop_id → EntityIdresolver exists as a governed authority source; - production readiness, live federation, formal NYCN pilot status, or complete API coverage.
This is a control map for migration, not an implementation.
Out of scope
This document covers the authorization-migration surface only. It does not address route/OpenAPI inventory (#2112), governance-scope decomposition implementation (#1868), or any non-auth findings; and it intentionally introduces no runtime change.
Validation
Docs-only change. The applicable gate is the documentation control plane:
python3 docs/scripts/doc_control_check.py --repo . --registry docs/registry.toml --strict
Not applicable to this doc: compliance_linter.py scans gateway api/**, models.rs, and SDK/UI only
(not docs/); lint-arch.py targets docs/ARCHITECTURE.md only. No Rust build/test is required for a
docs-only change.
Source references (verified at e7f3fc02)
icn/crates/icn-gateway/src/middleware.rs—require_coop_access(flat guard) + tests.icn/crates/icn-gateway/src/authority.rs—require_entity_access,EntityAction, observe-mode (observe_treasury_entity_access,EntityAccessObservation,DenyReason,IndeterminateReason).icn/crates/icn-gateway/src/token_authority.rs—TokenAuthoritySource,DenyUntilWired,IssuanceAuthorityDecision/IssuanceAuthorityBasis/IssuanceDenialReason.icn/crates/icn-gateway/src/auth.rs—TokenClaims(incl. non-enforcingentity_id/entity_type).icn/crates/icn-gateway/src/entity_map.rs—legacy_coop_id_to_entity_id_fallback(bridge).icn/crates/icn-gateway/src/api/{entity,treasury,ledger,coops,registry,invites,escrow,recurring_settlements}.rs— route guards.icn/crates/icn-entity/src/{entity,membership,registry}.rs— typed entity / membership model.