Skip to content

fix(standards): make RBAC role administration fully role-based#3215

Merged
mmagician merged 12 commits into
nextfrom
refactor-rbac-super-admin
Jul 8, 2026
Merged

fix(standards): make RBAC role administration fully role-based#3215
mmagician merged 12 commits into
nextfrom
refactor-rbac-super-admin

Conversation

@onurinanc

Copy link
Copy Markdown
Collaborator

Closes: #3214

@onurinanc

Copy link
Copy Markdown
Collaborator Author

Related to this PR as well:

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, left a few suggestions.

Comment on lines +21 to +27
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())
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we usually place trait impls for T after impl T.

Comment on lines +5 to +9
# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the last sentence feels only relevant for this PR

Comment on lines +420 to +426
dup eq.0
# => [is_unset, admin_role_symbol]

if.true
drop push.ADMIN_ROLE
end
# => [effective_admin_role_symbol]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can use cdrop here to avoid the branch

Comment on lines +431 to +433
#! 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: last sentence seems only relevant for this PR

Comment on lines +506 to +514
# 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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unnecessary drop-after-dup for is_member

}
}

// Seed ADMIN membership: [0, ADMIN, suffix, prefix] -> [1, 0, 0, 0].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
StorageMap::with_entries(vec![]).expect("empty role config map should be valid")
StorageMap::default()

Comment on lines +255 to +256
let member_count =
u32::try_from(admins.len()).expect("number of initial admins should fit in u32");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't enforce this, afaict, but it's probably fine for practical purposes.

Comment on lines +814 to +817
let tx = mock_chain
.build_tx_context(updated.clone(), &[], slice::from_ref(&owner_grant_note))?
.build()?;
let result = tx.execute().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could be written as one statement

let new_admin = test_account_id(105);
let member = test_account_id(106);

let admin = role("ADMIN");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd replace all role("ADMIN") with RoleBasedAccessControl::admin_role() so we use that definition in more places and get better coverage

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, nothing blocking ✅

Comment on lines +228 to +230
# 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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can group the value word in stack comments as:

# => [[is_member, 0, 0, 0], 0, role_symbol..., ]

Comment on lines +792 to +796
/// 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<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Fumuran Fumuran left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you!

Comment on lines +516 to +520
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit: we can use padw directly instead of push.0.0.0.0

@partylikeits1983 partylikeits1983 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Comment on lines 288 to 303
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ADMIN role administers itself (its own effective admin is ADMIN), so ADMIN
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?

Comment thread crates/miden-standards/src/account/access/rbac.rs Outdated
Comment thread crates/miden-standards/src/account/access/mod.rs Outdated
@onurinanc

Copy link
Copy Markdown
Collaborator Author

I've made the changes accordingly removing the owner dependency specified here: #3215 (comment)

It is ready for review for one more round.

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Comment on lines 431 to 435
dup eq.0
# => [is_unset, admin_role_symbol]

push.ADMIN_ROLE swap
# => [is_unset, ADMIN_ROLE, admin_role_symbol]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/slight optimization: push.ADMIN_ROLE dup.1 eq.0 cdrop.

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good ✅

@mmagician mmagician enabled auto-merge July 8, 2026 06:37
@mmagician mmagician added this pull request to the merge queue Jul 8, 2026
Merged via the queue into next with commit 32896b1 Jul 8, 2026
19 checks passed
@mmagician mmagician deleted the refactor-rbac-super-admin branch July 8, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

L-15: RBAC Owner Has Unconditional Super-Admin Authority Over All Roles

5 participants