Repository navigation
feat(audit-registry): define and enforce the cross-contract authorization pattern - #83
Merged
joelpeace48-cell merged 1 commit intoAug 24, 2026
Conversation
…tion pattern
Establishes the authorization rules for cross-contract calls before any
contract is wired to another, and implements them in audit-registry as the
reference for the edges still to come.
Soroban has no msg.sender. A callee cannot discover which contract invoked
it, so "only usage-meter may do this on a user's behalf" has to be built
rather than assumed. Two escalation paths follow from that, and neither is
visible under mocked auth:
- Authority laundering. A user signs once; an intermediate spends that
signature on different arguments than the user agreed to. Plain
require_auth cannot tell the difference.
- Standing theft. An unenrolled contract passes an enrolled contract's
address as the caller and inherits its permission.
Every entrypoint reachable cross-contract now applies three checks:
1. subject.require_auth_for_args over the arguments that determine the
effect, so a signature is spendable only on what it was given for
2. caller.require_auth when caller != subject, which a contract can only
satisfy for itself — require_auth on a contract address is implicit for
the immediate invoker alone, so naming another contract fails
3. an admin-managed caller allowlist, checked explicitly
None of the three is redundant, and dropping any one reopens a specific
attack. The rationale, the authorization tree for each flow, and the
require_auth vs require_auth_for_args decision are written up in
docs/cross-contract-authorization.md.
Authority is anchored on the subject — the party billed against the record —
never on the submitter. The submitter is stored for provenance and carries no
power: revoking a caller must not strip anyone of records already validly
created, and a former submitter must not keep the ability to invalidate them.
supersede_attestation is therefore authorized by the subject.
Reads are deliberately outside the allowlist. verify_attestation and
get_attestation return what the ledger already exposes, so gating them would
buy no confidentiality while coupling every future reader to an admin
transaction. usage-meter needs verify_attestation callable without enrolment.
Also replaces the Symbol admin placeholder with a real Address that signs for
its own installation, adds typed contract errors, TTL extension on every
persistent write and instance bump, and #[contractevent] types for the four
observable state changes.
Tests: 15, none of which call mock_all_auths — it approves every require_auth
in the transaction and would turn all of these green regardless of what the
contract does. Every signature is granted with mock_auths, scoped to one
address, one entrypoint and one argument list. Covered: replaying a signature
against different arguments, a third party submitting for a subject that never
signed, an unenrolled contract relaying a genuinely signed submission, an
enrolled contract forging for a subject that never signed, an unenrolled
contract naming an enrolled one as caller, revocation taking effect
immediately, and a submitter trying to invalidate a record it does not own.
fmt, clippy -D warnings, cargo test --workspace and the wasm32v1-none release
build all pass.
This was referenced Aug 24, 2026
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #74
Summary
Fixes the authorization rules for cross-contract calls before any contract is wired to another, and implements them in
audit-registryas the reference for the edges still to come. The pattern is written up indocs/cross-contract-authorization.md.The problem, concretely
Soroban has no
msg.sender. A callee cannot discover which contract invoked it, so "onlyusage-metermay do this on a user's behalf" has to be built rather than assumed. Two escalation paths follow, and neither is visible under mocked auth:require_authcannot tell the difference.The pattern
Every entrypoint reachable cross-contract applies three checks:
subject.require_auth_for_args(...)over the arguments that determine the effectcaller.require_auth()whencaller != subjectcallerpresent in the admin-managed allowlistNone is redundant. Without 1, an approved intermediate could take a signature given for one attestation and submit a different one. Without 2, any contract could pass an allowlisted address and inherit its standing. Without 3, the subject's signature alone would let any contract on the network relay.
Check 2 is the load-bearing, non-obvious one:
require_authon a contract address is satisfied implicitly only for the immediate invoker, which is exactly why claiming someone else's address fails. I wrote a test asserting the opposite first — that a contract not callingauthorize_as_current_contractwould be rejected — and it failed, because the invoker is auto-authorized. That test is now the impersonation case instead, which is the escalation that actually matters.Authority is anchored on the subject
The subject is the party billed against the record. The submitter is stored for provenance and carries no power: revoking a caller must not strip anyone of records already validly created, and a former submitter must not keep the ability to invalidate them.
supersede_attestationis therefore authorized by the subject.Reads are deliberately not gated
get_attestationandverify_attestationreturn what the ledger already exposes, so gating them would buy no confidentiality while coupling every future reader to an admin transaction.usage-meterneedsverify_attestationcallable without enrolment. The allowlist covers writes on behalf of a subject, and nothing else.Authorization trees
Both flows are documented in the doc file. In short, for the intermediate case:
The user's entry is rooted at the
audit-registryinvocation, not atusage-meter's — the user authorizes the effect, not the route to it.calleris deliberately excluded from the signed argument list, so a routing change does not force the subject to re-sign something they already agreed to.Tests
15, and none of them call
mock_all_auths. It approves everyrequire_authin the transaction, so it would turn all of these green regardless of what the contract does. Every signature is granted withmock_auths, scoped to one address, one entrypoint and one argument list. Two stand-in intermediates model howusage-meterwill call in.Escalation attempts covered: replaying a signature against different arguments; a third party submitting for a subject that never signed; an unenrolled contract relaying a genuinely signed submission; an enrolled contract forging for a subject that never signed; an unenrolled contract naming an enrolled one as
caller; revocation taking effect immediately; a submitter trying to invalidate a record it does not own.Also in this change
The scaffold's
Symboladmin placeholder becomes a realAddressthat signs for its own installation, plus typed contract errors, TTL extension on every persistent write and an instance bump, and#[contractevent]types for the four observable state changes.events().publishis deprecated in SDK 27 and CI runs with-D warnings, so the macro form is required rather than optional.Migration note
initialize(admin: Symbol)becomesinitialize(admin: Address), and the admin must sign.pingis gone.versionreturns 2.Acceptance criteria
mock_all_authscovering escalation attemptsThe last one explicitly requires review by someone who did not design it, so it cannot be closed from inside this change.
docs/cross-contract-authorization.mdends with an empty review-record table — please add the row rather than approving silently.Conflicts
This is one of three PRs. File sets are disjoint by crate: this one is
audit-registry/plusdocs/, #70 isusage-meter/only, #73 ispayment-router/only. All six merge orders were tested locally againstmain— clean in every order, withfmt,clippy -D warnings,cargo test --workspace(45 tests) and thewasm32v1-nonerelease build passing on the combined result.usage-meterin #70 declares its own#[contractclient]trait rather than depending on this crate, so nothing there needs changing when this merges — depending on the contract crate would pull this contract's generated WASM exports into that binary and collide at link time.