ADR-0035: Entity-aware request authorization (require_entity_access) — primitive + treasury observe
Status
accepted (decision). implementation_status: partial — the primitive and treasury
observation have landed; treasury enforcement is a tracked follow-up.
Context
Gateway request authorization is the flat require_coop_access guard
(middleware.rs: claims.coop_id == path_coop_id) at ~38 call sites — blind to
entity type, membership, and role. PR #2077 closed the self-asserted-coop_id
issuance weakness (#2075) issuance-side, but did not make authorization
entity-aware. RFC-0018 (#2074, spec #2061) specifies the move to
require_entity_access(caller, target, action).
The entity model already exists (icn-entity: EntityId, Membership,
MembershipRole, MembershipCapability) and the gateway already does
membership-based authz in one place — require_entity_write_access
(api/entity.rs), which resolves get_members(target) and checks the caller's
role. The gap is a reusable primitive and a measured migration path that does not
risk breaking live authorization while membership data is still being backfilled.
Decision
Introduce
require_entity_access(entity_mgr, caller, target, action)inauthority.rs, generalizingrequire_entity_write_access. It resolves the caller's membership intargetand checks an action-specific requirement.Canonical authz subject/target =
(caller individual EntityId, target EntityId). The callerEntityIdis derived at the guard from the token'sclaims.sub(DID) viaEntityId::from_did— it is not carried as an authority claim in the token. Rationale: a stored authority claim would recreate the #2075 failure mode (trusting a self-asserted token field) in a shinier outfit. Authority is resolved against the membership graph at request time. ("Claim vs lookup" — we choose lookup; revisit only with a trusted issuance path.)Action → authority basis (intentionally heterogeneous):
EntityActionRequirement Why ModifyEntityrole ∈ {Founder, BoardMember}, no active-standing gatepreserves require_entity_write_accessbehavior exactlyTreasuryWriteactive membership with TreasuryAccesscapabilitycapability, not role-name; Founder/BoardMember/Officer hold it via default_capabilities, a plain member only if explicitly grantedTreasuryReadactive membership (any role) broad for observe mode EntityActionstays minimal (three variants). New variants are added only when a real endpoint family needs them.Treasury is wired in OBSERVE mode, not enforcement. Each of the 10 treasury handlers, after the authoritative flat
require_coop_accesshas passed and the treasury is loaded, computes anEntityAccessObservationand records it viaentity_authz_observation_total{family,action,result,reason}. The entity path never denies in this slice;require_coop_accessremains the sole enforced gate. Observation runs only after flat success (the flat guard early-returns on denial), so the metric answers: of currently-allowed treasury requests, how often would the entity path also allow?coop_id → EntityIdprojection is fallback-only. Treasury already stores itsEntityId(treasury.entity_id()), so that is the authoritative target.legacy_coop_id_to_entity_id_fallback(a thin wrapper overEntityId::cooperative, which rejects rather than normalizes) is used only whenentity_id()isNone. The canonical, persisted, reversiblecoop_id ↔ EntityIdmapping/backfill is deferred (tracked follow-up).
Consequences
- Zero live authorization change. No flat guard is removed or weakened; the entity path is observation-only. The risk of denying legitimate treasury calls (e.g. coops not yet entity-registered) is avoided by design.
- Off the request path entirely. The observation (an entity-registry lookup
that may be a daemon-actor round trip) runs in a detached best-effort task, not
awaited inline. It therefore cannot affect the treasury response or its
latency, even if the
EntityManageris slow or stalled — the observation can be dropped without consequence. - The primitive gains one live caller immediately:
require_entity_write_accessdelegates to it (ModifyEntity), proving it against the existing entity-write path without behavior change. - The observation metric's
reasontaxonomy (missing_treasury_entity_id,no_memberships,non_member,missing_capability, …) becomes the evidence that gates the future enforcement cutover. - Heterogeneous per-action bases (role for
ModifyEntity, capability forTreasuryWrite) are deliberate; an implementer must not "simplify" them into a single role check — that would either weaken treasury or tighten entity-write. - Meaning Firewall preserved: the gateway translates the institutional action into
a generic membership/capability requirement; no domain (
trust/governance/ccl/coop/community) crate is imported, and the kernel sees nothing.
Implementation status
partial. Proven in this slice:
require_entity_access+EntityActionwith a 7-case decision matrix (authority.rstests): Founder/BoardMember allow; active member reads but cannot write; capability-beats-role; non-member deny; suspended deny (treasury);ModifyEntityrole-only preserved.require_entity_write_accessdelegates to the primitive; existing entity-write tests stay green.- Treasury observe-mode wiring + an "entity-deny does not deny the request" test.
legacy_coop_id_to_entity_id_fallbackreject-not-normalize tests.
Not implemented (tracked follow-ups):
- Treasury enforcement cutover (gated on clean observation metrics + membership
backfill; possible
TreasuryRead/TreasurySensitiveReadsplit). entity_idtoken claim — partially landed. The optional, non-enforcingentity_id/entity_typeclaim shape +issue_entity_tokenmint seam landed in PR #2111 (#2080 lane PR1). It is never set by the self-asserted path and read by no guard. Populating it from a trusted issuance path remains the follow-up.- Canonical persisted
coop_id ↔ EntityIdmapping/backfill. - Other endpoint families; delegation/federation/community cross-entity authority (RFC-0018 Step 5).
Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Enforce the entity guard on treasury now (dual-guard, both deny) | Membership data is only populated for entity-registered coops; enforcing would 403 legitimate calls and violate the doctrine's fail-closed-only-on-wired-resolver rule (ABUSE_CASE_HARDENING §4.3). Observe first, enforce on evidence. |
Carry an entity_id/authority claim in the JWT |
Recreates the #2075 self-asserted-claim trust problem. Resolve at the guard instead. |
| Single role-based check for all actions | Either weakens treasury (role ≠ treasury authority) or tightens entity-write (breaks behavior parity). Per-action bases are the point. |
Build the canonical coop_id ↔ EntityId backfill now |
Unnecessary for treasury (it stores entity_id); it is the RFC's heavy gating sub-task and belongs with the enforcement cutover, not the primitive. |
Lowercase-normalize coop_id into a slug |
Lossy: coop_A and coop-a collide → identity soup. Reject non-mappable ids instead. |
Notes
This ADR records the first RFC-0018 implementation slice. The RFC remains the design of record for the full migration; this ADR fixes the concrete decisions the primitive and treasury-observe wiring bake in.
Update (2026-06-20, PR #2111 / #2080 lane PR1): the "Carry an entity_id/authority
claim in the JWT" alternative above was rejected for this slice's purpose — resolving
the caller's authority from a self-asserted, guard-trusted claim, which recreates the
#2075 problem. PR #2111 adds an optional entity_id/entity_type claim that is not that:
it is non-enforcing (no guard reads it), it is never set by the self-asserted path
(verify_challenge → issue_token mints None), and it is populated only by a future
trusted issuance path. The "resolve at the guard" decision for the observe slice stands;
the claim is migration groundwork, not a trusted caller-supplied authority input.