Skip to content

feat(uma): X2a/X2b — UMA 2.0 ticket model, store and grant service; fix non-serialising single-use consumes - #292

Merged
ilpanich merged 6 commits into
mainfrom
claude/improvements-run5-benchmark-def-bazzei
Aug 11, 2026
Merged

feat(uma): X2a/X2b — UMA 2.0 ticket model, store and grant service; fix non-serialising single-use consumes#292
ilpanich merged 6 commits into
mainfrom
claude/improvements-run5-benchmark-def-bazzei

Conversation

@ilpanich

Copy link
Copy Markdown
Owner

Summary

The first two increments of X2 (UMA 2.0 permission tickets), plus a security fix that the X2a concurrency test uncovered in already-shipped code.

This is server-side groundwork only — there is no REST surface yet. See Scope below.

X2a — domain model, ticket store, schema v30

The load-bearing decision is written up in claude_dev/uma-mapping-design.md: a UMA resource scope maps to an AXIAM action, not to AXIAM's scope.

UMA scopes are verbs; AXIAM's verb is action, and AXIAM's scope is a sub-resource narrower that merely shares the word. Mapping onto scope is the natural-looking choice and is a privilege-escalation trap: a PermissionGrant with empty scope_ids matches any requested scope, so one unscoped grant of whatever fixed action we invented would satisfy every scope on the resource at once — registering a new delete scope would silently widen every existing grant to include it.

Verb-to-verb has no such failure, and it makes B1 deny-override apply to RPTs with no special casing, because minting an RPT becomes the same check a live request makes. That is what X2 requires ("a deny rule must veto an RPT exactly as it vetoes a live check") and it is only automatic under this mapping.

