use alloc::collections::{BTreeMap, BTreeSet};
use alloc::vec;
use alloc::vec::Vec;
use miden_protocol::account::component::{
AccountComponentCode,
AccountComponentMetadata,
SchemaType,
StorageSchema,
StorageSlotSchema,
};
use miden_protocol::account::{
AccountComponent,
AccountComponentName,
AccountId,
RoleSymbol,
StorageMap,
StorageMapKey,
StorageSlot,
StorageSlotName,
};
use miden_protocol::utils::sync::LazyLock;
use miden_protocol::{Felt, Word};
use crate::account::account_component_code;
account_component_code!(RBAC_CODE, "miden-standards-access-rbac.masp");
static ROLE_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
StorageSlotName::new("miden::standards::access::rbac::role_config")
.expect("storage slot name should be valid")
});
static ROLE_MEMBERSHIP_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
StorageSlotName::new("miden::standards::access::rbac::role_membership")
.expect("storage slot name should be valid")
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoleConfig {
role: RoleSymbol,
members: BTreeSet<AccountId>,
admin: Option<RoleSymbol>,
}
impl RoleConfig {
pub fn new(role: RoleSymbol) -> Self {
Self {
role,
members: BTreeSet::new(),
admin: None,
}
}
pub fn with_admin(mut self, admin: RoleSymbol) -> Self {
self.admin = Some(admin);
self
}
pub fn with_members(mut self, members: impl IntoIterator<Item = AccountId>) -> Self {
self.members.extend(members);
self
}
pub fn with_member(mut self, member: AccountId) -> Self {
self.members.insert(member);
self
}
}
impl RoleConfig {
pub fn role(&self) -> &RoleSymbol {
&self.role
}
pub fn members(&self) -> &BTreeSet<AccountId> {
&self.members
}
pub fn admin(&self) -> Option<&RoleSymbol> {
self.admin.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoleBasedAccessControl {
roles: BTreeMap<RoleSymbol, RoleConfig>,
}
#[bon::bon]
impl RoleBasedAccessControl {
#[builder]
pub fn new(
#[builder(field)] role_configs: Vec<RoleConfig>,
) -> Result<Self, RoleBasedAccessControlError> {
let mut roles = BTreeMap::new();
for config in role_configs {
if config.members.is_empty() && config.admin.is_none() {
return Err(RoleBasedAccessControlError::EmptyRoleConfig(config.role));
}
if u32::try_from(config.members.len()).is_err() {
return Err(RoleBasedAccessControlError::MemberCountOverflow {
role: config.role,
member_count: config.members.len(),
});
}
if roles.contains_key(&config.role) {
return Err(RoleBasedAccessControlError::DuplicateRole(config.role));
}
roles.insert(config.role.clone(), config);
}
for role_config in roles.values() {
let admin = role_config.admin.clone().unwrap_or_else(Self::admin_role);
if !reaches_populated_role(&admin, &roles) {
return Err(RoleBasedAccessControlError::UnmanageableRole {
role: role_config.role.clone(),
admin,
});
}
}
Ok(Self { roles })
}
}
impl RoleBasedAccessControl {
pub const NAME: &'static str = "miden::standards::access::rbac";
pub const ADMIN_ROLE: &'static str = "ADMIN";
pub fn with_admins(
admins: impl IntoIterator<Item = AccountId>,
) -> Result<Self, RoleBasedAccessControlError> {
Self::builder()
.role(RoleConfig::new(Self::admin_role()).with_members(admins))
.build()
}
pub fn admin_role() -> RoleSymbol {
RoleSymbol::new(Self::ADMIN_ROLE).expect("ADMIN is a valid role symbol")
}
pub const fn name() -> AccountComponentName {
AccountComponentName::from_static_str(Self::NAME)
}
pub fn code() -> &'static AccountComponentCode {
&RBAC_CODE
}
pub fn role_config_slot() -> &'static StorageSlotName {
&ROLE_CONFIG_SLOT_NAME
}
pub fn role_membership_slot() -> &'static StorageSlotName {
&ROLE_MEMBERSHIP_SLOT_NAME
}
pub fn role_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
(
Self::role_config_slot().clone(),
StorageSlotSchema::map(
"Per-role RBAC configuration (member count and delegated admin role)",
SchemaType::role_symbol(),
SchemaType::native_word(),
),
)
}
pub fn role_membership_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
(
Self::role_membership_slot().clone(),
StorageSlotSchema::map(
"Role membership flag indexed by role symbol and account ID",
SchemaType::native_word(),
SchemaType::native_word(),
),
)
}
pub fn component_metadata() -> AccountComponentMetadata {
let storage_schema = StorageSchema::new(vec![
Self::role_config_slot_schema(),
Self::role_membership_slot_schema(),
])
.expect("storage schema should be valid");
AccountComponentMetadata::new(Self::NAME)
.with_description("Role-based access control component")
.with_storage_schema(storage_schema)
}
}
impl<S: role_based_access_control_builder::State> RoleBasedAccessControlBuilder<S> {
pub fn role(mut self, config: RoleConfig) -> Self {
self.role_configs.push(config);
self
}
pub fn roles(mut self, configs: impl IntoIterator<Item = RoleConfig>) -> Self {
self.role_configs.extend(configs);
self
}
}
fn reaches_populated_role(role: &RoleSymbol, configs: &BTreeMap<RoleSymbol, RoleConfig>) -> bool {
let admin_role = RoleBasedAccessControl::admin_role();
let mut visited = BTreeSet::new();
let mut current = role.clone();
while visited.insert(current.clone()) {
current = match configs.get(¤t) {
Some(role) if !role.members.is_empty() => return true,
Some(role) => role.admin.clone().unwrap_or_else(|| admin_role.clone()),
None => admin_role.clone(),
};
}
false
}
impl From<RoleBasedAccessControl> for AccountComponent {
fn from(rbac: RoleBasedAccessControl) -> Self {
let mut config_entries = Vec::new();
let mut membership_entries = Vec::new();
for config in rbac.roles.into_values() {
let role_symbol: Felt = config.role.as_element();
let member_count = u32::try_from(config.members.len())
.expect("member count is validated on initialization");
let admin_symbol = config.admin.as_ref().map_or(Felt::ZERO, RoleSymbol::as_element);
config_entries.push((
StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, role_symbol])),
Word::from([Felt::from(member_count), admin_symbol, Felt::ZERO, Felt::ZERO]),
));
for member in config.members {
membership_entries.push((
StorageMapKey::new(Word::from([
Felt::ZERO,
role_symbol,
member.suffix(),
member.prefix().as_felt(),
])),
Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
));
}
}
let role_membership_map = StorageMap::with_entries(membership_entries)
.expect("config role membership map should be valid");
let role_config_map = StorageMap::with_entries(config_entries)
.expect("config role config map should be valid");
let role_config_slot = StorageSlot::with_map(
RoleBasedAccessControl::role_config_slot().clone(),
role_config_map,
);
let role_membership_slot = StorageSlot::with_map(
RoleBasedAccessControl::role_membership_slot().clone(),
role_membership_map,
);
AccountComponent::new(
RoleBasedAccessControl::code().clone(),
vec![role_config_slot, role_membership_slot],
RoleBasedAccessControl::component_metadata(),
)
.expect("RBAC component should satisfy the requirements of a valid account component")
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RoleBasedAccessControlError {
#[error("role {0} is defined more than once")]
DuplicateRole(RoleSymbol),
#[error("role {0} is defined with neither members nor a delegated admin")]
EmptyRoleConfig(RoleSymbol),
#[error(
"role {role} is defined with {member_count} members which exceeds the maximum of {}",
u32::MAX
)]
MemberCountOverflow { role: RoleSymbol, member_count: usize },
#[error(
"role {role} is defined with delegated admin {admin}, which can never hold members and so leaves {role} unmanageable"
)]
UnmanageableRole { role: RoleSymbol, admin: RoleSymbol },
}
#[cfg(test)]
mod tests {
use miden_protocol::account::{AccountType, StorageSlotContent};
use super::*;
fn test_admin(seed: u8) -> AccountId {
AccountId::builder()
.account_type(AccountType::Private)
.build_with_seed([seed; 32])
}
fn role(symbol: &str) -> RoleSymbol {
RoleSymbol::new_unchecked(symbol)
}
fn role_config_key(role: &RoleSymbol) -> StorageMapKey {
StorageMapKey::new(Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, role.as_element()]))
}
fn find_map<'a>(
component: &'a AccountComponent,
slot_name: &StorageSlotName,
) -> &'a StorageMap {
let slot = component
.storage_slots()
.iter()
.find(|slot| slot.name() == slot_name)
.expect("component should register the slot");
match slot.content() {
StorageSlotContent::Map(map) => map,
_ => panic!("slot {slot_name} should be a map"),
}
}
#[test]
fn admin_role_encoding_matches_masm_constant() {
const MASM_ADMIN_ROLE: u64 = 1836707;
assert_eq!(
RoleBasedAccessControl::admin_role().as_element().as_canonical_u64(),
MASM_ADMIN_ROLE,
);
}
#[test]
fn with_admins_sets_every_admin_and_the_member_count() -> anyhow::Result<()> {
let admins = [test_admin(1), test_admin(2), test_admin(3)];
let component: AccountComponent = RoleBasedAccessControl::with_admins(admins)?.into();
let admin_symbol = RoleBasedAccessControl::admin_role().as_element();
let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot());
assert_eq!(membership.num_entries(), admins.len());
for admin in admins {
let key = StorageMapKey::new(Word::from([
Felt::ZERO,
admin_symbol,
admin.suffix(),
admin.prefix().as_felt(),
]));
assert_eq!(
membership.get(&key),
Word::from([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO])
);
}
let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
let member_count = u32::try_from(admins.len())?;
assert_eq!(
config.get(&role_config_key(&RoleBasedAccessControl::admin_role())),
Word::from([Felt::from(member_count), Felt::ZERO, Felt::ZERO, Felt::ZERO]),
);
Ok(())
}
#[test]
fn with_admins_rejects_an_empty_member_set() {
let error =
RoleBasedAccessControl::with_admins([]).expect_err("initialization should have failed");
assert_eq!(
error,
RoleBasedAccessControlError::EmptyRoleConfig(RoleBasedAccessControl::admin_role())
);
}
#[test]
fn defining_no_role_defines_no_admin() -> anyhow::Result<()> {
let component: AccountComponent = RoleBasedAccessControl::builder().build()?.into();
let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot());
assert_eq!(membership.num_entries(), 0);
let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
assert_eq!(config.num_entries(), 0);
Ok(())
}
#[test]
fn defining_delegated_admin_is_written_to_the_role_config() -> anyhow::Result<()> {
let admin = test_admin(1);
let manager = test_admin(2);
let pauser = test_admin(3);
let manager_role = RoleSymbol::new("DOM_MANAGER")?;
let pauser_role = RoleSymbol::new("DOM_PAUSER")?;
let component: AccountComponent = RoleBasedAccessControl::builder()
.role(
RoleConfig::new(RoleBasedAccessControl::admin_role()).with_member(admin),
)
.role(
RoleConfig::new(manager_role.clone()).with_member(manager)
.with_admin(manager_role.clone())
)
.role(
RoleConfig::new(pauser_role.clone()).with_member(pauser)
.with_admin(manager_role.clone())
)
.build()?
.into();
let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
let manager_symbol = manager_role.as_element();
assert_eq!(
config.get(&role_config_key(&manager_role)),
Word::from([Felt::ONE, manager_symbol, Felt::ZERO, Felt::ZERO]),
);
assert_eq!(
config.get(&role_config_key(&pauser_role)),
Word::from([Felt::ONE, manager_symbol, Felt::ZERO, Felt::ZERO]),
);
Ok(())
}
#[test]
fn role_initialized_without_members_holds_its_delegated_admin() -> anyhow::Result<()> {
let admin = test_admin(1);
let minter_role = RoleSymbol::new("MINTER")?;
let minter_admin_role = RoleBasedAccessControl::admin_role();
let component: AccountComponent = RoleBasedAccessControl::builder()
.role(RoleConfig::new(minter_admin_role.clone()).with_member(admin))
.role(RoleConfig::new(minter_role.clone()).with_admin(minter_admin_role))
.build()?
.into();
let config = find_map(&component, RoleBasedAccessControl::role_config_slot());
assert_eq!(
config.get(&role_config_key(&minter_role)),
Word::from([
Felt::ZERO,
RoleBasedAccessControl::admin_role().as_element(),
Felt::ZERO,
Felt::ZERO
]),
);
let membership = find_map(&component, RoleBasedAccessControl::role_membership_slot());
assert_eq!(membership.num_entries(), 1);
Ok(())
}
#[test]
fn delegating_to_a_role_populated_later_is_allowed() -> anyhow::Result<()> {
let admin = test_admin(1);
let minter_role = RoleSymbol::new("MINTER")?;
let minter_admin_role = RoleSymbol::new("MINTER_ADMIN")?;
RoleBasedAccessControl::builder()
.role(RoleConfig::new(RoleBasedAccessControl::admin_role()).with_member(admin))
.role(RoleConfig::new(minter_role).with_admin(minter_admin_role))
.build()?;
Ok(())
}
#[rstest::rstest]
#[case::duplicate_role(
vec![
RoleConfig::new(role("MINTER")).with_member(test_admin(1)),
RoleConfig::new(role("MINTER")).with_member(test_admin(2)),
],
RoleBasedAccessControlError::DuplicateRole(role("MINTER")),
)]
#[case::empty_config(
vec![RoleConfig::new(role("MINTER"))],
RoleBasedAccessControlError::EmptyRoleConfig(role("MINTER")),
)]
#[case::unmanageable_role(
vec![
RoleConfig::new(role("MINTER")).with_admin(role("MINTER_ADMIN")),
RoleConfig::new(role("MINTER_ADMIN")).with_admin(role("MINTER_ADMIN")),
],
RoleBasedAccessControlError::UnmanageableRole {
role: role("MINTER"),
admin: role("MINTER_ADMIN"),
},
)]
#[case::empty_admins(
vec![RoleConfig::new(RoleBasedAccessControl::admin_role())],
RoleBasedAccessControlError::EmptyRoleConfig(RoleBasedAccessControl::admin_role()),
)]
#[case::unspecified_default_admin(
vec![RoleConfig::new(role("MINTER")).with_member(test_admin(1))],
RoleBasedAccessControlError::UnmanageableRole {
role: role("MINTER"),
admin: RoleBasedAccessControl::admin_role(),
},
)]
fn invalid_role_configs_are_rejected(
#[case] configs: Vec<RoleConfig>,
#[case] expected: RoleBasedAccessControlError,
) {
let error = RoleBasedAccessControl::builder()
.roles(configs)
.build()
.expect_err("initialization should have failed");
assert_eq!(error, expected);
}
}