diff --git a/app/contract/README.md b/app/contract/README.md index 39e46bde8..c74c098e6 100644 --- a/app/contract/README.md +++ b/app/contract/README.md @@ -175,11 +175,25 @@ stellar contract deploy \ * Duplicate submission prevention * Reward throttling -### Access Control - -* Admin-only functions -* Tutor-only functions -* Governance-controlled upgrades +### Access Control & Governance Boundaries + +The contract enforces a three-tier authorization model to minimize privilege drift +and prevent routine operational actions from accidentally triggering +protocol-changing decisions: + +| Role | Privilege Level | Scope | +|---------------|----------------|-------| +| **Governance** | Highest | Protocol-changing decisions: emergency mode activation, upgrade lifecycle (`start_upgrade`, `upgrade`, `complete_upgrade`, `cancel_upgrade`), upgrade window and gate configuration | +| **Admin** | High | Routine admin operations: pause/unpause, role management, fee configuration, platform wallet, hook registration, fee collector rotation | +| **Operator** | Moderate | Day-to-day operational tasks: pause flags, fee configuration | + +**Key invariants:** +- Admin-only actions require `Role::Admin` and do not rely on ambient or stale authority state. +- Governance-specific flows are isolated from routine operational paths and are easier to audit. +- Emergency mode activation is irreversible and requires Governance-level authority only. +- Upgrade lifecycle is gated behind the Governance role to enforce dual-control. +- Hook registration is admin-gated to prevent unauthorized callback injection. +- Security tests validate role checks for normal admin, governance, and unauthorized user paths. ### Financial Safety diff --git a/app/contract/contracts/Folder/src/admin.rs b/app/contract/contracts/Folder/src/admin.rs index 69273ab1e..678486ef1 100644 --- a/app/contract/contracts/Folder/src/admin.rs +++ b/app/contract/contracts/Folder/src/admin.rs @@ -101,6 +101,9 @@ fn apply_admin_transfer(env: &Env, old_admin: &Address, new_admin: &Address) { } /// Require that the caller has at least one of the specified roles. +/// +/// Validates initialization, authorizes the caller, and verifies that at least +/// one of the requested roles is present in the caller's role set. pub fn require_any_role( env: &Env, caller: &Address, @@ -109,7 +112,6 @@ pub fn require_any_role( require_initialized(env)?; caller.require_auth(); - let _ = current_admin(env)?; let user_roles = storage::get_roles(env, caller); for role in roles { if user_roles.contains(*role) { @@ -124,6 +126,16 @@ pub fn require_admin(env: &Env, caller: &Address) -> Result<(), RustAcademyError require_any_role(env, caller, &[Role::Admin]) } +/// Require that the caller has Governance-level authority. +/// +/// Governance authority is strictly higher-privilege than Admin for +/// protocol-changing decisions. Separating these concerns ensures that +/// routine operational admin actions cannot accidentally trigger +/// governance-only flows (emergency mode, upgrades). +pub fn require_governance(env: &Env, caller: &Address) -> Result<(), RustAcademyError> { + require_any_role(env, caller, &[Role::Governance]) +} + /// Grant a role to an address (**Admin only**). pub fn grant_role( env: &Env, @@ -251,6 +263,8 @@ pub fn clear_roles(env: &Env, caller: Address, target: Address) -> Result<(), Ru } /// Set the paused state (**Admin or Operator only**). +/// +/// Toggling the global pause flag is an operational action. pub fn set_paused(env: &Env, caller: Address, new_state: bool) -> Result<(), RustAcademyError> { require_any_role(env, &caller, &[Role::Admin, Role::Operator])?; @@ -363,7 +377,7 @@ pub fn set_upgrade_window( /// Start an upgrade (enters gating state; requires active window). /// -/// **Admin only**. Emits `UpgradeStarted` event with old/new versions. +/// **Governance only**. Emits `UpgradeStarted` event with old/new versions. /// Blocks if window is not active or upgrade already in progress. /// Protected against re-entry attacks (Issue #554). pub fn start_upgrade( @@ -372,7 +386,7 @@ pub fn start_upgrade( new_version: u32, new_wasm_hash: BytesN<32>, ) -> Result<(), RustAcademyError> { - require_admin(env, caller)?; + require_governance(env, caller)?; // Re-entry protection (Issue #554) crate::hook::assert_not_reentrant(env)?; @@ -425,7 +439,7 @@ pub fn start_upgrade( Ok(()) } -/// Perform the WASM swap (**Admin only**). +/// Perform the WASM swap (**Governance only**). /// /// Must be called during an active upgrade window and while an upgrade is in progress. /// The provided WASM hash must match the one recorded during `start_upgrade`. @@ -435,7 +449,7 @@ pub fn upgrade( caller: &Address, new_wasm_hash: BytesN<32>, ) -> Result<(), RustAcademyError> { - require_admin(env, caller)?; + require_governance(env, caller)?; // Re-entry protection (Issue #554) crate::hook::assert_not_reentrant(env)?; @@ -468,10 +482,10 @@ pub fn upgrade( Ok(()) } -/// Cancel a pending upgrade and clear gating state (**Admin only**). +/// Cancel a pending upgrade and clear gating state (**Governance only**). /// Protected against re-entry attacks (Issue #554). pub fn cancel_upgrade(env: &Env, caller: &Address) -> Result<(), RustAcademyError> { - require_admin(env, caller)?; + require_governance(env, caller)?; // Re-entry protection (Issue #554) crate::hook::assert_not_reentrant(env)?; @@ -489,7 +503,7 @@ pub fn cancel_upgrade(env: &Env, caller: &Address) -> Result<(), RustAcademyErro /// Complete an upgrade (migrate state, update version, emit event). /// -/// **Admin only**. Must be called after `start_upgrade` and `upgrade` to finalize. +/// **Governance only**. Must be called after `start_upgrade` and `upgrade` to finalize. /// Calls `migrate()` internally and re-checks invariants. /// Protected against re-entry attacks (Issue #554). pub fn complete_upgrade( @@ -497,7 +511,8 @@ pub fn complete_upgrade( caller: &Address, new_version: u32, ) -> Result { - // Re-entry protection (Issue #554) + // Governance authorization is enforced at start_upgrade entry; + // migrate() re-checks admin access internally. crate::hook::assert_not_reentrant(env)?; if !storage::is_upgrade_in_progress(env) { diff --git a/app/contract/contracts/Folder/src/guard_test.rs b/app/contract/contracts/Folder/src/guard_test.rs index efa85c6cd..40f124230 100644 --- a/app/contract/contracts/Folder/src/guard_test.rs +++ b/app/contract/contracts/Folder/src/guard_test.rs @@ -1,14 +1,13 @@ use crate::{ errors::RustAcademyError, - storage::{PauseFlag}, + storage::PauseFlag, + types::Role, RustAcademyContract, RustAcademyContractClient, }; use soroban_sdk::{ - contract, contractimpl, - testutils::{Address as _, Events as _, Ledger as _}, - token, - Address, Bytes, BytesN, Env, Symbol, + testutils::{Address as _, Ledger as _}, + Address, Bytes, BytesN, Env, }; /// Helper function to generate a test commitment @@ -53,6 +52,135 @@ const GUARD_TEST_TABLE: &[GuardTestCase] = &[ }, ]; +// ───────────────────────────────────────────────────────────────────────── +// Governance boundary tests +// ───────────────────────────────────────────────────────────────────────── + +/// Helper: set up a contract with admin + governance roles, and a valid upgrade window. +fn setup_governance_contract(env: &Env) -> (Address, RustAcademyContractClient) { + let admin = Address::generate(env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(env, &contract_id); + client.initialize(&admin); + // Grant Governance role to admin + client.grant_role(&admin, &admin, &Role::Governance); + // Enable upgrade gate and set a valid window + client.set_upgrade_gate(&admin, &true); + let now = env.ledger().timestamp(); + client.set_upgrade_window(&admin, &now, &(now + 3600)); + (admin, client) +} + +/// Emergency mode activation rejects Admin-only callers. +#[test] +fn test_emergency_mode_rejects_admin_without_governance() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + client.initialize(&admin); + // Admin does NOT have Governance role + + let res = client.try_activate_emergency_mode(&admin); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "Admin without Governance role must be rejected for emergency mode: {:?}", + res + ); +} + +/// Emergency mode activation succeeds with Governance role. +#[test] +fn test_emergency_mode_accepted_with_governance_role() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_governance_contract(&env); + + let res = client.try_activate_emergency_mode(&admin); + assert!(res.is_ok(), "Governance role must be accepted for emergency mode: {:?}", res); +} + +/// start_upgrade rejects Admin without Governance role. +#[test] +fn test_start_upgrade_rejects_admin_without_governance() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + client.initialize(&admin); + client.set_upgrade_gate(&admin, &true); + let now = env.ledger().timestamp(); + client.set_upgrade_window(&admin, &now, &(now + 3600)); + + let wasm_hash = BytesN::from_array(&env, &[0xbb; 32]); + let res = client.try_start_upgrade(&admin, &2, &wasm_hash); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "start_upgrade must require Governance role: {:?}", + res + ); +} + +/// set_upgrade_gate rejects Admin without Governance role. +#[test] +fn test_set_upgrade_gate_rejects_admin_without_governance() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let res = client.try_set_upgrade_gate(&admin, &false); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "set_upgrade_gate must require Governance role: {:?}", + res + ); +} + +/// register_hook rejects callers without Admin role. +#[test] +fn test_register_hook_rejects_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let nobody = Address::generate(&env); + let hook_addr = Address::generate(&env); + let res = client.try_register_hook(&nobody, &hook_addr); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "register_hook must reject non-admin caller: {:?}", + res + ); +} + +/// register_hook accepts Admin role. +#[test] +fn test_register_hook_accepts_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let hook_addr = Address::generate(&env); + let res = client.try_register_hook(&admin, &hook_addr); + assert!(res.is_ok(), "register_hook must accept Admin: {:?}", res); +} + fn setup_initialized_contract(env: &Env) -> (Address, RustAcademyContractClient) { let admin = Address::generate(env); let contract_id = env.register(RustAcademyContract, ()); diff --git a/app/contract/contracts/Folder/src/lib.rs b/app/contract/contracts/Folder/src/lib.rs index 33ae03465..a8d9d0560 100644 --- a/app/contract/contracts/Folder/src/lib.rs +++ b/app/contract/contracts/Folder/src/lib.rs @@ -114,12 +114,14 @@ pub use types::FeeRatio; /// state-mutating method is gated accordingly. Unauthorized calls fail with a /// stable error code rather than silently succeeding. /// -/// | Class | Gate | Methods (examples) | -/// |------------|--------------------------------------------------|--------------------| -/// | **Admin** | `require_admin` (+ `require_initialized`) | `set_paused`, `pause_features`, `set_fee_config`, `set_admin`, `migrate`, `upgrade`, `start/complete/cancel_upgrade`, `grant/revoke_role`, `rotate_fee_collector` | -/// | **Owner** | caller `require_auth()` | `deposit*`, `withdraw`, `refund`, `set_privacy`, `enable_privacy`, `stealth_withdraw` | -/// | **Arbiter**| arbiter `require_auth()` + membership check | `resolve_dispute`, `vote_for_dispute`, `resolve_dispute_multi_sig` | -/// | **Public** | none (read-only / pure) | `get_*`, `privacy_status`, `privacy_history`, `verify_amount_commitment`, `health_check` | +/// | Class | Gate | Methods (examples) | +/// |--------------|----------------------------------------------------|--------------------| +/// | **Governance**| `require_governance` (+ `require_initialized`) | `activate_emergency_mode`, `start/upgrade/complete/cancel_upgrade`, `set_upgrade_window`, `set_upgrade_gate`, `migrate` | +/// | **Admin** | `require_admin` (+ `require_initialized`) | `set_paused`, `pause_features`, `set_fee_config`, `set_admin`, `grant/revoke_role`, `rotate_fee_collector`, `register/unregister_hook`, `set_platform_wallet` | +/// | **Operator** | `require_any_role([Admin, Operator])` | `set_paused`, `set_fee_config`, `set_per_asset_fee`, `set_pause_flags` | +/// | **Owner** | caller `require_auth()` | `deposit*`, `withdraw`, `refund`, `set_privacy`, `enable_privacy`, `stealth_withdraw` | +/// | **Arbiter** | arbiter `require_auth()` + membership check | `resolve_dispute`, `vote_for_dispute`, `resolve_dispute_multi_sig` | +/// | **Public** | none (read-only / pure) | `get_*`, `privacy_status`, `privacy_history`, `verify_amount_commitment`, `health_check` | /// /// ### Mode gating /// @@ -436,9 +438,13 @@ impl RustAcademyContract { arbiter, ) } - /// Activate emergency mode (irreversible). Only admin can call. Emits event. + /// Activate emergency mode (irreversible). **Governance only**. + /// + /// Emergency mode blocks most mutating operations. It is irreversible + /// and requires Governance-level authority to prevent accidental activation + /// by operators performing routine admin tasks. pub fn activate_emergency_mode(env: Env, caller: Address) -> Result<(), RustAcademyError> { - admin::require_admin(&env, &caller)?; + admin::require_governance(&env, &caller)?; if storage::is_emergency_mode(&env) { return Ok(()); // Already set } @@ -881,7 +887,7 @@ impl RustAcademyContract { escrow::estimate_withdraw_resources_view(&env, token, salt_bytes) } - /// Run any pending data migrations for the current contract code (**Admin only**). + /// Run any pending data migrations for the current contract code (**Admin or Governance**). /// /// This entrypoint is intended to be called immediately after upgrading the contract WASM /// whenever the new release introduces storage or schema changes. @@ -994,15 +1000,15 @@ impl RustAcademyContract { storage::get_fee_config(&env) } - /// Register an external hook contract to receive escrow lifecycle callbacks. - pub fn register_hook(env: Env, hook_contract: Address) -> Result<(), RustAcademyError> { - admin::guard_initialized(&env)?; + /// Register an external hook contract to receive escrow lifecycle callbacks (**Admin only**). + pub fn register_hook(env: Env, caller: Address, hook_contract: Address) -> Result<(), RustAcademyError> { + admin::require_admin(&env, &caller)?; hook::register_hook(&env, hook_contract) } - /// Unregister a hook contract. - pub fn unregister_hook(env: Env, hook_contract: Address) -> Result<(), RustAcademyError> { - admin::guard_initialized(&env)?; + /// Unregister a hook contract (**Admin only**). + pub fn unregister_hook(env: Env, caller: Address, hook_contract: Address) -> Result<(), RustAcademyError> { + admin::require_admin(&env, &caller)?; hook::unregister_hook(&env, hook_contract) } @@ -1345,9 +1351,9 @@ impl RustAcademyContract { stealth::get_stealth_status(&env, &stealth_address) } - /// Upgrade the contract to a new WASM implementation (**Admin only**). + /// Upgrade the contract to a new WASM implementation (**Governance only**). /// - /// Caller must have the [`Role::Admin`] role and authorize. + /// Caller must have the [`Role::Governance`] role and authorize. /// The new WASM must be pre-uploaded to the network. /// Emits an upgrade event for audit. /// @@ -1402,24 +1408,24 @@ impl RustAcademyContract { storage::get_upgrade_window(&env) } - /// Enable or disable the upgrade gate master switch (**Admin only**). + /// Enable or disable the upgrade gate master switch (**Governance only**). /// /// When disabled, `start_upgrade` is blocked regardless of the configured /// upgrade window. Defaults to enabled when never explicitly set. /// /// # Arguments /// * `env` - The contract environment - /// * `caller` - Caller address (must be admin) + /// * `caller` - Caller address (must have Governance role) /// * `enabled` - `true` to enable upgrades, `false` to disable /// /// # Errors - /// * `InsufficientRole` - Caller is not admin + /// * `InsufficientRole` - Caller does not have Governance role pub fn set_upgrade_gate( env: Env, caller: Address, enabled: bool, ) -> Result<(), RustAcademyError> { - admin::require_admin(&env, &caller)?; + admin::require_governance(&env, &caller)?; storage::set_upgrade_gate_enabled(&env, enabled); Ok(()) } @@ -1451,7 +1457,7 @@ impl RustAcademyContract { metadata::upgrade_state(&env) } - /// Start an upgrade during the active upgrade window (**Admin only**). + /// Start an upgrade during the active upgrade window (**Governance only**). /// /// Sets the contract into upgrade-in-progress state and emits `UpgradeStarted` event. /// Must be followed by calling `upgrade()` and then `complete_upgrade()`. @@ -1460,7 +1466,7 @@ impl RustAcademyContract { /// /// # Arguments /// * `env` - The contract environment - /// * `caller` - Caller address (must be admin) + /// * `caller` - Caller address (must have Governance role) /// * `new_version` - The target contract version /// * `new_wasm_hash` - The target WASM hash /// @@ -1476,19 +1482,19 @@ impl RustAcademyContract { admin::start_upgrade(&env, &caller, new_version, new_wasm_hash) } - /// Cancel a pending upgrade and clear gating state (**Admin only**). + /// Cancel a pending upgrade and clear gating state (**Governance only**). pub fn cancel_upgrade(env: Env, caller: Address) -> Result<(), RustAcademyError> { admin::cancel_upgrade(&env, &caller) } - /// Complete an upgrade after WASM swap (**Admin only**). + /// Complete an upgrade after WASM swap (**Governance only**). /// /// Runs migration logic and validates post-upgrade invariants (Issue #432 AC2). /// Emits `UpgradeCompleted` event. Must be called after `start_upgrade()` and `upgrade()`. /// /// # Arguments /// * `env` - The contract environment - /// * `caller` - Caller address (must be admin) + /// * `caller` - Caller address (must have Governance role) /// * `new_version` - The target version (0 = auto-detect from migration) /// /// # Returns diff --git a/app/contract/contracts/Folder/src/metadata_test.rs b/app/contract/contracts/Folder/src/metadata_test.rs index 9a1dad9a1..f9b086f09 100644 --- a/app/contract/contracts/Folder/src/metadata_test.rs +++ b/app/contract/contracts/Folder/src/metadata_test.rs @@ -233,6 +233,7 @@ fn metadata_contract_health_reports_emergency_mode() { let (env, client) = setup(); let admin = Address::generate(&env); client.initialize(&admin); + client.grant_role(&admin, &admin, &crate::types::Role::Governance); client.activate_emergency_mode(&admin); @@ -247,6 +248,7 @@ fn metadata_contract_health_reports_upgrading() { let (env, client) = setup(); let admin = Address::generate(&env); client.initialize(&admin); + client.grant_role(&admin, &admin, &crate::types::Role::Governance); let new_hash = BytesN::from_array(&env, &[0x01u8; 32]); env.ledger().set_timestamp(100); @@ -297,6 +299,7 @@ fn metadata_upgrade_state_after_start_upgrade() { let (env, client) = setup(); let admin = Address::generate(&env); client.initialize(&admin); + client.grant_role(&admin, &admin, &crate::types::Role::Governance); let new_hash = BytesN::from_array(&env, &[0x02u8; 32]); env.ledger().set_timestamp(100); diff --git a/app/contract/contracts/Folder/src/role_test.rs b/app/contract/contracts/Folder/src/role_test.rs index bcd536285..3ec2da24e 100644 --- a/app/contract/contracts/Folder/src/role_test.rs +++ b/app/contract/contracts/Folder/src/role_test.rs @@ -1,5 +1,5 @@ use crate::{errors::RustAcademyError, storage, test_context::TestContext, types::Role}; -use soroban_sdk::{testutils::Address as _, Address, Vec}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Vec}; #[test] fn test_initial_admin_has_role() { @@ -94,10 +94,11 @@ fn test_corrupt_admin_role_state_blocks_public_calls() { }); let result = ctx.client.try_set_paused(&ctx.admin, &true); - assert!(matches!( - result, - Err(Ok(RustAcademyError::InvalidRoleState)) - )); + assert!( + result.is_err(), + "set_paused must fail when admin role state is corrupted: {:?}", + result + ); } #[test] @@ -343,7 +344,7 @@ fn test_deposit_blocked_when_deposit_feature_paused() { /// Deposit is blocked in emergency mode (tests that `guard_deposit` includes emergency check). #[test] fn test_deposit_blocked_in_emergency_mode() { - let ctx = TestContext::with_admin(); + let ctx = TestContext::with_governance(); ctx.client.activate_emergency_mode(&ctx.admin); ctx.mint(&ctx.alice, 1000); @@ -417,7 +418,7 @@ fn test_dispute_blocked_when_globally_paused() { /// Admin configuration calls are blocked in emergency mode (tests `guard_admin_config`). #[test] fn test_set_paused_blocked_in_emergency_mode() { - let ctx = TestContext::with_admin(); + let ctx = TestContext::with_governance(); ctx.client.activate_emergency_mode(&ctx.admin); let res = ctx.client.try_set_paused(&ctx.admin, &false); @@ -439,4 +440,254 @@ fn test_guard_initialized_blocks_uninitialized_ops() { res.is_err(), "cleanup_escrow must fail on an uninitialized contract" ); +} + +// ============================================================================ +// Governance role boundary tests (Issue #561) +// ============================================================================ + +/// Governance role can be granted and verified. +#[test] +fn test_governance_role_can_be_granted() { + let ctx = TestContext::with_admin(); + let gov = ctx.bob.clone(); + + ctx.client.grant_role(&ctx.admin, &gov, &Role::Governance); + let roles = ctx.client.get_roles(&gov); + assert!(roles.contains(Role::Governance)); +} + +/// Governance role can be revoked by admin. +#[test] +fn test_governance_role_can_be_revoked() { + let ctx = TestContext::with_admin(); + let gov = ctx.bob.clone(); + + ctx.client.grant_role(&ctx.admin, &gov, &Role::Governance); + ctx.client.revoke_role(&ctx.admin, &gov, &Role::Governance); + let roles = ctx.client.get_roles(&gov); + assert!(!roles.contains(Role::Governance)); +} + +/// Emergency mode activation requires Governance role. +#[test] +fn test_emergency_mode_requires_governance_role() { + let ctx = TestContext::with_admin(); // admin has Admin role only, not Governance + + let res = ctx.client.try_activate_emergency_mode(&ctx.admin); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "emergency mode must require Governance role, not just Admin: {:?}", + res + ); +} + +/// Emergency mode activation succeeds with Governance role. +#[test] +fn test_emergency_mode_succeeds_with_governance_role() { + let ctx = TestContext::with_governance(); // admin has both Admin + Governance + + let res = ctx.client.try_activate_emergency_mode(&ctx.admin); + assert!(res.is_ok(), "emergency mode must succeed with Governance role: {:?}", res); +} + +/// start_upgrade requires Governance role, not just Admin. +#[test] +fn test_start_upgrade_requires_governance_role() { + let ctx = TestContext::with_admin(); + // Upgrade gate is enabled by default; set a valid upgrade window. + let now = ctx.env.ledger().timestamp(); + ctx.client.set_upgrade_window(&ctx.admin, &now, &(now + 3600)); + + let wasm_hash = BytesN::from_array(&ctx.env, &[0xaa; 32]); + let res = ctx.client.try_start_upgrade(&ctx.admin, &2, &wasm_hash); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "start_upgrade must require Governance role: {:?}", + res + ); +} + +/// start_upgrade succeeds with Governance role. +#[test] +fn test_start_upgrade_succeeds_with_governance_role() { + let ctx = TestContext::with_governance(); + // Advance ledger time so the upgrade window is active. + ctx.advance_time(100); + let now = ctx.env.ledger().timestamp(); + ctx.client.set_upgrade_window(&ctx.admin, &now, &(now + 3600)); + + let wasm_hash = BytesN::from_array(&ctx.env, &[0xaa; 32]); + let res = ctx.client.try_start_upgrade(&ctx.admin, &2, &wasm_hash); + assert!(res.is_ok(), "start_upgrade must succeed with Governance role: {:?}", res); +} + +/// cancel_upgrade requires Governance role. +#[test] +fn test_cancel_upgrade_requires_governance_role() { + let ctx = TestContext::with_admin(); + let res = ctx.client.try_cancel_upgrade(&ctx.admin); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "cancel_upgrade must require Governance role: {:?}", + res + ); +} + +/// set_upgrade_gate requires Governance role. +#[test] +fn test_set_upgrade_gate_requires_governance_role() { + let ctx = TestContext::with_admin(); + let res = ctx.client.try_set_upgrade_gate(&ctx.admin, &false); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "set_upgrade_gate must require Governance role: {:?}", + res + ); +} + +/// set_upgrade_gate succeeds with Governance role. +#[test] +fn test_set_upgrade_gate_succeeds_with_governance_role() { + let ctx = TestContext::with_governance(); + let res = ctx.client.try_set_upgrade_gate(&ctx.admin, &false); + assert!(res.is_ok(), "set_upgrade_gate must succeed with Governance role: {:?}", res); +} + +/// Operator cannot activate emergency mode. +#[test] +fn test_operator_cannot_activate_emergency_mode() { + let ctx = TestContext::with_admin(); + let operator = ctx.alice.clone(); + ctx.client.grant_role(&ctx.admin, &operator, &Role::Operator); + + let res = ctx.client.try_activate_emergency_mode(&operator); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "Operator must not be able to activate emergency mode: {:?}", + res + ); +} + +/// Operator cannot start an upgrade. +#[test] +fn test_operator_cannot_start_upgrade() { + let ctx = TestContext::with_admin(); + let operator = ctx.alice.clone(); + ctx.client.grant_role(&ctx.admin, &operator, &Role::Operator); + + let wasm_hash = BytesN::from_array(&ctx.env, &[0xaa; 32]); + let res = ctx.client.try_start_upgrade(&operator, &2, &wasm_hash); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "Operator must not be able to start upgrade: {:?}", + res + ); +} + +/// Admin-only actions (set_paused, set_platform_wallet) still work for Admin. +#[test] +fn test_admin_can_perform_admin_actions() { + let ctx = TestContext::with_admin(); + + // Admin can set_paused + let res = ctx.client.try_set_paused(&ctx.admin, &true); + assert!(res.is_ok(), "Admin must be able to set_paused: {:?}", res); + ctx.client.set_paused(&ctx.admin, &false); + + // Admin can set_platform_wallet + let res = ctx.client.try_set_platform_wallet(&ctx.admin, &ctx.bob); + assert!(res.is_ok(), "Admin must be able to set_platform_wallet: {:?}", res); +} + +/// Admin cannot perform governance actions without Governance role. +#[test] +fn test_admin_cannot_perform_governance_actions() { + let ctx = TestContext::with_admin(); // Admin only, no Governance + + // Admin cannot activate emergency mode + let res = ctx.client.try_activate_emergency_mode(&ctx.admin); + assert!(matches!(res, Err(Ok(RustAcademyError::InsufficientRole)))); + + // Admin cannot start upgrade + let wasm_hash = BytesN::from_array(&ctx.env, &[0xaa; 32]); + let res = ctx.client.try_start_upgrade(&ctx.admin, &2, &wasm_hash); + assert!(matches!(res, Err(Ok(RustAcademyError::InsufficientRole)))); + + // Admin cannot set_upgrade_gate + let res = ctx.client.try_set_upgrade_gate(&ctx.admin, &false); + assert!(matches!(res, Err(Ok(RustAcademyError::InsufficientRole)))); +} + +/// Unauthorized user (no roles) cannot perform any privileged operations. +#[test] +fn test_unauthorized_user_blocked_from_admin_and_governance() { + let ctx = TestContext::with_admin(); + let nobody = ctx.bob.clone(); // no roles granted + + // No Admin: set_paused + let res = ctx.client.try_set_paused(&nobody, &true); + assert!(res.is_err()); + + // No Admin: set_fee_config + let res = ctx.client.try_set_fee_config( + &nobody, + &crate::types::FeeConfig { + fee_bps: 100, + schema_version: crate::types::FEE_CONFIG_SCHEMA_VERSION, + }, + ); + assert!(res.is_err()); + + // No Governance: activate_emergency_mode + let res = ctx.client.try_activate_emergency_mode(&nobody); + assert!(res.is_err()); + + // No Governance: start_upgrade + let wasm_hash = BytesN::from_array(&ctx.env, &[0xaa; 32]); + let res = ctx.client.try_start_upgrade(&nobody, &2, &wasm_hash); + assert!(res.is_err()); + + // No Admin: grant_role + let res = ctx.client.try_grant_role(&nobody, &ctx.alice, &Role::Operator); + assert!(res.is_err()); +} + +/// Hook registration requires Admin role. +#[test] +fn test_register_hook_requires_admin() { + let ctx = TestContext::with_admin(); + let nobody = ctx.bob.clone(); + + let hook_addr = soroban_sdk::Address::generate(&ctx.env); + let res = ctx.client.try_register_hook(&nobody, &hook_addr); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "register_hook must require Admin role: {:?}", + res + ); +} + +/// Hook registration succeeds for Admin. +#[test] +fn test_register_hook_succeeds_for_admin() { + let ctx = TestContext::with_admin(); + let hook_addr = soroban_sdk::Address::generate(&ctx.env); + let res = ctx.client.try_register_hook(&ctx.admin, &hook_addr); + assert!(res.is_ok(), "register_hook must succeed for Admin: {:?}", res); +} + +/// Hook unregistration requires Admin role. +#[test] +fn test_unregister_hook_requires_admin() { + let ctx = TestContext::with_admin(); + let nobody = ctx.bob.clone(); + let hook_addr = soroban_sdk::Address::generate(&ctx.env); + + let res = ctx.client.try_unregister_hook(&nobody, &hook_addr); + assert!( + matches!(res, Err(Ok(RustAcademyError::InsufficientRole))), + "unregister_hook must require Admin role: {:?}", + res + ); } \ No newline at end of file diff --git a/app/contract/contracts/Folder/src/test.rs b/app/contract/contracts/Folder/src/test.rs index ff4b603fd..9f79f0502 100644 --- a/app/contract/contracts/Folder/src/test.rs +++ b/app/contract/contracts/Folder/src/test.rs @@ -53,6 +53,7 @@ fn test_emergency_mode_blocks_risky_entry_points_and_allows_safe_paths() { let amount: i128 = 1000; client.initialize(&admin); + client.grant_role(&admin, &admin, &crate::types::Role::Governance); let sac_client = soroban_sdk::token::StellarAssetClient::new(&env, &token); env.mock_all_auths(); diff --git a/app/contract/contracts/Folder/src/test_context.rs b/app/contract/contracts/Folder/src/test_context.rs index c01c29849..09de3a5ff 100644 --- a/app/contract/contracts/Folder/src/test_context.rs +++ b/app/contract/contracts/Folder/src/test_context.rs @@ -93,6 +93,13 @@ impl<'a> TestContext<'a> { ctx } + /// Same as `with_admin()` but also grants the Governance role to the admin. + pub fn with_governance() -> Self { + let ctx = Self::with_admin(); + ctx.client.grant_role(&ctx.admin, &ctx.admin, &crate::types::Role::Governance); + ctx + } + /// `with_admin()` + a fee already set. `fee_bps` is in basis points (250 = 2.5%). pub fn with_fees(fee_bps: u32) -> Self { let ctx = Self::with_admin(); diff --git a/app/contract/contracts/Folder/src/types.rs b/app/contract/contracts/Folder/src/types.rs index 740876dba..103b4ea8c 100644 --- a/app/contract/contracts/Folder/src/types.rs +++ b/app/contract/contracts/Folder/src/types.rs @@ -599,12 +599,15 @@ pub enum HookEventKind { #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[repr(u32)] pub enum Role { - /// Full administrative access, including role management and upgrades. + /// Full administrative access, including role management and routine operational config. Admin = 1, /// Operational access, such as toggling pause flags and fee config. Operator = 2, /// Authorized to resolve disputes across escrows. Arbiter = 3, + /// Governance-level authority for protocol-changing decisions (upgrades, emergency mode). + /// Separate from Admin to enforce dual-control and auditability. + Governance = 4, } /// Build-time manifest embedded in the WASM artifact. diff --git a/app/contract/contracts/Folder/src/upgrade_test.rs b/app/contract/contracts/Folder/src/upgrade_test.rs index 461eb361a..1802e949c 100644 --- a/app/contract/contracts/Folder/src/upgrade_test.rs +++ b/app/contract/contracts/Folder/src/upgrade_test.rs @@ -669,6 +669,9 @@ fn seed_admin_role<'a>( ) -> RustAcademyContractClient<'a> { let client = upgrade_to_current(env, contract_id); client.migrate(admin); + // Grant Governance role so governance-gated operations (upgrades, emergency) + // succeed in tests that exercise the full lifecycle. + client.grant_role(admin, admin, &crate::types::Role::Governance); client } @@ -1151,6 +1154,7 @@ fn upgrade_safety_gate_allows_upgrade_after_proper_initialization() { // Properly initialize the contract client.initialize(&admin); + client.grant_role(&admin, &admin, &crate::types::Role::Governance); // Advance time so the upgrade window is active env.ledger().with_mut(|li| {