Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions app/contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 24 additions & 9 deletions app/contract/contracts/Folder/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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])?;

Expand Down Expand Up @@ -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(
Expand All @@ -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)?;
Expand Down Expand Up @@ -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`.
Expand All @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand All @@ -489,15 +503,16 @@ 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(
env: &Env,
caller: &Address,
new_version: u32,
) -> Result<u32, RustAcademyError> {
// 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) {
Expand Down
138 changes: 133 additions & 5 deletions app/contract/contracts/Folder/src/guard_test.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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, ());
Expand Down
Loading
Loading