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
32 changes: 32 additions & 0 deletions contracts/admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ pub enum AdminError {
RoleNotHeldForRenounce = 28,
/// The last SuperAdmin tried to renounce; at least one must remain (#915).
LastSuperAdmin = 29,
/// A role proposal could not be found for the requested proposal ID.
RoleProposalNotFound = 30,
/// A role proposal must be executed through `execute_role_proposal`.
RoleProposalRequiresExecution = 31,
}

/// Storage keys for the access-control layer.
Expand Down Expand Up @@ -394,6 +398,9 @@ pub enum AdminKey {
/// [`get_instance_ttl`] derives a non-negative remaining lifetime from this
/// value. It is not one of the five monitored singleton keys.
InstanceLiveUntil,
/// Role grant action attached to a legacy proposal, keyed by proposal ID.
/// Appended to preserve every existing storage-key discriminant.
RoleGrantProposal(u64),
}

/// Instance TTL and the count of known singleton admin keys.
Expand Down Expand Up @@ -594,6 +601,16 @@ mod tests {
super::create_proposal(&env, creator, description)
}

pub fn create_role_proposal(
env: Env,
creator: Address,
role: Role,
target: Address,
description: String,
) -> u64 {
super::create_role_proposal(&env, creator, role, target, description)
}

pub fn approve_proposal(env: Env, admin: Address, proposal_id: u64) {
super::approve_proposal(&env, admin, proposal_id);
}
Expand All @@ -606,6 +623,21 @@ mod tests {
super::mark_executed(&env, proposal_id);
}

pub fn execute_role_proposal(
env: Env,
executor: Address,
proposal_id: u64,
) -> Result<(), AdminError> {
super::execute_role_proposal(&env, executor, proposal_id)
}

pub fn get_role_proposal(
env: Env,
proposal_id: u64,
) -> Option<RoleGrantProposalDetails> {
super::get_role_proposal(&env, proposal_id)
}

pub fn cancel_legacy_proposal(
env: Env,
caller: Address,
Expand Down
132 changes: 131 additions & 1 deletion contracts/admin/src/multisig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use soroban_sdk::{contracttype, vec, Address, BytesN, Env, Map, String, Vec};

use crate::address::require_non_zero_address;
use crate::events;
use crate::rbac::{get_admin, has_admin, require_admin};
use crate::rbac::{get_admin, has_admin, require_admin, require_super_admin, Role};
use crate::reentrancy_guard;
use crate::{extend_instance_ttl, extend_storage_ttl_for_key, AdminError, AdminKey};

Expand Down Expand Up @@ -60,6 +60,27 @@ pub struct Proposal {
pub cancelled: bool,
}

/// Role grant bound to a legacy multisig proposal without changing the
/// persisted `Proposal` layout.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct RoleGrantAction {
pub role: Role,
pub target: Address,
}

/// Role proposal information needed by the voting interface.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct RoleGrantProposalDetails {
pub description: String,
pub role: Role,
pub target: Address,
pub approvals: u32,
pub quorum: u32,
pub executed: bool,
}

/// Lifecycle state of an [`UpgradeProposal`].
///
/// A single enum rather than a set of booleans: the upgrade flow needs
Expand Down Expand Up @@ -419,6 +440,59 @@ pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64
id
}

/// Creates a multisig proposal that grants `role` to `target` when executed.
pub fn create_role_proposal(
env: &Env,
creator: Address,
role: Role,
target: Address,
description: String,
) -> u64 {
require_non_zero_address(env, &target);
if matches!(role, Role::Admin) {
soroban_sdk::panic_with_error!(env, AdminError::InvalidRole);
}
if crate::rbac::has_role(env, role, &target) {
soroban_sdk::panic_with_error!(env, AdminError::RoleAlreadyGranted);
}
let id = create_proposal(env, creator, description);
env.storage()
.instance()
.set(&AdminKey::RoleGrantProposal(id), &RoleGrantAction { role, target });
extend_instance_ttl(env);
id
}

/// Returns proposal details for the voting panel.
pub fn get_proposal(env: &Env, proposal_id: u64) -> Option<Proposal> {
let proposal = env
.storage()
.instance()
.get(&AdminKey::Proposal(proposal_id));
if proposal.is_some() {
extend_instance_ttl(env);
}
proposal
}

/// Returns a role proposal with its live approval tally, or `None` for a
/// generic proposal or unknown ID.
pub fn get_role_proposal(env: &Env, proposal_id: u64) -> Option<RoleGrantProposalDetails> {
let action: RoleGrantAction = env
.storage()
.instance()
.get(&AdminKey::RoleGrantProposal(proposal_id))?;
let proposal = get_proposal(env, proposal_id)?;
Some(RoleGrantProposalDetails {
description: proposal.description,
role: action.role,
target: action.target,
approvals: proposal.approvals.len(),
quorum: get_threshold(env),
executed: proposal.executed,
})
}

/// Approves a multi-sig governance proposal.
///
/// @notice Approves proposal `proposal_id` on behalf of `admin`.
Expand Down Expand Up @@ -488,6 +562,14 @@ pub fn mark_executed(env: &Env, proposal_id: u64) {
let admin = get_admin(env);
admin.require_auth();

if env
.storage()
.instance()
.has(&AdminKey::RoleGrantProposal(proposal_id))
{
soroban_sdk::panic_with_error!(env, AdminError::RoleProposalRequiresExecution);
}

let mut proposal: Proposal = env
.storage()
.instance()
Expand All @@ -514,6 +596,51 @@ pub fn mark_executed(env: &Env, proposal_id: u64) {
extend_instance_ttl(env);
}

/// Executes a quorum-approved role grant proposal.
pub fn execute_role_proposal(
env: &Env,
executor: Address,
proposal_id: u64,
) -> Result<(), AdminError> {
let _reentrancy_guard = reentrancy_guard::enter(env);
executor.require_auth();
if !get_admin_pool(env).contains(&executor) {
return Err(AdminError::UnauthorizedRole);
}
require_super_admin(env, &executor);

let mut proposal: Proposal = env
.storage()
.instance()
.get(&AdminKey::Proposal(proposal_id))
.ok_or(AdminError::ProposalNotFound)?;
if proposal.executed {
return Err(AdminError::ProposalAlreadyExecuted);
}
if proposal.cancelled {
return Err(AdminError::ProposalCancelled);
}
if _proposal_expired(env, &proposal) {
return Err(AdminError::ProposalExpired);
}
if !is_proposal_ready(env, proposal_id) {
return Err(AdminError::ThresholdNotMet);
}

let action: RoleGrantAction = env
.storage()
.instance()
.get(&AdminKey::RoleGrantProposal(proposal_id))
.ok_or(AdminError::RoleProposalNotFound)?;
crate::rbac::_grant_role(env, &executor, action.role, &action.target);
proposal.executed = true;
env.storage()
.instance()
.set(&AdminKey::Proposal(proposal_id), &proposal);
extend_instance_ttl(env);
Ok(())
}

fn _proposal_expired(env: &Env, proposal: &Proposal) -> bool {
match proposal.expiry_ledger {
Some(expiry_ledger) => env.ledger().sequence() > expiry_ledger,
Expand Down Expand Up @@ -547,6 +674,9 @@ pub fn cancel_legacy_proposal(
}

proposal.cancelled = true;
env.storage()
.instance()
.remove(&AdminKey::RoleGrantProposal(proposal_id));
env.storage()
.instance()
.set(&AdminKey::Proposal(proposal_id), &proposal);
Expand Down
29 changes: 28 additions & 1 deletion contracts/admin/src/rbac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ pub fn grant_role(env: &Env, caller: &Address, role: Role, address: &Address) {
/// - [`AdminError::RoleAlreadyGranted`] — `address` already holds `role` (#768).
/// # Events
/// Emits `role_grnt` with data `(admin, role, address)`.
fn _grant_role(env: &Env, admin: &Address, role: Role, address: &Address) {
pub(crate) fn _grant_role(env: &Env, admin: &Address, role: Role, address: &Address) {
require_non_zero_address(env, address);
let bit = match role_bit(role) {
Some(bit) => bit,
Expand Down Expand Up @@ -644,9 +644,36 @@ pub fn revoke_role(
require_valid_role(env, role);
require_non_zero_address(env, address);

if role == Role::SuperAdmin && is_last_super_admin(env, address) {
return Err(AdminError::LastSuperAdmin);
}

_revoke_role(env, role, address)
}

/// Returns whether removing this holder would leave the configured admin set
/// without any effective SuperAdmin.
pub(crate) fn is_last_super_admin(env: &Env, address: &Address) -> bool {
if !has_role(env, Role::SuperAdmin, address) {
return false;
}
let admin = get_admin(env);
if admin != *address {
return false;
}
let pool = crate::multisig::get_admin_pool(env);
for i in 0..pool.len() {
let member = pool.get(i).expect("index in range");
if member != *address {
let mask = load_raw_role_mask(env, &member);
if mask & (ROLE_BIT_ADMIN | ROLE_BIT_SUPER_ADMIN) != 0 {
return false;
}
}
}
true
}

/// Removes a role assignment without performing authorization.
///
/// This helper is intentionally private. Callers exposed by a contract must
Expand Down
54 changes: 54 additions & 0 deletions contracts/admin/src/tests/issue_914_918.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,60 @@ fn wasm_hash(env: &Env, byte: u8) -> BytesN<32> {
BytesN::from_array(env, &[byte; 32])
}

#[test]
fn test_role_proposal_requires_quorum_and_executes_the_bound_grant() {
let env = Env::default();
let (client, admin) = setup(&env);
let second_admin = Address::generate(&env);
let target = Address::generate(&env);
client.set_admin_pool(&vec![&env, admin.clone(), second_admin.clone()], &2);

let proposal_id = client.create_role_proposal(
&admin,
&Role::Minter,
&target,
&String::from_str(&env, "Grant Minter to target"),
);
let details = client
.get_role_proposal(&proposal_id)
.expect("role proposal details are available");
assert_eq!(details.role, Role::Minter);
assert_eq!(details.target, target);
assert_eq!(details.approvals, 1);
assert_eq!(details.quorum, 2);
assert!(!details.executed);

assert_eq!(
client.try_execute_role_proposal(&admin, &proposal_id),
Err(Ok(AdminError::ThresholdNotMet)),
);
assert_eq!(
client.try_mark_executed(&proposal_id),
Err(Ok(AdminError::RoleProposalRequiresExecution)),
);

client.approve_proposal(&second_admin, &proposal_id);
client.execute_role_proposal(&admin, &proposal_id);
assert!(client.has_role(&Role::Minter, &target));
assert!(
client
.get_role_proposal(&proposal_id)
.expect("executed proposal remains readable")
.executed
);
}

#[test]
fn test_revoke_role_rejects_removing_the_only_configured_super_admin() {
let env = Env::default();
let (client, admin) = setup(&env);

assert_eq!(
client.try_revoke_role(&admin, &Role::SuperAdmin, &admin),
Err(Ok(AdminError::LastSuperAdmin)),
);
}

// ─────────────────────────────────────────────────────────────────────────────
// #914 — privileged-action timelock
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading