Skip to content

feat(audit-registry): define and enforce the cross-contract authorization pattern - #83

Merged
joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
Alheriii:feat/cross-contract-authz-74
Aug 24, 2026
Merged

joelpeace48-cell merged 1 commit into
FinesseStudioLab:mainfrom
Alheriii:feat/cross-contract-authz-74

Conversation

@Alheriii

Copy link
Copy Markdown
Contributor

Closes #74

Summary

Fixes 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. The pattern is written up in docs/cross-contract-authorization.md.

The problem, concretely

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, 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.

The pattern

Every entrypoint reachable cross-contract applies three checks:

# Check Guards against
1 subject.require_auth_for_args(...) over the arguments that determine the effect An intermediate spending the user's signature on different arguments
2 caller.require_auth() when caller != subject A contract naming a different contract as the caller
3 caller present in the admin-managed allowlist An arbitrary contract relaying for users at all

None 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_auth on 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 calling authorize_as_current_contract would 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_attestation is therefore authorized by the subject.

Reads are deliberately not gated

get_attestation and verify_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. 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:

tx root: usage-meter.record_usage(...)
└── sub-invocation: audit-registry.submit_attestation(caller=M, subject=U, ...)
    ├── auth: U  →  require_auth_for_args([id, model, policy])   (signed by the user)
    └── auth: M  →  require_auth()                                (implicit: M is the invoker)
        └── allowlist: M enrolled by admin

The user's entry is rooted at the audit-registry invocation, not at usage-meter's — the user authorizes the effect, not the route to it. caller is 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 every require_auth in the transaction, so it 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. Two stand-in intermediates model how usage-meter will 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 Symbol admin placeholder becomes a real Address that 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().publish is deprecated in SDK 27 and CI runs with -D warnings, so the macro form is required rather than optional.

Migration note

initialize(admin: Symbol) becomes initialize(admin: Address), and the admin must sign. ping is gone. version returns 2.

Acceptance criteria

  • Authorization tree documented per cross-contract flow
  • Caller allowlists enforced in code
  • Tests without mock_all_auths covering escalation attempts
  • Pattern peer-reviewed and the review recorded

The 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.md ends 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/ plus docs/, #70 is usage-meter/ only, #73 is payment-router/ only. All six merge orders were tested locally against main — clean in every order, with fmt, clippy -D warnings, cargo test --workspace (45 tests) and the wasm32v1-none release build passing on the combined result.

usage-meter in #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.

…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.
@joelpeace48-cell
joelpeace48-cell merged commit d15690e into FinesseStudioLab:main Aug 24, 2026
2 checks passed
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.

Define the cross-contract authorization pattern before wiring contracts together

2 participants