feat(uma): X2a/X2b — UMA 2.0 ticket model, store and grant service; fix non-serialising single-use consumes - #292
Conversation
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
Blocked: the
|
| 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:
- Shard the
Testjob per crate (or per group), so no single job links the whole workspace at once. Most headroom, most config churn. [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.- A larger runner for this job.
- Free more of the image in the existing "Free up disk space" step —
/usr/share/swift,/usr/local/lib/node_modules,/opt/azare 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
…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
Correction (post-merge): the single-use fix in this PR does not holdThe description above says of
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 CI caught this as an intermittent failure of Two repairs that look more obvious than the nonce were measured and rejected, both worse than the defect:
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. Two further notes for anyone relying on the description above:
Generated by Claude Code |
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 AXIAMaction, not to AXIAM'sscope.UMA scopes are verbs; AXIAM's verb is
action, and AXIAM'sscopeis a sub-resource narrower that merely shares the word. Mapping ontoscopeis the natural-looking choice and is a privilege-escalation trap: aPermissionGrantwith emptyscope_idsmatches any requested scope, so one unscoped grant of whatever fixed action we invented would satisfy every scope on the resource at once — registering a newdeletescope 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:
client_idis in the consuming statement'sWHEREclause, 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.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 theWHERE consumed = falseguard does not serialise concurrent callers at all, despite both sites' doc comments saying exactly one can win.Measured, 8 concurrent callers on one row:
device_grant.redeempushed_auth_request.consumerequest_urioauth2_auth_code.consumeThe fix is
BEGIN/COMMITso the datastore detects the write-write conflict, plus a newis_transaction_conflicthelper 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:
revokeis also single-statement, and the service compensates on a lost race — the loser's revoke returnsNotFound, so it revokes the token it just minted and answersinvalid_grantinstead of handing over a second live token set. Both are pinned by tests anyway, because "safe today" and "safe after the next refactor to theLET/SELECTshape" are different claims.The distinguishing factor is statement count, not the
WHEREguard — worth knowing before the next single-use consume is written.X2b — grant service
Mints tickets and exchanges them for RPT contents.
The evaluator and scope catalogue are traits declared in the module, not imports:
axiam-oauth2does not depend onaxiam-authz, andaxiam-api-restis 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-corelib 111,axiam-oauth2lib 88 (14 new),axiam-dblib 144permission_ticket_test10 andsingle_use_concurrency_test4, both stable across repeated consecutive runsdevice_grant_test11,oauth2_refresh_token_gaps_test7-D warningsand rustfmt cleanConcurrency 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