From 04f6afbbf90a4095e860c907a61d8402af0c43ba Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:52:03 -0300 Subject: [PATCH 1/3] feat: verify and enforce authorization and roles --- contracts/async-vault/src/lib.rs | 36 +++-- contracts/async-vault/src/test/wind_down.rs | 7 + contracts/identity-verifier/Cargo.toml | 3 + contracts/identity-verifier/src/lib.rs | 6 +- contracts/identity-verifier/src/test.rs | 55 ++++++++ contracts/nav-oracle/src/lib.rs | 6 +- contracts/nav-oracle/src/test.rs | 26 ++++ contracts/share-token/src/contract.rs | 6 +- contracts/share-token/src/test.rs | 145 ++++++++++++++++++-- docs/AUTHORIZATION_MATRIX.md | 122 ++++++++++++++++ docs/strata-product-and-architecture.md | 2 + 11 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 contracts/identity-verifier/src/test.rs create mode 100644 docs/AUTHORIZATION_MATRIX.md diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 1f92a45..1db8ecb 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -61,6 +61,12 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { + // The vault kit white-label topology specifies five distinct authorities: + // governance, manager, treasury, guardian, compliance, and attester. + // Although compliance is enforced on the identity verifier and attestation + // on the NAV oracle, the vault checks pairwise distinctness at construction + // time to prevent role misconfiguration across the kit ecosystem. Neither + // compliance nor attester is granted a direct role on the vault contract itself. if roles.treasury == roles.guardian || roles.treasury == roles.governance || roles.compliance == roles.governance @@ -159,12 +165,14 @@ impl AsyncVault { } #[only_admin] - pub fn set_custodian(e: &Env, custodian: Address, _caller: Address) { + pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { + caller.require_auth(); treasury::set_custodian(e, &custodian); } #[only_admin] - pub fn set_wind_down_delay(e: &Env, secs: u64, _caller: Address) { + pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { + caller.require_auth(); wind_down::set_delay(e, secs); } @@ -177,12 +185,14 @@ impl AsyncVault { } #[only_admin] - pub fn propose_wind_down(e: &Env, _caller: Address) { + pub fn propose_wind_down(e: &Env, caller: Address) { + caller.require_auth(); wind_down::propose(e); } #[only_admin] - pub fn cancel_wind_down_proposal(e: &Env, _caller: Address) { + pub fn cancel_wind_down_proposal(e: &Env, caller: Address) { + caller.require_auth(); wind_down::cancel_proposal(e); } @@ -221,7 +231,8 @@ impl AsyncVault { } #[only_admin] - pub fn set_notice(e: &Env, secs: u64, _caller: Address) { + pub fn set_notice(e: &Env, secs: u64, caller: Address) { + caller.require_auth(); if secs > MAX_NOTICE_SECS { panic_with_error!(e, VaultError::NoticeTooLong); } @@ -233,23 +244,27 @@ impl AsyncVault { } #[only_admin] - pub fn propose_upgrade(e: &Env, wasm_hash: BytesN<32>, _caller: Address) { + pub fn propose_upgrade(e: &Env, wasm_hash: BytesN<32>, caller: Address) { + caller.require_auth(); upgrade::propose_wasm(e, wasm_hash); } #[only_admin] - pub fn propose_upgrade_delay(e: &Env, secs: u64, _caller: Address) { + pub fn propose_upgrade_delay(e: &Env, secs: u64, caller: Address) { + caller.require_auth(); upgrade::propose_delay(e, secs); } #[only_admin] - pub fn cancel_upgrade(e: &Env, _caller: Address) { + pub fn cancel_upgrade(e: &Env, caller: Address) { + caller.require_auth(); upgrade::cancel(e); } #[only_admin] #[when_not_paused] - pub fn apply_upgrade(e: &Env, _caller: Address) { + pub fn apply_upgrade(e: &Env, caller: Address) { + caller.require_auth(); upgrade::apply(e); } @@ -360,7 +375,8 @@ impl Pausable for AsyncVault { } #[only_admin] - fn unpause(e: &Env, _caller: Address) { + fn unpause(e: &Env, caller: Address) { + caller.require_auth(); pausable::unpause(e); upgrade::on_unpause(e); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 0fd4b7b..e141597 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -107,6 +107,13 @@ fn governance_cancels_a_proposal_before_activation() { f.vault.set_wind_down_delay(&WEEK, &f.admin); f.vault.propose_wind_down(&f.admin); + let stranger = Address::generate(&f.e); + f.e.set_auths(&[]); + assert!(f.vault.try_cancel_wind_down_proposal(&stranger).is_err()); + assert!(f.vault.try_cancel_wind_down_proposal(&f.manager).is_err()); + assert!(f.vault.try_cancel_wind_down_proposal(&f.guardian).is_err()); + + f.e.mock_all_auths(); f.vault.cancel_wind_down_proposal(&f.admin); assert_eq!(f.vault.wind_down(), None); diff --git a/contracts/identity-verifier/Cargo.toml b/contracts/identity-verifier/Cargo.toml index 45cd6af..b6d90b6 100644 --- a/contracts/identity-verifier/Cargo.toml +++ b/contracts/identity-verifier/Cargo.toml @@ -14,3 +14,6 @@ soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 13fbf74..7c84490 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -30,7 +30,8 @@ impl IdentityVerifier { /// Adds or removes an account from the allowlist. #[only_admin] - pub fn allow(e: &Env, account: Address, allowed: bool, _caller: Address) { + pub fn allow(e: &Env, account: Address, allowed: bool, caller: Address) { + caller.require_auth(); e.storage() .persistent() .set(&DataKey::Allowed(account), &allowed); @@ -69,3 +70,6 @@ impl identity_verification::IdentityVerifier for IdentityVerifier { ) { } } + +#[cfg(test)] +mod test; diff --git a/contracts/identity-verifier/src/test.rs b/contracts/identity-verifier/src/test.rs new file mode 100644 index 0000000..b8929c7 --- /dev/null +++ b/contracts/identity-verifier/src/test.rs @@ -0,0 +1,55 @@ +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; +use stellar_tokens::rwa::identity_verification::IdentityVerifierClient as IdClient; + +#[test] +fn only_admin_can_allow() { + let e = Env::default(); + let admin = Address::generate(&e); + let stranger = Address::generate(&e); + let investor = Address::generate(&e); + + let id = e.register(IdentityVerifier, (admin.clone(),)); + let client = IdentityVerifierClient::new(&e, &id); + + // Initial state: not allowed. + assert!(!client.is_allowed(&investor)); + + // Stranger cannot allow. + assert!(client.try_allow(&investor, &true, &stranger).is_err()); + assert!(!client.is_allowed(&investor)); + + // Admin allows. + e.mock_all_auths(); + client.allow(&investor, &true, &admin); + assert!(client.is_allowed(&investor)); + + // Stranger cannot disallow. + e.set_auths(&[]); + assert!(client.try_allow(&investor, &false, &stranger).is_err()); + assert!(client.is_allowed(&investor)); + + // Admin disallows. + e.mock_all_auths(); + client.allow(&investor, &false, &admin); + assert!(!client.is_allowed(&investor)); +} + +#[test] +fn verify_identity_checks_allowlist() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let investor = Address::generate(&e); + + let id = e.register(IdentityVerifier, (admin.clone(),)); + let client = IdentityVerifierClient::new(&e, &id); + let id_client = IdClient::new(&e, &id); + + // Unallowed investor fails identity verification. + assert!(id_client.try_verify_identity(&investor).is_err()); + + // Once allowed, verification succeeds. + client.allow(&investor, &true, &admin); + id_client.verify_identity(&investor); +} diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 9014c27..60b3484 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -214,7 +214,8 @@ impl NavOracleContract { } #[only_admin] - pub fn set_ripcord(e: &Env, paused: bool, _caller: Address) { + pub fn set_ripcord(e: &Env, paused: bool, caller: Address) { + caller.require_auth(); state::set_ripcord(e, paused); RipcordSet { paused }.publish(e); } @@ -223,7 +224,8 @@ impl NavOracleContract { /// its distance from the last. Only while the ripcord is raised, so /// resuming is always a deliberate second act. #[only_admin] - pub fn clear_latest(e: &Env, _caller: Address) { + pub fn clear_latest(e: &Env, caller: Address) { + caller.require_auth(); if !ripcord_raised(e) { panic_with_error!(e, OracleError::RipcordNotRaised); } diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index ed3acf5..17fba73 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -249,6 +249,18 @@ fn set_config_rejects_a_zero_freshness_duration() { assert!(f.oracle.try_set_config(&cfg).is_err()); } +#[test] +fn only_admin_can_set_config() { + let f = setup(); + let cfg = config(); + + f.e.set_auths(&[]); + assert!(f.oracle.try_set_config(&cfg).is_err()); + + f.e.mock_all_auths(); + f.oracle.set_config(&cfg); +} + #[test] fn the_guardian_raises_the_ripcord_but_lowering_needs_governance() { let f = setup(); @@ -379,6 +391,20 @@ fn the_record_clears_only_while_the_ripcord_is_raised() { assert_eq!(f.oracle.nav_per_share(), SCALE * 50); } +#[test] +fn only_admin_can_clear_latest() { + let f = setup(); + let stranger = Address::generate(&f.e); + let r = report(&f.e, SCALE, 1, 1_000_000); + f.oracle.attest(&r, &f.attester); + f.oracle.raise_ripcord(&f.guardian); + + f.e.set_auths(&[]); + assert!(f.oracle.try_clear_latest(&stranger).is_err()); + assert!(f.oracle.try_clear_latest(&f.attester).is_err()); + assert!(f.oracle.try_clear_latest(&f.guardian).is_err()); +} + #[test] fn every_oracle_authority_is_readable_and_rotatable() { let f = setup(); diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 5e4b18a..c779e31 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -34,11 +34,13 @@ impl ShareToken { #[contractimpl(contracttrait)] impl Pausable for ShareToken { #[only_admin] - fn pause(e: &Env, _caller: Address) { + fn pause(e: &Env, caller: Address) { + caller.require_auth(); pausable::pause(e); } #[only_admin] - fn unpause(e: &Env, _caller: Address) { + fn unpause(e: &Env, caller: Address) { + caller.require_auth(); pausable::unpause(e); } } diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 2024173..9527e21 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -39,7 +39,19 @@ fn manager_mints_to_allowlisted_account() { assert_eq!(token.balance(&receiver), 100); } -fn setup(e: &Env) -> ShareTokenClient<'_> { +struct Fixture<'a> { + #[allow(dead_code)] + e: Env, + token: ShareTokenClient<'a>, + admin: Address, + manager: Address, + #[allow(dead_code)] + compliance: Address, + #[allow(dead_code)] + identity_verifier: Address, +} + +fn setup_fixture(e: &Env) -> Fixture<'_> { let admin = Address::generate(e); let manager = Address::generate(e); let compliance = Address::generate(e); @@ -50,13 +62,20 @@ fn setup(e: &Env) -> ShareTokenClient<'_> { ( String::from_str(e, "RWA Vault USDC"), String::from_str(e, "rwsUSDC"), - admin, - manager, - compliance, - identity_verifier, + admin.clone(), + manager.clone(), + compliance.clone(), + identity_verifier.clone(), ), ); - ShareTokenClient::new(e, &id) + Fixture { + e: e.clone(), + token: ShareTokenClient::new(e, &id), + admin, + manager, + compliance, + identity_verifier, + } } #[test] @@ -64,7 +83,117 @@ fn constructor_sets_7_decimals() { let e = Env::default(); e.mock_all_auths(); - let token = setup(&e); + let f = setup_fixture(&e); + assert_eq!(f.token.decimals(), 7); +} + +#[test] +fn unauthorized_caller_cannot_pause_or_unpause() { + let e = Env::default(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + + // Stranger and manager holding no admin role cannot pause. + assert!(f.token.try_pause(&stranger).is_err()); + assert!(f.token.try_pause(&f.manager).is_err()); + + e.mock_all_auths(); + f.token.pause(&f.admin); + assert!(f.token.paused()); + + // Stranger cannot unpause. + e.set_auths(&[]); + assert!(f.token.try_unpause(&stranger).is_err()); + assert!(f.token.try_unpause(&f.manager).is_err()); + + e.mock_all_auths(); + f.token.unpause(&f.admin); + assert!(!f.token.paused()); +} + +#[test] +fn unauthorized_caller_cannot_mint() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + let receiver = Address::generate(&e); + + // Only manager holds the mint role; stranger and admin are refused. + assert!(f.token.try_mint(&receiver, &100, &stranger).is_err()); + assert!(f.token.try_mint(&receiver, &100, &f.admin).is_err()); +} + +#[test] +fn unauthorized_caller_cannot_burn() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + let user = Address::generate(&e); + + assert!(f.token.try_burn(&user, &100, &stranger).is_err()); + assert!(f.token.try_burn(&user, &100, &f.admin).is_err()); +} + +#[test] +fn unauthorized_caller_cannot_forced_transfer() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + + assert!(f + .token + .try_forced_transfer(&from, &to, &100, &stranger) + .is_err()); + assert!(f + .token + .try_forced_transfer(&from, &to, &100, &f.admin) + .is_err()); +} + +#[test] +fn unauthorized_caller_cannot_freeze_or_recover() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + let user = Address::generate(&e); + let new_user = Address::generate(&e); + + assert!(f + .token + .try_set_address_frozen(&user, &true, &stranger) + .is_err()); + assert!(f + .token + .try_freeze_partial_tokens(&user, &100, &stranger) + .is_err()); + assert!(f + .token + .try_unfreeze_partial_tokens(&user, &100, &stranger) + .is_err()); + assert!(f + .token + .try_recover_balance(&user, &new_user, &stranger) + .is_err()); +} + +#[test] +fn unauthorized_caller_cannot_set_compliance_or_verifier() { + let e = Env::default(); + e.mock_all_auths(); + let f = setup_fixture(&e); + let stranger = Address::generate(&e); + let new_comp = Address::generate(&e); + let new_verifier = Address::generate(&e); - assert_eq!(token.decimals(), 7); + assert!(f.token.try_set_compliance(&new_comp, &stranger).is_err()); + assert!(f + .token + .try_set_identity_verifier(&new_verifier, &stranger) + .is_err()); } diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md new file mode 100644 index 0000000..b50c83c --- /dev/null +++ b/docs/AUTHORIZATION_MATRIX.md @@ -0,0 +1,122 @@ +# Authorization & Access Control Matrix + +This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`), satisfying the audit requirements of Issue #78. + +--- + +## 1. Architectural Principles + +1. **Direct Role Membership Checks ($O(1)$)**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. +2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. +3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. +4. **Five Separate Authorities**: + - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency ripcord reset. + - **Manager (Vault)**: Closes epochs. + - **Treasury**: Deploys free reserve assets to custodian. + - **Guardian**: Emergency responder; pauses vault entries, raises oracle ripcord. + - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. + - **Compliance / Identity**: External registry managing allowlists and transfer restrictions. + +--- + +## 2. The Dual `manager` Roles Distinction + +The symbol `"manager"` names two completely distinct roles living on separate contracts with separate privileges: + +| Contract | Role Symbol | Held By | Scope & Privileges | +|---|---|---|---| +| **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | +| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions), `forced_transfer` (escrow redemptions), and compliance operations. | + +### Deployment Script Safety +In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: +- The vault's constructor receives `roles.manager = accounts.manager.publicKey()`. +- The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`. +- A misconfiguration attempting to grant `accounts.manager` the token manager role is prevented by verification assertions checking `token.has_role({ account: vault, role: "manager" }) == true`. + +--- + +## 3. Vault Constructor Authority Handling + +`AsyncVault::__constructor` accepts `VaultRoles`, which includes `governance`, `manager`, `treasury`, `guardian`, `compliance`, and `attester`. +- **Why `compliance` and `attester` are kept in the constructor**: + The vault kit enforces an end-to-end separation of duties. At construction, the vault validates pairwise distinctness: + `treasury != guardian`, `treasury != governance`, `compliance != governance`, and `compliance != treasury`. + Although compliance is enforced on `IdentityVerifier` / `Compliance` and attestation on `NavOracle`, the vault records and validates the full 5-authority topology at instantiation time to prevent cross-contamination of duties. Neither `compliance` nor `attester` is granted a direct role on the vault itself. + +--- + +## 4. Open Entrypoints and Design Rationale + +The following entrypoints are open to any caller on purpose: + +| Contract | Entrypoint | Authorization | Rationale | +|---|---|---|---| +| **`AsyncVault`** | `fund` | `from.require_auth()` | Anyone (treasury, LP, custodian, donor) may fund the vault with settlement assets. `from.require_auth()` ensures that the sender explicitly approves transferring their own assets into the vault. Receiving capital is non-privileged and always safe. | +| **`AsyncVault`** | `fulfill_epoch` | Open (Any caller) | Pricing is deterministic once an epoch is closed and the oracle publishes a valid attestation. Permissionless fulfillment prevents a malicious operator from stalling pricing or censoring settlements. Guarded by `#[when_not_paused]` and oracle validity. | +| **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | +| **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | +| **`AsyncVault`** | `claim_wind_down` | Open (for `holder`) | Callable by the holder or a third-party keeper acting on the holder's behalf; payouts always go directly to `holder`. | +| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused` and not winding down. | +| **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | +| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | +| **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | +| **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | +| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit before epoch pricing. Returns escrowed shares. | + +--- + +## 5. Complete Access Control Matrix Across All 4 Contracts + +### 5.1 AsyncVault (`contracts/async-vault`) + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `close_epoch` | `#[only_role(caller, "manager")]` | Epoch must be open | `test::epochs::admin_cannot_close_epoch`, `test::epochs::unauthorized_close_epoch_fails` | +| `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_without_treasury_role_is_refused` | +| `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::the_guardian_pauses_and_unauthorised_cannot` | +| `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | +| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | None | `test::treasury::setting_the_custodian_needs_the_admin_signature` | +| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay` | `test::notice::setting_the_notice_needs_governance_authorisation` | +| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_WIND_DOWN_DELAY`, not active | `test::wind_down::only_governance_sets_the_delay` | +| `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | +| `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | +| `propose_upgrade` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, delay >= notice | `test::upgrade::only_governance_proposes_and_cancels` | +| `propose_upgrade_delay` | `#[only_admin]`, `caller.require_auth()` | `secs in [MIN, MAX]`, `secs >= notice` | `test::upgrade::only_governance_proposes_and_cancels` | +| `cancel_upgrade` | `#[only_admin]`, `caller.require_auth()` | Proposal must stand | `test::upgrade::only_governance_proposes_and_cancels` | +| `apply_upgrade` | `#[only_admin]`, `caller.require_auth()` | Timelock expired, `when_not_paused` | `test::upgrade::only_governance_applies` | +| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::upgrade::the_vault_admin_cannot_renounce` | + +### 5.2 ShareToken (`contracts/share-token`) + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `pause` | `#[only_admin]`, `caller.require_auth()` | Not paused | `test::unauthorized_caller_cannot_pause_or_unpause` | +| `unpause` | `#[only_admin]`, `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause` | +| `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint` | +| `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | +| `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer` | +| `set_address_frozen` | `#[only_role(operator, "manager")]` | None | `test::unauthorized_caller_cannot_freeze_or_recover` | +| `freeze_partial_tokens` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover` | +| `unfreeze_partial_tokens`| `#[only_role(operator, "manager")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover` | +| `recover_balance` | `#[only_role(operator, "manager")]` | Target allowlisted | `test::unauthorized_caller_cannot_freeze_or_recover` | +| `set_compliance` | `#[only_role(operator, "manager")]` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier` | +| `set_identity_verifier` | `#[only_role(operator, "manager")]` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier` | + +### 5.3 NavOracle (`contracts/nav-oracle`) + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `attest` | `#[only_role(caller, "attester")]` | Cooldown, deviation cap, bounds `[min, max]` | `test::attest_is_role_gated` | +| `raise_ripcord` | `#[only_role(caller, "guardian")]` | None | `test::raising_the_ripcord_is_limited_to_the_guardian` | +| `set_ripcord` | `#[only_admin]`, `caller.require_auth()` | None | `test::the_guardian_raises_the_ripcord_but_lowering_needs_governance` | +| `clear_latest` | `#[only_admin]`, `caller.require_auth()` | Ripcord must be raised | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_ripcord_is_raised` | +| `set_config` | `#[only_admin]`, `caller.require_auth()` | Valid bounds | `test::only_admin_can_set_config` | +| `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away` | + +### 5.4 IdentityVerifier (`contracts/identity-verifier`) + +| Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | +|---|---|---|---| +| `allow` | `#[only_admin]`, `caller.require_auth()` | None | `test::only_admin_can_allow` | +| `verify_identity` | Open check (Internal hook) | `is_allowed(account) == true` | `test::verify_identity_checks_allowlist` | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 84496d9..db604f3 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -86,6 +86,8 @@ or legal solution, and not a vault for on-chain RWA tokens. Five authorities in code, held by native Stellar multisig accounts, assignable at deploy; one account may hold several. +For the exhaustive access control matrix, dual `manager` role distinction, and entrypoint-level test mapping, see [`docs/AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md). + ## 6. How it works 1. Compliance allowlists a verified investor. From 3078801c5f3c217b16a2d028bc2f442dd6e9ac52 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:04:05 -0300 Subject: [PATCH 2/3] docs: clean authorization matrix phrasing and references --- docs/AUTHORIZATION_MATRIX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index b50c83c..b1f4c24 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -1,12 +1,12 @@ # Authorization & Access Control Matrix -This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`), satisfying the audit requirements of Issue #78. +This document provides the complete authorization and role model specification for the Strata Vault Kit across all four contracts (`AsyncVault`, `ShareToken`, `NavOracle`, and `IdentityVerifier`). --- ## 1. Architectural Principles -1. **Direct Role Membership Checks ($O(1)$)**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. +1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. 3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. 4. **Five Separate Authorities**: From 0e46a0d1e0e91fba8b0fc81844a68767c5d2f0dc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:18:31 -0300 Subject: [PATCH 3/3] docs: match the authorization matrix to the code Fix the set_config and claim_wind_down rows, cite the refusal tests that exist, record that the share token keeps the default renounce_admin, and state what the deploy script actually checks. --- docs/AUTHORIZATION_MATRIX.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index b1f4c24..e860113 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -32,7 +32,7 @@ The symbol `"manager"` names two completely distinct roles living on separate co In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: - The vault's constructor receives `roles.manager = accounts.manager.publicKey()`. - The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`. -- A misconfiguration attempting to grant `accounts.manager` the token manager role is prevented by verification assertions checking `token.has_role({ account: vault, role: "manager" }) == true`. +- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. It does not check that no other account holds it. --- @@ -56,7 +56,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `fulfill_epoch` | Open (Any caller) | Pricing is deterministic once an epoch is closed and the oracle publishes a valid attestation. Permissionless fulfillment prevents a malicious operator from stalling pricing or censoring settlements. Guarded by `#[when_not_paused]` and oracle validity. | | **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | -| **`AsyncVault`** | `claim_wind_down` | Open (for `holder`) | Callable by the holder or a third-party keeper acting on the holder's behalf; payouts always go directly to `holder`. | +| **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | | **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused` and not winding down. | | **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | | **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | @@ -72,9 +72,9 @@ The following entrypoints are open to any caller on purpose: | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| -| `close_epoch` | `#[only_role(caller, "manager")]` | Epoch must be open | `test::epochs::admin_cannot_close_epoch`, `test::epochs::unauthorized_close_epoch_fails` | -| `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_without_treasury_role_is_refused` | -| `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::the_guardian_pauses_and_unauthorised_cannot` | +| `close_epoch` | `#[only_role(caller, "manager")]` | Epoch must be open | `test::epochs::a_non_manager_cannot_close` | +| `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | +| `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | | `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | | `set_custodian` | `#[only_admin]`, `caller.require_auth()` | None | `test::treasury::setting_the_custodian_needs_the_admin_signature` | | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay` | `test::notice::setting_the_notice_needs_governance_authorisation` | @@ -85,7 +85,7 @@ The following entrypoints are open to any caller on purpose: | `propose_upgrade_delay` | `#[only_admin]`, `caller.require_auth()` | `secs in [MIN, MAX]`, `secs >= notice` | `test::upgrade::only_governance_proposes_and_cancels` | | `cancel_upgrade` | `#[only_admin]`, `caller.require_auth()` | Proposal must stand | `test::upgrade::only_governance_proposes_and_cancels` | | `apply_upgrade` | `#[only_admin]`, `caller.require_auth()` | Timelock expired, `when_not_paused` | `test::upgrade::only_governance_applies` | -| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::upgrade::the_vault_admin_cannot_renounce` | +| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | ### 5.2 ShareToken (`contracts/share-token`) @@ -102,6 +102,7 @@ The following entrypoints are open to any caller on purpose: | `recover_balance` | `#[only_role(operator, "manager")]` | Target allowlisted | `test::unauthorized_caller_cannot_freeze_or_recover` | | `set_compliance` | `#[only_role(operator, "manager")]` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier` | | `set_identity_verifier` | `#[only_role(operator, "manager")]` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier` | +| `renounce_admin` | OZ default: the admin can renounce | None | None. Unlike the vault and the oracle, the token does not refuse it; a paused token whose admin renounced could never be unpaused. | ### 5.3 NavOracle (`contracts/nav-oracle`) @@ -111,7 +112,7 @@ The following entrypoints are open to any caller on purpose: | `raise_ripcord` | `#[only_role(caller, "guardian")]` | None | `test::raising_the_ripcord_is_limited_to_the_guardian` | | `set_ripcord` | `#[only_admin]`, `caller.require_auth()` | None | `test::the_guardian_raises_the_ripcord_but_lowering_needs_governance` | | `clear_latest` | `#[only_admin]`, `caller.require_auth()` | Ripcord must be raised | `test::only_admin_can_clear_latest`, `test::the_record_clears_only_while_the_ripcord_is_raised` | -| `set_config` | `#[only_admin]`, `caller.require_auth()` | Valid bounds | `test::only_admin_can_set_config` | +| `set_config` | `#[only_admin]` (no caller argument; the stored admin signs) | Valid bounds | `test::only_admin_can_set_config` | | `renounce_admin` | Refused always (`OracleError::AdminRequired`) | None | `test::the_oracle_admin_cannot_renounce_itself_away` | ### 5.4 IdentityVerifier (`contracts/identity-verifier`)