Skip to content

feat(epitrope): implement grant narrowing, validity, budgets and lifecycle - #86

Merged
forkwright merged 19 commits into
mainfrom
feat/epitrope-authz
Sep 25, 2026
Merged

forkwright merged 19 commits into
mainfrom
feat/epitrope-authz

Conversation

@forkwright

@forkwright forkwright commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What changed

crates/epitrope is the pure authorization core. It does no I/O, uses no tokio, and reads the clock only through an injected Clock.

  • Grant narrowing. check_issue validates a proposed child against the designated grant on every axis, in contract order: capabilities, audit scope, session scope, target scope, each ceiling against the parent's remaining budget, validity window, depth, and issuer/parent link. A violation reports the failing axis back to the issuer as Denied{NarrowingViolation, axis}.
  • Chain validity. check_chain walks leaf to root at clock.now().
    • A revocation on any ancestor invalidates every descendant without writing to each one.
    • now == expires_at counts as expired.
    • Depth must strictly decrease, so the walk always ends. A malformed link fails closed.
    • A view that answers with a record for a different id fails closed too.
  • authorize. It authorizes against the designated grant's chain only.
    • A missing or foreign designated grant returns the same NotFoundOrDenied.
    • Target scope and session scope are checked on every link of the chain.
    • Each capability's session requirement is enforced here, not left to the daemon.
  • check_revoke. A request may revoke only the designated grant or a grant descending from it, and the designated grant's chain must confer GrantRevoke. Any target outside that subtree, including another tenant's grant, gets the same NotFoundOrDenied as a missing one. Revoking an already-revoked grant is idempotent.
  • Budgets. Each dimension has its own ceiling. plan_reservation checks every ledger along the chain, plus the session and tenant ledgers.
    • When the caller's own ledger is exhausted, the reply is BudgetExceeded{dimension}.
    • When an upstream ledger is exhausted, the reply is Denied{BudgetUnavailable} and names no dimension.
    • settle charges the actual use, never more than was reserved, and releases the rest. All arithmetic is checked.
  • Lifecycle. The transition table matches the contract's B1–B5 states, and recovery_action gives the restart rule for each state.
  • Dry-run planner. It runs over read-only view traits, which have no write methods. A refused plan's grant chain is empty.
  • Audit (D17.7). Operator default scope is All; agents default to OwnAndOwnedSessions. RuleView has no audit accessor, and a compile-fail doctest proves it.

Contract amendments (the contract stays at v1; nothing has shipped): new syntheke codes DenyCode::BudgetUnavailable and DenyCode::SessionRequired, Denied{code, axis}, and NarrowingAxis::AuditScope. capability-contract.md adds:

  • the authorization and attenuation check order;
  • the session-requirement table and the revocation-authority rule;
  • Own session scope, which covers descendant tenants;
  • the validity-window and revocation-time rules;
  • a Query request must name a session scope.

A new fixture, neg_session_required, is included.

Why

Phase 01 S2 requires every invocation to be authorized before any effect. This crate holds that logic with no I/O, so it can be tested exhaustively and cannot write.

Dependencies

Runtime: syntheke and snafu only. Dev: proptest 1.11 (alloc/no_std, a fixed ChaCha seed, no filesystem or OS entropy).

Stage and acceptance

Phase 01 S2. This PR proves the acceptance clauses for grant attenuation, expiry and revocation, and for cross-tenant read and metadata denial, at the authorization boundary. Dry-run can't write because the view traits have no write methods.

Proof (toolchain 1.97.1)

  • epitrope: 85 tests plus doctests. They cover:
    • Every narrowing axis, and expiry and not-before at the exact boundary.
    • A revoked grandparent, a cyclic lineage, and a designated grant held by another tenant.
    • Every budget dimension, settlement overrun and overflow.
    • The full transition matrix and recovery actions.
    • Revoke checked against self, child, grandchild, sibling, ancestor, foreign and missing targets.
    • The session requirement for all 9 capabilities.
    • Identical dry-run plans for a foreign grant and a missing one.
  • Property test (4096 cases, both directions): a child that passes narrowing is never broader than its parent, and a refused child is broader on at least one axis. Deliberately breaking the check makes the test fail.
  • Workspace: 202 tests pass. clippy -D warnings, cargo deny check, cargo tree -d (only the documented syn skip) and the doc checks are clean.
  • Two independent adversarial reviews: the second added view-id checks, depth re-validation in the chain walk, and closed the revoke and session gaps.

rkyv 0.8 with bytecheck is the wire format fixed by topology.md; the
format features are named so a conflicting layout fails the build.
snafu is the fleet error library. toml is a dev-dependency for the
contract fixture test. deny.toml allows MIT, Apache-2.0, and
Unicode-3.0 for these crates and skips the syn 2 copy that snafu-derive
and munge_macro still build with.
Identifiers with ULID text form, the capability, mode, scope, and
budget vocabulary, request and reply payloads for every capability,
the outcome taxonomy, the frame header codec, handshake frames, the
authentication transcript, and a decoder that bounds length before
copying and validates the archive before any field is read.
Every message type round-trips through a full frame; corrupted and
truncated bodies are rejected. The fixture test maps each file under
docs/contract/fixtures onto syntheke types and fails when the
directory or a declared fixture is missing.
Request gains a grant field naming the one grant a call acts under, in
execute and dry-run mode. GrantIssueRequest drops parent_grant: the
designated grant is the parent, so the two can no longer disagree.
Fault frames now refuse any failure other than ProtocolError and
AuthFailed on encode and decode, and VersionChoice is non-exhaustive.

Fixtures carry grant as a wire field on every capability request; the
cast gives tnt0f grant grn0f, and neg_foreign_grant covers a request
naming another tenant's grant. The fixture conformance test splits into
load, mapping, and test modules.
Every capability request names the grant it acts under; the daemon
authorizes against that grant's chain only. A missing or foreign
designated grant returns the same NotFoundOrDenied, and the idempotency
digest covers the grant. The wire section now lists the eight frame
kinds with their bytes, restricts Fault to connection-level failures,
fixes the auth transcript layout, and states time units. Fixture keys
move grant to the wire fields. The contract stays at version 1.
Names the crates on each side of the syn 2/syn 3 split, the one
alternative (holding rkyv at 0.8.17) and why it is rejected, and the
retirement condition. The license list takes the same entries and order
as the storage branch so the later merge touches only comments.
Add the pure authorization crate over the syntheke contract types:

- narrowing of a child grant against the designated parent on every
  axis, reporting the first failing NarrowingAxis;
- chain validity at an injected clock against revocation records, so an
  ancestor's revocation invalidates descendants without fan-out;
- designated-grant authorization where a grant the connection's tenant
  does not hold reads exactly as a missing one;
- reservation and settlement arithmetic with checked ledgers and a typed
  overrun;
- the invocation transition table and restart recovery actions;
- a dry-run planner over a read-only snapshot trait;
- D17.7 audit scope defaults and a rule view with no audit accessor;
- a minimal origin parser for target scopes that refuses every
  construct where parsers could disagree about the host.

proptest (dev only, no default features) checks that a child passing
the narrowing check is never broader than its parent on any axis.
Add DenyCode::BudgetUnavailable for exhaustion on a ledger the caller
does not own; it names no dimension. Add NarrowingAxis::AuditScope.
Failure::Denied carries axis: Option<NarrowingAxis>, set exactly for
NarrowingViolation; Response::check refuses any other combination in a
reply or a plan's refusal (DeniedAxisMismatch). Move the outcome unit
tests to outcome/tests.rs.
Upstream budget exhaustion is Denied{BudgetUnavailable}. A narrowing
refusal carries its axis on the wire, and the audit scope is its own
axis. The chain walk and designated-grant lookups refuse a view answer
filed under another id, and the walk re-checks each link's depth
against its parent's maximum. The narrowing property now checks both
directions.
State Own-scope lineage, check order, session and target refusals,
mode-independent decisions, empty chains on refused plans, every
attenuation axis with its order, revocation regardless of effect time,
the legal transitions, BudgetUnavailable, and the axis on Denied.
@forkwright
forkwright merged commit b7d63b3 into main Sep 25, 2026
13 checks passed
@forkwright
forkwright deleted the feat/epitrope-authz branch September 25, 2026 20:49
@forkwright forkwright mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant