Skip to content

Commit 6e2cae0

Browse files
authored
Merge pull request #178 from dDevAhmed/feature/issue-130-safe-role-revocation
Implement safe role revocation for property token
2 parents 344a8bd + 0c69647 commit 6e2cae0

4 files changed

Lines changed: 320 additions & 11 deletions

File tree

apps/rust/property-token/src/lib.rs

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use cosmwasm_std::{
66
entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order,
77
Response, StdError, StdResult, Uint128, BankMsg, Coin,
88
};
9+
use std::collections::BTreeSet;
910
use crate::msg::{
1011
Auth, BatchMsg, ExecuteMsg, InstantiateMsg, MigrateMsg,
1112
PropertyMetadata, QueryMsg, SelfCheckResponse, TreasuryAction,
@@ -16,6 +17,28 @@ use crate::state::{
1617
};
1718
use crate::security::{ensure_authorized, prevent_replay};
1819

20+
enum RoleMutation {
21+
Grant(String),
22+
Revoke(String),
23+
}
24+
25+
fn validate_principal(label: &str, principal: &str) -> StdResult<String> {
26+
let normalized = principal.trim();
27+
if normalized.is_empty() {
28+
return Err(StdError::generic_err(format!("{} cannot be empty", label)));
29+
}
30+
31+
Ok(normalized.to_string())
32+
}
33+
34+
fn format_audit_list(entries: &[String]) -> String {
35+
if entries.is_empty() {
36+
"none".to_string()
37+
} else {
38+
entries.join(",")
39+
}
40+
}
41+
1942
// ── Entry points ─────────────────────────────────────────────────────────────
2043

2144
#[entry_point]
@@ -271,26 +294,95 @@ pub fn execute_update_config(
271294
new_admin: Option<String>,
272295
authorized_roles: Option<Vec<(String, bool)>>,
273296
) -> StdResult<Response> {
274-
let admin = ADMIN.load(deps.storage)?;
275-
if info.sender.as_str() != admin {
297+
let current_admin = ADMIN.load(deps.storage)?;
298+
if info.sender.as_str() != current_admin {
276299
return Err(StdError::generic_err("Only the current admin can update config"));
277300
}
278-
if let Some(addr) = new_admin {
279-
ADMIN.save(deps.storage, &addr)?;
280-
}
301+
302+
let next_admin = new_admin
303+
.as_deref()
304+
.map(|addr| validate_principal("new_admin", addr))
305+
.transpose()?;
306+
307+
let mut seen_roles = BTreeSet::new();
308+
let mut role_mutations: Vec<RoleMutation> = Vec::new();
309+
281310
if let Some(roles) = authorized_roles {
282311
for (addr, is_auth) in roles {
283-
AUTHORIZED_ROLES.save(deps.storage, &addr, &is_auth)?;
312+
let normalized = validate_principal("authorized role address", &addr)?;
313+
if !seen_roles.insert(normalized.clone()) {
314+
return Err(StdError::generic_err(format!(
315+
"Duplicate role update requested for {}",
316+
normalized
317+
)));
318+
}
319+
320+
let existing_state = AUTHORIZED_ROLES.may_load(deps.storage, &normalized)?;
321+
match (existing_state, is_auth) {
322+
(Some(true), true) => {}
323+
(Some(false), true) | (None, true) => {
324+
role_mutations.push(RoleMutation::Grant(normalized));
325+
}
326+
(Some(true), false) | (Some(false), false) => {
327+
role_mutations.push(RoleMutation::Revoke(normalized));
328+
}
329+
(None, false) => {
330+
return Err(StdError::generic_err(format!(
331+
"Cannot revoke role for {} because no active permission exists",
332+
normalized
333+
)));
334+
}
335+
}
336+
}
337+
}
338+
339+
let mut response = Response::new()
340+
.add_attribute("action", "update_config")
341+
.add_attribute("actor", info.sender.as_str());
342+
343+
if let Some(addr) = next_admin {
344+
let admin_changed = addr != current_admin;
345+
if admin_changed {
346+
ADMIN.save(deps.storage, &addr)?;
284347
}
348+
response = response
349+
.add_attribute("admin_changed", admin_changed.to_string())
350+
.add_attribute("previous_admin", current_admin.clone())
351+
.add_attribute("current_admin", addr);
352+
} else {
353+
response = response
354+
.add_attribute("admin_changed", "false")
355+
.add_attribute("current_admin", current_admin.clone());
285356
}
286-
Ok(Response::new().add_attribute("action", "update_config"))
357+
358+
let mut granted_roles: Vec<String> = Vec::new();
359+
let mut revoked_roles: Vec<String> = Vec::new();
360+
361+
for mutation in role_mutations {
362+
match mutation {
363+
RoleMutation::Grant(addr) => {
364+
AUTHORIZED_ROLES.save(deps.storage, &addr, &true)?;
365+
granted_roles.push(addr);
366+
}
367+
RoleMutation::Revoke(addr) => {
368+
AUTHORIZED_ROLES.remove(deps.storage, &addr);
369+
revoked_roles.push(addr);
370+
}
371+
}
372+
}
373+
374+
Ok(response
375+
.add_attribute("roles_granted_count", granted_roles.len().to_string())
376+
.add_attribute("roles_revoked_count", revoked_roles.len().to_string())
377+
.add_attribute("roles_granted", format_audit_list(&granted_roles))
378+
.add_attribute("roles_revoked", format_audit_list(&revoked_roles)))
287379
}
288380

289381
/// #136 — Storage Cleanup Utility
290382
///
291383
/// Removes:
292384
/// - Zero-balance entries from `FEE_BALANCES` (saves storage rent)
293-
/// - Explicitly revoked role entries (value == false) from `AUTHORIZED_ROLES`
385+
/// - Legacy explicitly-revoked role entries (value == false) from `AUTHORIZED_ROLES`
294386
///
295387
/// Admin-only. Does not affect treasury, metadata, or nonce state.
296388
pub fn execute_cleanup_storage(
@@ -316,7 +408,7 @@ pub fn execute_cleanup_storage(
316408
FEE_BALANCES.remove(deps.storage, key.as_str());
317409
}
318410

319-
// Collect revoked role keys (explicitly set to false — dead entries)
411+
// Collect legacy revoked role keys (explicitly set to false — dead entries)
320412
let revoked_role_keys: Vec<String> = AUTHORIZED_ROLES
321413
.range(deps.storage, None, None, Order::Ascending)
322414
.filter_map(|item| {
@@ -400,7 +492,7 @@ pub fn query_self_check(deps: Deps) -> StdResult<SelfCheckResponse> {
400492
));
401493
}
402494

403-
// 5. No explicitly-revoked role entries should remain (they waste storage)
495+
// 5. No legacy explicitly-revoked role entries should remain (they waste storage)
404496
let revoked_role_count = AUTHORIZED_ROLES
405497
.range(deps.storage, None, None, Order::Ascending)
406498
.filter_map(|item| item.ok())
@@ -409,7 +501,7 @@ pub fn query_self_check(deps: Deps) -> StdResult<SelfCheckResponse> {
409501

410502
if revoked_role_count > 0 {
411503
failures.push(format!(
412-
"{} authorized_roles entries are explicitly false (consider running CleanupStorage)",
504+
"{} authorized_roles entries use the legacy false tombstone format (consider running CleanupStorage)",
413505
revoked_role_count
414506
));
415507
} else {

apps/rust/property-token/src/msg.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ pub enum ExecuteMsg {
5555

5656
// Role-based Access Control
5757
UpdateConfig {
58+
/// Replaces the current admin when provided. Empty values are rejected.
5859
new_admin: Option<String>,
60+
/// Backward-compatible permission updates.
61+
/// `true` grants a role; `false` revokes it by removing the map entry.
5962
authorized_roles: Option<Vec<(String, bool)>>,
6063
},
6164

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
2+
use property_token::{
3+
execute_update_config,
4+
instantiate,
5+
query_self_check,
6+
msg::InstantiateMsg,
7+
state::AUTHORIZED_ROLES,
8+
};
9+
10+
fn attr_value(response: &cosmwasm_std::Response, key: &str) -> Option<String> {
11+
response
12+
.attributes
13+
.iter()
14+
.find(|attr| attr.key == key)
15+
.map(|attr| attr.value.clone())
16+
}
17+
18+
#[test]
19+
fn revoking_a_role_removes_the_storage_entry_and_emits_audit_attributes() {
20+
let mut deps = mock_dependencies();
21+
instantiate(
22+
deps.as_mut(),
23+
mock_env(),
24+
mock_info("admin", &[]),
25+
InstantiateMsg { admin: None },
26+
)
27+
.unwrap();
28+
AUTHORIZED_ROLES
29+
.save(deps.as_mut().storage, "operator", &true)
30+
.unwrap();
31+
32+
let response = execute_update_config(
33+
deps.as_mut(),
34+
mock_env(),
35+
mock_info("admin", &[]),
36+
None,
37+
Some(vec![("operator".to_string(), false)]),
38+
)
39+
.unwrap();
40+
41+
assert_eq!(AUTHORIZED_ROLES.may_load(deps.as_ref().storage, "operator").unwrap(), None);
42+
assert_eq!(attr_value(&response, "roles_revoked_count").as_deref(), Some("1"));
43+
assert_eq!(attr_value(&response, "roles_revoked").as_deref(), Some("operator"));
44+
assert_eq!(attr_value(&response, "actor").as_deref(), Some("admin"));
45+
}
46+
47+
#[test]
48+
fn revoking_a_missing_role_is_rejected() {
49+
let mut deps = mock_dependencies();
50+
instantiate(
51+
deps.as_mut(),
52+
mock_env(),
53+
mock_info("admin", &[]),
54+
InstantiateMsg { admin: None },
55+
)
56+
.unwrap();
57+
58+
let err = execute_update_config(
59+
deps.as_mut(),
60+
mock_env(),
61+
mock_info("admin", &[]),
62+
None,
63+
Some(vec![("ghost".to_string(), false)]),
64+
)
65+
.unwrap_err()
66+
.to_string();
67+
68+
assert!(err.contains("Cannot revoke role for ghost"), "unexpected error: {}", err);
69+
}
70+
71+
#[test]
72+
fn legacy_false_role_entries_are_cleaned_by_revocation_and_self_check_passes() {
73+
let mut deps = mock_dependencies();
74+
instantiate(
75+
deps.as_mut(),
76+
mock_env(),
77+
mock_info("admin", &[]),
78+
InstantiateMsg { admin: None },
79+
)
80+
.unwrap();
81+
AUTHORIZED_ROLES
82+
.save(deps.as_mut().storage, "legacy-role", &false)
83+
.unwrap();
84+
85+
execute_update_config(
86+
deps.as_mut(),
87+
mock_env(),
88+
mock_info("admin", &[]),
89+
None,
90+
Some(vec![("legacy-role".to_string(), false)]),
91+
)
92+
.unwrap();
93+
94+
let report = query_self_check(deps.as_ref()).unwrap();
95+
assert!(report.ok, "expected self check to pass, got failures: {:?}", report.failures);
96+
}
97+
98+
#[test]
99+
fn unauthorized_callers_cannot_revoke_roles() {
100+
let mut deps = mock_dependencies();
101+
instantiate(
102+
deps.as_mut(),
103+
mock_env(),
104+
mock_info("admin", &[]),
105+
InstantiateMsg { admin: None },
106+
)
107+
.unwrap();
108+
AUTHORIZED_ROLES
109+
.save(deps.as_mut().storage, "operator", &true)
110+
.unwrap();
111+
112+
let err = execute_update_config(
113+
deps.as_mut(),
114+
mock_env(),
115+
mock_info("attacker", &[]),
116+
None,
117+
Some(vec![("operator".to_string(), false)]),
118+
)
119+
.unwrap_err()
120+
.to_string();
121+
122+
assert!(err.contains("Only the current admin can update config"), "unexpected error: {}", err);
123+
assert_eq!(AUTHORIZED_ROLES.may_load(deps.as_ref().storage, "operator").unwrap(), Some(true));
124+
}
125+
126+
#[test]
127+
fn empty_admin_updates_are_rejected_to_preserve_admin_control() {
128+
let mut deps = mock_dependencies();
129+
instantiate(
130+
deps.as_mut(),
131+
mock_env(),
132+
mock_info("admin", &[]),
133+
InstantiateMsg { admin: None },
134+
)
135+
.unwrap();
136+
137+
let err = execute_update_config(
138+
deps.as_mut(),
139+
mock_env(),
140+
mock_info("admin", &[]),
141+
Some(" ".to_string()),
142+
None,
143+
)
144+
.unwrap_err()
145+
.to_string();
146+
147+
assert!(err.contains("new_admin cannot be empty"), "unexpected error: {}", err);
148+
}
149+
150+
#[test]
151+
fn duplicate_role_updates_are_rejected_before_state_changes() {
152+
let mut deps = mock_dependencies();
153+
instantiate(
154+
deps.as_mut(),
155+
mock_env(),
156+
mock_info("admin", &[]),
157+
InstantiateMsg { admin: None },
158+
)
159+
.unwrap();
160+
161+
let err = execute_update_config(
162+
deps.as_mut(),
163+
mock_env(),
164+
mock_info("admin", &[]),
165+
None,
166+
Some(vec![
167+
("operator".to_string(), true),
168+
("operator".to_string(), false),
169+
]),
170+
)
171+
.unwrap_err()
172+
.to_string();
173+
174+
assert!(err.contains("Duplicate role update requested for operator"), "unexpected error: {}", err);
175+
assert_eq!(AUTHORIZED_ROLES.may_load(deps.as_ref().storage, "operator").unwrap(), None);
176+
}

docs/ROLE_REVOCATION_RULES.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Role Revocation Rules
2+
3+
This document describes the role revocation behavior for the Soroban property-token contract.
4+
5+
## Scope
6+
7+
The contract maintains two permission layers:
8+
9+
- `ADMIN`: the single administrative authority stored as a dedicated state item
10+
- `AUTHORIZED_ROLES`: a map of additional addresses that may perform privileged actions
11+
12+
## Revocation Rules
13+
14+
1. Only the current `ADMIN` may change permissions.
15+
2. Revoking a role removes the address from `AUTHORIZED_ROLES` entirely.
16+
3. The contract rejects revocation requests for addresses that were never granted a role.
17+
4. Legacy `AUTHORIZED_ROLES[address] = false` tombstones are treated as revocable stale state and are removed when revoked again.
18+
5. The admin role cannot be cleared. Admin control can only change through an explicit `new_admin` replacement value.
19+
6. Empty or whitespace-only principals are rejected for both role updates and admin changes.
20+
7. Duplicate updates for the same address in a single config change are rejected to avoid ambiguous final state.
21+
22+
## Auditability
23+
24+
Every successful `UpdateConfig` execution emits attributes for:
25+
26+
- the actor performing the change
27+
- whether the admin changed
28+
- the current admin after the change
29+
- granted role count and address list
30+
- revoked role count and address list
31+
32+
These attributes provide an on-chain audit trail for permission changes.
33+
34+
## Operational Notes
35+
36+
- `CleanupStorage` remains available to remove legacy `false` tombstones that may still exist from older deployments.
37+
- `SelfCheck` reports legacy tombstones as a failure until they are removed.
38+
- Revoking an address from `AUTHORIZED_ROLES` does not clear the dedicated `ADMIN` value. Admin transfer must be done explicitly.

0 commit comments

Comments
 (0)