fix(standards): make RBAC role administration fully role-based#3215
Conversation
|
Related to this PR as well: |
PhilippGackstatter
left a comment
There was a problem hiding this comment.
Looks good, left a few suggestions.
| pub struct RoleSymbol(ShortCapitalString); | ||
|
|
||
| impl Ord for RoleSymbol { | ||
| fn cmp(&self, other: &Self) -> Ordering { | ||
| self.as_element().as_canonical_u64().cmp(&other.as_element().as_canonical_u64()) | ||
| } | ||
| } |
There was a problem hiding this comment.
nit: we usually place trait impls for T after impl T.
| # Provides per-role membership tracking with delegated role admins. Members are tracked | ||
| # with a boolean flag and a per-role member count. Role administration is fully role-based: | ||
| # the built-in `ADMIN` role is the default administrator of every role, mirroring the | ||
| # OpenZeppelin AccessControl `DEFAULT_ADMIN_ROLE`. There is no external super-admin above | ||
| # the role graph. |
There was a problem hiding this comment.
nit: the last sentence feels only relevant for this PR
| dup eq.0 | ||
| # => [is_unset, admin_role_symbol] | ||
|
|
||
| if.true | ||
| drop push.ADMIN_ROLE | ||
| end | ||
| # => [effective_admin_role_symbol] |
There was a problem hiding this comment.
nit: we can use cdrop here to avoid the branch
| #! The effective admin is the role's configured delegated admin, or the built-in `ADMIN` | ||
| #! role when none is configured. There is no owner override: role administration is fully | ||
| #! role-based. |
There was a problem hiding this comment.
nit: last sentence seems only relevant for this PR
| # Stash the membership flag and role_symbol; both are needed after the map write. | ||
| dup loc_store.MEMBERSHIP_FLAG_LOC | ||
| # => [is_member, role_symbol, account_suffix, account_prefix] | ||
|
|
||
| dup.1 loc_store.ROLE_SYMBOL_LOC | ||
| # => [is_member, role_symbol, account_suffix, account_prefix] | ||
|
|
||
| drop | ||
| # => [role_symbol, account_suffix, account_prefix] |
There was a problem hiding this comment.
nit: unnecessary drop-after-dup for is_member
| } | ||
| } | ||
|
|
||
| // Seed ADMIN membership: [0, ADMIN, suffix, prefix] -> [1, 0, 0, 0]. |
There was a problem hiding this comment.
| // Seed ADMIN membership: [0, ADMIN, suffix, prefix] -> [1, 0, 0, 0]. | |
| // Seed ADMIN membership: [0, admin_role, suffix, prefix] -> [1, 0, 0, 0]. |
nit: felts should be lowercase
| // Seed the ADMIN role config with its member count. The delegated admin is left unset | ||
| // (0) so ADMIN administers itself. When there are no admins, the config stays empty. | ||
| let role_config_map = if admins.is_empty() { | ||
| StorageMap::with_entries(vec![]).expect("empty role config map should be valid") |
There was a problem hiding this comment.
| StorageMap::with_entries(vec![]).expect("empty role config map should be valid") | |
| StorageMap::default() |
| let member_count = | ||
| u32::try_from(admins.len()).expect("number of initial admins should fit in u32"); |
There was a problem hiding this comment.
We don't enforce this, afaict, but it's probably fine for practical purposes.
| let tx = mock_chain | ||
| .build_tx_context(updated.clone(), &[], slice::from_ref(&owner_grant_note))? | ||
| .build()?; | ||
| let result = tx.execute().await; |
There was a problem hiding this comment.
nit: could be written as one statement
| let new_admin = test_account_id(105); | ||
| let member = test_account_id(106); | ||
|
|
||
| let admin = role("ADMIN"); |
There was a problem hiding this comment.
nit: I'd replace all role("ADMIN") with RoleBasedAccessControl::admin_role() so we use that definition in more places and get better coverage
mmagician
left a comment
There was a problem hiding this comment.
LGTM, nothing blocking ✅
| # Validate the account ID. This is the only membership-mutating path that validates: | ||
| # grant is the sole writer of the membership map, so every stored key is already valid | ||
| # and revoke/renounce need not re-check (an invalid ID cannot match a stored key). |
There was a problem hiding this comment.
this comment reads as-if something was cut off
| # => [0, 0, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] | ||
| # Build the value word [is_member, 0, 0, 0] on top of the key word. | ||
| push.0.0.0 loc_load.MEMBERSHIP_FLAG_LOC | ||
| # => [is_member, 0, 0, 0, 0, role_symbol, account_suffix, account_prefix] |
There was a problem hiding this comment.
nit: we can group the value word in stack comments as:
# => [[is_member, 0, 0, 0], 0, role_symbol..., ]
| /// Regression test: Once a role is delegated to a dedicated admin role, the general `ADMIN` | ||
| /// role can no longer grant it. The role becomes exclusively controlled by its delegated admin, | ||
| /// so a sensitive capability can be kept out of reach of the general administrator. | ||
| #[tokio::test] | ||
| async fn test_rbac_delegation_locks_out_admin() -> anyhow::Result<()> { |
There was a problem hiding this comment.
isn't this test effectively the same as test_rbac_delegated_admin_can_clear_own_delegation (different action taken, but same pattern)?
then we could perhaps refactor to use rstest to cover both cases
| push.0 | ||
| # => [0, role_symbol, account_suffix, account_prefix, role_symbol] | ||
| # => [0, role_symbol, account_suffix, account_prefix] | ||
|
|
||
| push.CLEAR_MEMBERSHIP | ||
| # => [0, 0, 0, 0, 0, role_symbol, account_suffix, account_prefix, role_symbol] | ||
| # Build the value word [is_member, 0, 0, 0] on top of the key word. | ||
| push.0.0.0 loc_load.MEMBERSHIP_FLAG_LOC |
There was a problem hiding this comment.
Small nit: we can use padw directly instead of push.0.0.0.0
partylikeits1983
left a comment
There was a problem hiding this comment.
Looks great! I think we should add some more tests to increase code coverage but otherwise looks good!
| AccessControl::Rbac { owner, roles } => vec![ | ||
| Ownable2Step::new(owner).into(), | ||
| RoleBasedAccessControl::empty().into(), | ||
| RoleBasedAccessControl::new(owner).into(), |
There was a problem hiding this comment.
With this change, being the owner no longer means anything from the RBAC component's point of view. The owner just starts out as the first ADMIN member. So after an ownership transfer via Ownable2Step, the old owner can still manage RBAC roles whereas the new owner in Ownable2Step cannot.
There are no tests for this. Could you add a test that does a full transfer (old owner calls transfer_ownership, new owner calls accept_ownership) and then checks both directions: the new owner's grant_role fails with ERR_SENDER_NOT_ROLE_ADMIN (ownership does not grant RBAC authority), and the old owner's grant_role still succeeds (changing ownership does not revoke RBAC ADMIN authority)?
| pub proc renounce_role | ||
| dup exec.assert_role_symbol_non_zero | ||
| # => [role_symbol, pad(15)] | ||
|
|
||
| exec.active_note::get_sender | ||
| # => [sender_suffix, sender_prefix, role_symbol, pad(15)] | ||
|
|
||
| movup.2 | ||
| # => [role_symbol, sender_suffix, sender_prefix, pad(13)] | ||
|
|
||
| exec.revoke_role_internal | ||
| push.0 | ||
| # => [is_member=0, role_symbol, sender_suffix, sender_prefix, pad(13)] | ||
|
|
||
| exec.set_membership_internal | ||
| # => [pad(16)] | ||
| end |
There was a problem hiding this comment.
The
ADMINrole administers itself (its own effective admin isADMIN), soADMIN
membership can be granted, revoked, and renounced through the standard API.
The docs say ADMIN membership can be renounced, but no test ever renounces ADMIN, only the PAUSER role. Can you add a test where an admin renounces ADMIN role, including the case where they are the last admin?
|
I've made the changes accordingly removing the owner dependency specified here: #3215 (comment) It is ready for review for one more round. |
| dup eq.0 | ||
| # => [is_unset, admin_role_symbol] | ||
|
|
||
| push.ADMIN_ROLE swap | ||
| # => [is_unset, ADMIN_ROLE, admin_role_symbol] |
There was a problem hiding this comment.
nit/slight optimization: push.ADMIN_ROLE dup.1 eq.0 cdrop.
Closes: #3214