Other choices worth naming:

  • A ticket is a stored row, not a signed JWT (Keycloak's choice). UMA 2.0 §3.3 requires single-use, and single-use is not a property a stateless token can have.
  • client_id is in the consuming statement's WHERE clause, not checked on the returned row. Checked afterwards, a ticket leaked to another client would be burned by that client's failed attempt — a denial of service against the resource server that owns it.
  • Every ticket rejection is one message on the wire. Unknown, consumed, expired and wrong-client differ only in the audit trail; a client that could tell them apart could probe for live handles.

Security fix — single-use consumes did not serialise

The X2a concurrency test failed, and the cause was not in X2. A multi-statement query (LET $x = (UPDATE …); SELECT … FROM $x) is not atomic in SurrealDB — each statement runs in its own transaction — so the WHERE consumed = false guard does not serialise concurrent callers at all, despite both sites' doc comments saying exactly one can win.

Measured, 8 concurrent callers on one row:

Site Before After
device_grant.redeem 4 of 8 won — one user approval minting four token sets 1
pushed_auth_request.consume 2 of 8 won — a replayable RFC 9126 request_uri 1
oauth2_auth_code.consume 1 — safe already (single statement) 1
refresh-token rotation 1 — safe already (see below) 1

The fix is BEGIN/COMMIT so the datastore detects the write-write conflict, plus a new is_transaction_conflict helper translating the loser's aborted transaction into "no row consumed" — otherwise a correctly-refused replay surfaces as a 500. Both fixed sites now yield exactly one winner and zero errors.

Two sites I initially suspected turned out to be fine, and the tests say so rather than my reading of them. Authorization-code consume is a single statement, which SurrealDB does execute atomically. Refresh rotation is safe by a different route: revoke is also single-statement, and the service compensates on a lost race — the loser's revoke returns NotFound, so it revokes the token it just minted and answers invalid_grant instead of handing over a second live token set. Both are pinned by tests anyway, because "safe today" and "safe after the next refactor to the LET/SELECT shape" are different claims.

The distinguishing factor is statement count, not the WHERE guard — worth knowing before the next single-use consume is written.

X2b — grant service

Mints tickets and exchanges them for RPT contents.

  • The ticket is consumed before anything is evaluated. Evaluating first would let two concurrent redemptions both pass and both mint — the exact class of bug above. It also means a replay costs no engine round trips.
  • Partial grants are refused whole, never trimmed. A trimmed RPT would make its own contents depend on evaluation order and hand the client a token that silently does less than it asked for. UMA's answer here is claims-gathering, deferred to v2.
  • Scope names are validated before the ticket exists, so an undeclared scope is an actionable error now rather than a credential guaranteed to fail 60 s later. The test asserts no row is written.

The evaluator and scope catalogue are traits declared in the module, not imports: axiam-oauth2 does not depend on axiam-authz, and axiam-api-rest is the composition root. They are separate traits because "could this scope be asked for" and "may this subject have it" are different questions — conflating them would make an undeclared scope indistinguishable from a denied one.

Scope

Deliberately not in this PR, and not started: the REST surface (/uma2/perm, rreg CRUD, /.well-known/uma2-configuration), the RPT introspection extension, and SDK contract §17 with its downstream fan-out. Nothing here is reachable over HTTP yet — it is the model, the store and the service, with the grant-type constant defined but not wired into the token endpoint.

Verification

  • axiam-core lib 111, axiam-oauth2 lib 88 (14 new), axiam-db lib 144
  • permission_ticket_test 10 and single_use_concurrency_test 4, both stable across repeated consecutive runs
  • device_grant_test 11, oauth2_refresh_token_gaps_test 7
  • clippy -D warnings and rustfmt clean

Concurrency was measured against the in-memory engine the tests use. The fix is correct regardless of backend, but pre-fix severity on a production datastore has not been measured.

No issues are open in this repository, so none are referenced or closed.


Generated by Claude Code

claude added 4 commits August 10, 2026 16:49
First increment of X2 (UMA 2.0). Domain model, repository trait, SurrealDB
implementation, schema v30, and the tests for the parts that decide security.

The load-bearing decision is recorded in claude_dev/uma-mapping-design.md:
**a UMA resource scope maps to an AXIAM `action`**, not to AXIAM's `scope`.
UMA scopes are verbs; AXIAM's verb is `action`, and AXIAM's `scope` is a
sub-resource narrower that merely shares the word.

Mapping onto `scope` looked natural and is a privilege-escalation trap. A
grant with empty `scope_ids` matches any requested scope, so one unscoped
grant of whatever fixed action we invented would satisfy every scope on the
resource at once — registering a new `delete` scope would silently widen every
existing grant to include it. Verb-to-verb has no such failure, and it makes
B1 deny-override apply to RPTs with no special casing, because minting an RPT
becomes the same check a live request makes.

A ticket is a stored row rather than a signed JWT (Keycloak's choice) because
UMA 2.0 §3.3 requires single-use, and single-use is not a property a stateless
token can have.

`client_id` sits in the consuming statement's WHERE clause rather than being
checked on the returned row. Checked afterwards, a ticket leaked to another
client would be *burned* by that client's failed attempt — a denial of service
against the resource server that owns it.

The concurrency test found something that was not in the plan. A single
`UPDATE ... WHERE consumed = false ... RETURN BEFORE` — the pattern this
repository already uses for single-use elsewhere — does NOT serialise: eight
concurrent redemptions of one ticket all succeeded, 4 to 8 winners per run.
Wrapping it in BEGIN/COMMIT makes SurrealDB detect the write-write conflict
and abort the losers, which `is_transaction_conflict` then translates to "you
lost the race" rather than a 500. The regression test fails with 4 winners out
of 8 if the transaction is removed.

This same pattern backs shipped code — `pushed_auth_request` (RFC 9126
single-use `request_uri`) reproduces it identically at 4 of 8. That is outside
X2's scope and is reported separately rather than fixed here.

Verified: axiam-core 111 passed, permission_ticket_test 10 passed and stable
across four consecutive runs, clippy -D warnings and rustfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu
A multi-statement query is not atomic in SurrealDB — each statement runs in
its own transaction — so an `UPDATE ... WHERE <precondition> ... RETURN BEFORE`
wrapped as `LET $before = (...); SELECT ... FROM $before` does not serialise
concurrent callers, despite the doc comments on both sites saying exactly one
can win. Measured with eight concurrent callers on one row:

  device_grant.redeem       4 of 8 won   -> one user approval, four token sets
  pushed_auth_request       2 of 8 won   -> a replayable RFC 9126 request_uri

Wrapping each in BEGIN/COMMIT makes the datastore detect the write-write
conflict and abort the losers. That abort is not a fault — it is the answer
these methods exist to give — so `is_transaction_conflict` (new, in helpers)
translates it to "no row consumed" rather than letting a correctly-refused
replay surface as a 500. Both now yield exactly one winner and zero errors.

The other two sites I suspected are NOT affected, and the tests say so rather
than my reading of them:

  oauth2_auth_code.consume  — safe. One statement, which SurrealDB does
    execute atomically. The losers get NotFound.
  refresh-token rotation    — safe, by a different route. `revoke` is also a
    single statement, and the service compensates on a lost race: the loser's
    revoke returns NotFound, so it revokes the token it just minted and answers
    invalid_grant instead of handing over a second live token set.

Both are pinned by tests anyway, because "safe today" and "safe after the next
refactor to the LET/SELECT shape" are different claims.

The distinguishing factor is statement count, not the WHERE guard — which is
worth knowing before writing the next single-use consume.

Verified: single_use_concurrency_test 4 passed and stable across three
consecutive runs; axiam-db lib 144, device_grant_test 11,
permission_ticket_test 10, oauth2_refresh_token_gaps_test 7, axiam-oauth2 lib
75; clippy -D warnings and rustfmt clean.

Measured against the in-memory engine the tests use. The fix is correct
regardless of backend, but the pre-fix severity on a production datastore has
not been measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu
The UMA service: mint a ticket for what a resource server requires, and
exchange it for an RPT's contents. 14 tests.

Evaluation is the same check a live request makes, which is what makes B1
deny-override apply to an RPT for free — a DeniedByRule outcome on any pair
refuses the exchange, and the test for it asserts the requirement X2 states.

Three decisions worth naming.

**The ticket is consumed before anything is evaluated.** Evaluating first
would let two concurrent redemptions both pass and both mint, which is exactly
what single-use exists to stop. Consuming first also means a replay costs no
engine round trips, and a caller cannot distinguish rejection reasons by
timing how long the refusal took.

**Partial grants are refused whole, never trimmed.** If a ticket names two
pairs and the engine allows one, the answer is access_denied rather than a
one-pair RPT. A trimmed RPT makes its own contents depend on evaluation order
and hands the client a token that silently does less than it asked for. UMA's
own answer to this is claims-gathering, which v1 defers.

**Scope names are validated before the ticket exists.** A ticket naming an
undeclared scope could never be redeemed, so minting one would hand back a
credential guaranteed to fail sixty seconds later instead of an error the
resource server can act on now. The test asserts no row is written.

The evaluator and the scope catalogue are traits declared here rather than
imports: axiam-oauth2 does not depend on axiam-authz, and axiam-api-rest is
the composition root that owns both. They are separate traits because they
answer different questions — *could* this scope be asked for, versus *may*
this subject have it — and conflating them would make an undeclared scope
indistinguishable from a denied one.

Every ticket rejection is one message on the wire. Unknown, consumed, expired
and wrong-client are distinguishable only in the audit trail; a client that
could tell them apart could probe for live handles.

Verified: axiam-oauth2 lib 88 passed (14 new), axiam-core 111, clippy
-D warnings and rustfmt clean.

Still to come in X2: the REST surface (/uma2/perm, rreg CRUD, discovery), the
introspection extension, and the SDK contract §17 fan-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu
CI's Test job died linking this crate's tests:

  collect2: fatal error: ld terminated with signal 7 [Bus error], core dumped

with RabbitMQ reporting 48 MB free on the same runner at the same second.
That is disk exhaustion, not a defect: ld mmaps its output, the write cannot
allocate, and touching the mapped page faults as SIGBUS rather than surfacing
a clean ENOSPC. There were zero test failures, zero panics and zero compile
errors in the log.

Not my code's fault, but my code's weight. Each integration-test file is a
separate linked binary, axiam-db already had 39 of them, and this PR added
two more. The runner's "Free up disk space" step already prunes dotnet,
Android and the tool cache, so the headroom that remained was what the new
binaries consumed.

Both new files are mine and both are about the same property — that a
single-use consume serialises — so they share a binary now instead of taking
one each. 39 back down to 38, all 14 tests kept, no coverage lost.

This trims the marginal cost rather than solving the underlying one: a crate
whose test suite links 38 binaries will hit this again on the next couple of
additions, and the fix for that is a CI config change (a larger runner, or
sharding the test job) rather than something to smuggle into a feature PR.

Verified: permission_ticket_test 14 passed and stable across three
consecutive runs, clippy -D warnings and rustfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ubrFbqsMkBqC5gwadPsDu

Copy link
Copy Markdown
Owner Author

Blocked: the Test job is out of disk, not failing tests

Both runs of Test on this branch died the same way, with no test failures, no panics and no compile errors anywhere in the log:

collect2: fatal error: ld terminated with signal 7 [Bus error], core dumped
##[error]Process completed with exit code 101

with the RabbitMQ service container logging the disk alarm at the same second:

Run Head Free bytes at failure
1 e8da8349 48,906,240
2 b826883e 0

ld mmaps its output; when the write cannot allocate, touching the mapped page faults as SIGBUS rather than surfacing a clean ENOSPC. Every other check on this PR is green, including E2E.

Why it is this PR that tips it

cargo test --workspace --no-fail-fast links every test target in the workspace with no cleanup between crates. Each integration-test file is its own binary. main has 37 in axiam-db; this branch had 39, now 38 after folding my two new suites into one (b826883e).

So main passes with essentially no margin, and one extra binary is the difference. That trim bought less than it needed to — the second run reached zero bytes free, so the shortfall is larger than one binary's worth.

Not something this PR should fix

I am not going to keep deleting test binaries to buy a few hundred MB, and I do not think a shared CI-config change belongs smuggled into a feature PR. The options, roughly in order of how much headroom they buy:

  1. Shard the Test job per crate (or per group), so no single job links the whole workspace at once. Most headroom, most config churn.
  2. [profile.test] debug = 0 (or "line-tables-only") in the workspace manifest. Debug info dominates test binary size, so this is the biggest single win for the least YAML — at the cost of poorer backtraces for everyone.
  3. A larger runner for this job.
  4. Free more of the image in the existing "Free up disk space" step — /usr/share/swift, /usr/local/lib/node_modules, /opt/az are the remaining large items. Cheapest, and buys the least; likely postpones rather than solves.

Tracked separately so it does not get lost behind this PR. Happy to implement whichever is preferred, in its own PR.

The code in this PR is unaffected by any of the above: axiam-core 111, axiam-oauth2 88, axiam-db lib 144, permission_ticket_test 14 (stable across three consecutive runs), clippy -D warnings and rustfmt all clean locally.


Generated by Claude Code

claude added 2 commits August 11, 2026 06:55
…r disk

The Test job failed with `ld terminated with signal 7 [Bus error], core
dumped` while linking grpc_userinfo_test. That is an out-of-disk write,
not a toolchain bug: the RabbitMQ service container logged "Free disk
space is insufficient. Free bytes: 0" in the same second.

The failure walks. Across the last two runs on this branch, `cargo test
--workspace` passed both times and the job then died at a *different*
trailing step each time — grpc_authz_test in one, grpc_userinfo_test in
the next. Those two steps relink axiam-api-grpc under `--features
client`, on top of a target/ already holding the whole workspace test
build, so the job simply finishes at the disk ceiling and which step
lands past it is luck. main sits just under the same ceiling and passes.

Both settings cut target/ rather than buying time:

  CARGO_INCREMENTAL=0        incremental artifacts are pure waste on a
                             one-shot runner that never rebuilds
  *_DEBUG=line-tables-only   debuginfo is the largest contributor to
                             test-binary size; line-tables-only keeps
                             file and line in panic backtraces, which
                             debug=0 would drop

Measured on axiam-core's test build: 368M -> 240M (-35%). The saving is
proportionally larger on axiam-api-rest and axiam-api-grpc, which carry
far more integration binaries.

Also reports df/du before the two feature-gated steps, so the next time
this ceiling is reached it reads as a disk number in the log rather than
as a linker signal that has to be correlated against a service
container's alarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015P5QahkVjqoBqz83H2Gqvc
…alDB to

`consume` marked a ticket single-use by putting the precondition in a `WHERE`
clause and wrapping it in `BEGIN`/`COMMIT`, on the understanding that the
datastore would detect the write-write conflict and abort every loser. It does
not do so reliably. Measured over 1200 rounds of 8 concurrent redemptions, 8
rounds saw two transactions *both* commit — zero errors on either, both
returning the pre-transition row. That is two RPTs from one authorization
decision, and CI caught it as an intermittent failure of
`concurrent_redemptions_yield_exactly_one_winner`.

Each attempt now stamps a per-attempt nonce and reads the row back; the caller
whose nonce persisted is the redemption. Nothing depends on conflict detection
or on constraint enforcement, which are the two things this engine was measured
not to provide.

Two repairs were measured and rejected first, both of which look more obvious
than this one:

  mechanism                                   wrong (this consume path)
  -----------------------------------------   -------------------------
  before: transaction + WHERE consumed=false   1 / 320
  claim keyed on a record ID                  30 / 1200  (isolated probe)
  claim on a UNIQUE INDEX, in a transaction    3 / 320
  after:  per-attempt nonce, write then read   1 / 640

Stated plainly, because the comments this replaces were confident and wrong:
1/640 against 1/320 is one event either way and does **not** show the nonce is
less likely to double-redeem. It shows it is not worse. It is preferred because
its residual window is nameable — a write landing after another racer's
read-back — where a silent failure to detect a conflict is not.

No mechanism tested reaches zero, so single-use is not guaranteed here. The
schema comment records what closing the window would actually take: a guarantee
from below this layer. The prior comments asserted a guarantee that did not
exist, which is the more dangerous state for whoever writes the next single-use
consume.

The read-back is deliberately outside a transaction. Inside one it would see the
caller's own write under snapshot isolation and every racer would believe it
won, turning an occasional double-redemption into a certain one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015P5QahkVjqoBqz83H2Gqvc
@ilpanich
ilpanich merged commit c328206 into main Aug 11, 2026
21 checks passed

Copy link
Copy Markdown
Owner Author

Correction (post-merge): the single-use fix in this PR does not hold

The description above says of device_grant.redeem and pushed_auth_request.consume:

The fix is BEGIN/COMMIT so the datastore detects the write-write conflict […] Both fixed sites now yield exactly one winner and zero errors.

That is not true, and the mechanism it rests on does not work. SurrealDB 3.2.3 does not reliably detect the write-write conflict. Measured on permission_ticket.consume — the same shape as both of these — 8 rounds in 1200 (8 racers each) saw two transactions both commit, with zero errors on either, both returning the pre-transition row. Two RPTs from one authorization decision.

CI caught this as an intermittent failure of concurrent_redemptions_yield_exactly_one_winner, and 0fa8897 (in this PR) already replaced the ticket path with a per-attempt nonce that does not ask the engine to arbitrate. The other two sites were left on the old mechanism and are addressed in #301.

Two repairs that look more obvious than the nonce were measured and rejected, both worse than the defect:

Mechanism Wrong
Transaction + WHERE consumed = false (as merged here) 1 / 320
Claim keyed on a record ID 30 / 1200
Claim on a UNIQUE INDEX, inside a transaction 3 / 320
Per-attempt nonce (0fa8897) 1 / 640

Stated carefully: 1/640 against 1/320 is one event either way and does not show the nonce double-redeems less often — only that it is not worse. It is preferred because its residual window is nameable (a write landing after another racer's read-back), where a silent failure to detect a conflict is not analysable.

Single-use is not guaranteed anywhere in this tree. No mechanism tested reaches zero. Closing the window needs a guarantee from below this layer — a storage engine that serialises, or single-writer serialisation in front of it. SCHEMA_V31 carries the full write-up.

Two further notes for anyone relying on the description above:

  • The claim that authorization-code consume and refresh-token rotation are "safe already" rests on the same reasoning about statement count and atomicity that turned out not to hold here. They were not re-measured under the harness that exposed the ticket race, so treat them as unverified rather than confirmed.
  • "The distinguishing factor is statement count, not the WHERE guard" should be read as withdrawn. Statement count was not the distinguishing factor; the engine's conflict detection is simply unreliable under contention regardless.

Generated by Claude Code

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.

2 participants