diff --git a/src/components/admin_permission_manager.cairo b/src/components/admin_permission_manager.cairo new file mode 100644 index 0000000..d9f9e40 --- /dev/null +++ b/src/components/admin_permission_manager.cairo @@ -0,0 +1,561 @@ +//! Admin permission control system for managing administrative permissions in LittleFinger. + +/// ## A Starknet component responsible for managing administrative permissions within an +/// organization. +/// +/// This component handles: +/// - Permission granting and revoking +/// - Owner privilege management +/// - Permission validation and querying +/// - Bitmask operations for efficient storage +/// - Event emission for permission changes +/// +/// The component ensures that only authorized users can modify permissions and provides +/// fine-grained control over administrative capabilities within the organization. +#[starknet::component] +pub mod AdminPermissionManagerComponent { + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_caller_address}; + use crate::interfaces::iadmin_permission_manager::IAdminPermissionManager; + use crate::structs::admin_permissions::{ + AdminPermission, AdminPermissionGranted, AdminPermissionIntoFelt252, AdminPermissionRevoked, + AdminPermissionTrait, AllAdminPermissionsGranted, AllAdminPermissionsRevoked, + }; + + /// Defines the storage layout for the `AdminPermissionManagerComponent`. + #[storage] + pub struct Storage { + /// Maps (permission_felt252, admin_address) to boolean indicating if permission is granted. + /// This allows efficient lookup of specific permissions for specific admins. + pub admin_permissions: Map<(felt252, ContractAddress), bool>, + /// The owner address who has all permissions by default and cannot have permissions + /// revoked. + pub owner: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + AdminPermissionGranted: AdminPermissionGranted, + AdminPermissionRevoked: AdminPermissionRevoked, + AllAdminPermissionsGranted: AllAdminPermissionsGranted, + AllAdminPermissionsRevoked: AllAdminPermissionsRevoked, + } + + #[embeddable_as(AdminPermissionManagerImpl)] + impl AdminPermissionManager< + TContractState, +HasComponent, + > of IAdminPermissionManager> { + /// # has_admin_permission + /// + /// Checks if a specific admin has a particular permission. + /// The owner always has all permissions and returns true for any permission check. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `admin`: The contract address of the admin to check. + /// - `permission`: The specific permission to verify. + /// + /// ## Returns + /// + /// A boolean indicating whether the admin has the specified permission. + /// + /// ## Implementation Details + /// + /// - Owner check is performed first for efficiency + /// - Permission is converted to felt252 for storage lookup + /// - Uses efficient map lookup for permission verification + fn has_admin_permission( + self: @ComponentState, + admin: ContractAddress, + permission: AdminPermission, + ) -> bool { + if admin == self.owner.read() { + return true; + } + + let permission_felt: felt252 = permission.into(); + self.admin_permissions.entry((permission_felt, admin)).read() + } + + /// # get_admin_permissions + /// + /// Retrieves all permissions currently granted to a specific admin. + /// For the owner, returns all available permissions without checking storage. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `admin`: The contract address of the admin whose permissions to retrieve. + /// + /// ## Returns + /// + /// An array of `AdminPermission` values representing all permissions granted to the admin. + /// + /// ## Implementation Details + /// + /// - Owner receives all permissions automatically + /// - For non-owners, iterates through all possible permissions and checks each one + /// - Uses the `has_admin_permission` method for consistent permission checking + fn get_admin_permissions( + self: @ComponentState, admin: ContractAddress, + ) -> Array { + let mut permissions: Array = array![]; + + if admin == self.owner.read() { + return AdminPermissionTrait::get_all_permissions(); + } + + let all_permissions = AdminPermissionTrait::get_all_permissions(); + let mut i = 0; + while i != all_permissions.len() { + let permission = *all_permissions.at(i); + if self.has_admin_permission(admin, permission) { + permissions.append(permission); + } + i += 1; + } + + permissions + } + + /// # grant_admin_permission + /// + /// Grants a specific permission to an admin. This is a privileged action that requires + /// the caller to have GRANT_PERMISSIONS permission or be the owner. + /// + /// ## Parameters + /// + /// - `ref self: ComponentState`: The current state of the component. + /// - `admin`: The contract address of the admin to grant permission to. + /// - `permission`: The specific permission to grant. + /// + /// ## Authorization + /// + /// - Caller must be the owner OR have GRANT_PERMISSIONS permission + /// - Fails with 'Not authorized to grant' if authorization check fails + /// + /// ## Events + /// + /// Emits `AdminPermissionGranted` event if permission was not already granted. + /// + /// ## Implementation Details + /// + /// - Only grants permission if not already present (idempotent) + /// - Converts permission to felt252 for efficient storage + /// - Records the granter for audit purposes + fn grant_admin_permission( + ref self: ComponentState, + admin: ContractAddress, + permission: AdminPermission, + ) { + let caller = get_caller_address(); + + assert( + caller == self.owner.read() + || self.has_admin_permission(caller, AdminPermission::GRANT_PERMISSIONS), + 'Not authorized to grant', + ); + + let permission_felt: felt252 = permission.into(); + + if !self.admin_permissions.entry((permission_felt, admin)).read() { + self.admin_permissions.entry((permission_felt, admin)).write(true); + self + .emit( + Event::AdminPermissionGranted( + AdminPermissionGranted { + permission: permission_felt, admin, granted_by: caller, + }, + ), + ); + } + } + + /// # revoke_admin_permission + /// + /// Revokes a specific permission from an admin. This is a privileged action that requires + /// the caller to have REVOKE_PERMISSIONS permission or be the owner. Owner permissions + /// cannot be revoked. + /// + /// ## Parameters + /// + /// - `ref self: ComponentState`: The current state of the component. + /// - `admin`: The contract address of the admin to revoke permission from. + /// - `permission`: The specific permission to revoke. + /// + /// ## Authorization + /// + /// - Caller must be the owner OR have REVOKE_PERMISSIONS permission + /// - Cannot revoke permissions from the owner + /// - Fails with 'Not authorized to revoke' if authorization check fails + /// - Fails with 'Cannot revoke from owner' if attempting to revoke from owner + /// + /// ## Events + /// + /// Emits `AdminPermissionRevoked` event if permission was previously granted. + /// + /// ## Implementation Details + /// + /// - Only revokes permission if currently present (idempotent) + /// - Converts permission to felt252 for efficient storage + /// - Records the revoker for audit purposes + fn revoke_admin_permission( + ref self: ComponentState, + admin: ContractAddress, + permission: AdminPermission, + ) { + let caller = get_caller_address(); + + assert( + caller == self.owner.read() + || self.has_admin_permission(caller, AdminPermission::REVOKE_PERMISSIONS), + 'Not authorized to revoke', + ); + + assert(admin != self.owner.read(), 'Cannot revoke from owner'); + + let permission_felt: felt252 = permission.into(); + + if self.admin_permissions.entry((permission_felt, admin)).read() { + self.admin_permissions.entry((permission_felt, admin)).write(false); + self + .emit( + Event::AdminPermissionRevoked( + AdminPermissionRevoked { + permission: permission_felt, admin, revoked_by: caller, + }, + ), + ); + } + } + + /// # grant_all_admin_permissions + /// + /// Grants all available permissions to an admin at once. This is a privileged action + /// that requires the caller to have GRANT_PERMISSIONS permission or be the owner. + /// + /// ## Parameters + /// + /// - `ref self: ComponentState`: The current state of the component. + /// - `admin`: The contract address of the admin to grant all permissions to. + /// + /// ## Authorization + /// + /// - Caller must be the owner OR have GRANT_PERMISSIONS permission + /// - Fails with 'Not authorized to grant' if authorization check fails + /// + /// ## Events + /// + /// Emits `AllAdminPermissionsGranted` event after granting all permissions. + /// + /// ## Implementation Details + /// + /// - Iterates through all available permissions and grants each one + /// - Overwrites existing permissions (idempotent operation) + /// - More efficient than calling grant_admin_permission multiple times + /// - Records the granter for audit purposes + fn grant_all_admin_permissions( + ref self: ComponentState, admin: ContractAddress, + ) { + let caller = get_caller_address(); + + assert( + caller == self.owner.read() + || self.has_admin_permission(caller, AdminPermission::GRANT_PERMISSIONS), + 'Not authorized to grant', + ); + + let all_permissions = AdminPermissionTrait::get_all_permissions(); + let mut i = 0; + while i != all_permissions.len() { + let permission = *all_permissions.at(i); + let permission_felt: felt252 = permission.into(); + self.admin_permissions.entry((permission_felt, admin)).write(true); + i += 1; + } + + self + .emit( + Event::AllAdminPermissionsGranted( + AllAdminPermissionsGranted { admin, granted_by: caller }, + ), + ); + } + + /// # revoke_all_admin_permissions + /// + /// Revokes all permissions from an admin at once. This is a privileged action + /// that requires the caller to have REVOKE_PERMISSIONS permission or be the owner. + /// Owner permissions cannot be revoked. + /// + /// ## Parameters + /// + /// - `ref self: ComponentState`: The current state of the component. + /// - `admin`: The contract address of the admin to revoke all permissions from. + /// + /// ## Authorization + /// + /// - Caller must be the owner OR have REVOKE_PERMISSIONS permission + /// - Cannot revoke permissions from the owner + /// - Fails with 'Not authorized to revoke' if authorization check fails + /// - Fails with 'Cannot revoke from owner' if attempting to revoke from owner + /// + /// ## Events + /// + /// Emits `AllAdminPermissionsRevoked` event after revoking all permissions. + /// + /// ## Implementation Details + /// + /// - Iterates through all available permissions and revokes each one + /// - Sets all permissions to false (idempotent operation) + /// - More efficient than calling revoke_admin_permission multiple times + /// - Records the revoker for audit purposes + fn revoke_all_admin_permissions( + ref self: ComponentState, admin: ContractAddress, + ) { + let caller = get_caller_address(); + + assert( + caller == self.owner.read() + || self.has_admin_permission(caller, AdminPermission::REVOKE_PERMISSIONS), + 'Not authorized to revoke', + ); + + assert(admin != self.owner.read(), 'Cannot revoke from owner'); + + let all_permissions = AdminPermissionTrait::get_all_permissions(); + let mut i = 0; + while i != all_permissions.len() { + let permission = *all_permissions.at(i); + let permission_felt: felt252 = permission.into(); + self.admin_permissions.entry((permission_felt, admin)).write(false); + i += 1; + } + + self + .emit( + Event::AllAdminPermissionsRevoked( + AllAdminPermissionsRevoked { admin, revoked_by: caller }, + ), + ); + } + + /// # get_owner + /// + /// Retrieves the owner address of the contract. The owner has all permissions by default + /// and their permissions cannot be revoked. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// + /// ## Returns + /// + /// The `ContractAddress` of the contract owner. + /// + /// ## Implementation Details + /// + /// - Simple storage read operation + /// - Owner is set during component initialization + /// - Owner address is immutable after initialization + fn get_owner(self: @ComponentState) -> ContractAddress { + self.owner.read() + } + + /// # is_owner + /// + /// Checks if a given address is the contract owner. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `address`: The contract address to check for ownership. + /// + /// ## Returns + /// + /// A boolean indicating whether the address is the contract owner. + /// + /// ## Implementation Details + /// + /// - Performs simple address comparison + /// - Used internally for authorization checks + /// - More readable than direct owner comparison in calling code + fn is_owner(self: @ComponentState, address: ContractAddress) -> bool { + address == self.owner.read() + } + + /// # permissions_to_mask + /// + /// Converts an array of permissions to a bitmask representation for efficient storage and + /// operations. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `permissions`: An array of `AdminPermission` values to convert. + /// + /// ## Returns + /// + /// A `u16` bitmask representing the permissions. + /// + /// ## Implementation Details + /// + /// - Uses bitwise OR operations to combine individual permission masks + /// - Each permission has a unique bit position in the mask + /// - Allows compact representation of multiple permissions + /// - Useful for batch operations and efficient storage + fn permissions_to_mask( + self: @ComponentState, permissions: Array, + ) -> u16 { + let mut mask: u16 = 0; + let mut i = 0; + while i != permissions.len() { + let permission = *permissions.at(i); + mask = mask | permission.to_mask(); + i += 1; + } + mask + } + + /// # permissions_from_mask + /// + /// Converts a bitmask representation back to an array of permissions. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `mask`: A `u16` bitmask representing permissions. + /// + /// ## Returns + /// + /// An array of `AdminPermission` values represented by the bitmask. + /// + /// ## Implementation Details + /// + /// - Iterates through all possible permissions + /// - Checks each permission's bit in the mask using bitwise AND + /// - Reconstructs the original permission set from the compact representation + /// - Useful for converting stored masks back to usable permission arrays + fn permissions_from_mask( + self: @ComponentState, mask: u16, + ) -> Array { + let mut permissions_array: Array = array![]; + let all_permissions = AdminPermissionTrait::get_all_permissions(); + + let mut i = 0; + while i != all_permissions.len() { + let permission = *all_permissions.at(i); + if permission.has_permission_from_mask(mask) { + permissions_array.append(permission); + } + i += 1; + } + + permissions_array + } + + /// # is_valid_admin_mask + /// + /// Validates whether a bitmask represents a valid combination of admin permissions. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `mask`: A `u16` bitmask to validate. + /// + /// ## Returns + /// + /// A boolean indicating whether the bitmask is valid. + /// + /// ## Implementation Details + /// + /// - Checks if at least one valid permission bit is set in the mask + /// - Iterates through all known permissions to verify validity + /// - Returns false for empty masks (no permissions set) + /// - Useful for input validation and preventing invalid permission states + fn is_valid_admin_mask(self: @ComponentState, mask: u16) -> bool { + let all_permissions = AdminPermissionTrait::get_all_permissions(); + let mut valid = false; + + let mut i = 0; + while i != all_permissions.len() { + let permission = *all_permissions.at(i); + if permission.has_permission_from_mask(mask) { + valid = true; + break; + } + i += 1; + } + + valid + } + } + + /// # AdminPermissionManagerInternalTrait + /// + /// Internal implementation providing utility functions for the admin permission manager + /// component. + /// These functions are intended for use by the contract implementation and other components. + #[generate_trait] + pub impl AdminPermissionManagerInternalImpl< + TContractState, +HasComponent, + > of AdminPermissionManagerInternalTrait { + /// # initialize_admin_permissions + /// + /// Initializes the admin permission manager component by setting the contract owner. + /// This function should be called during contract construction. + /// + /// ## Parameters + /// + /// - `ref self: ComponentState`: The current state of the component. + /// - `owner`: The contract address to set as the owner. + /// + /// ## Implementation Details + /// + /// - Sets the owner address in storage + /// - Owner automatically has all permissions + /// - Should only be called once during contract initialization + /// - Owner address cannot be changed after initialization + fn initialize_admin_permissions( + ref self: ComponentState, owner: ContractAddress, + ) { + self.owner.write(owner); + } + + /// # require_admin_permission + /// + /// Utility function to enforce permission requirements in contract methods. + /// Reverts the transaction if the caller doesn't have the required permission. + /// + /// ## Parameters + /// + /// - `self: @ComponentState`: A snapshot of the component's state. + /// - `caller`: The contract address of the caller to check. + /// - `required_permission`: The permission that the caller must have. + /// + /// ## Panics + /// + /// Reverts with 'Insufficient admin permissions' if the caller doesn't have the required + /// permission. + /// + /// ## Implementation Details + /// + /// - Uses the `has_admin_permission` method for consistent permission checking + /// - Provides a convenient way to add permission checks to contract methods + /// - Owner automatically passes all permission checks + fn require_admin_permission( + self: @ComponentState, + caller: ContractAddress, + required_permission: AdminPermission, + ) { + assert( + self.has_admin_permission(caller, required_permission), + 'Insufficient admin permissions', + ); + } + } +} diff --git a/src/components/member_manager.cairo b/src/components/member_manager.cairo index 3c9c518..5ac70b3 100644 --- a/src/components/member_manager.cairo +++ b/src/components/member_manager.cairo @@ -369,14 +369,10 @@ pub mod MemberManagerComponent { ref self: ComponentState, member_id: u256, amount: u256, timestamp: u64, ) { let mut member_node = self.members.entry(member_id); - member_node - .total_received - .write(member_node.total_received.read() + 1); + member_node.total_received.write(member_node.total_received.read() + 1); member_node.no_of_payouts.write(member_node.no_of_payouts.read() + 1); member_node.last_disbursement_timestamp.write(timestamp); - member_node - .total_disbursements - .write(member_node.total_disbursements.read() + 1); + member_node.total_disbursements.write(member_node.total_disbursements.read() + 1); } /// Returns the address of the factory contract. diff --git a/src/components/organization.cairo b/src/components/organization.cairo index 0cdc162..8bdc4ae 100644 --- a/src/components/organization.cairo +++ b/src/components/organization.cairo @@ -198,7 +198,7 @@ pub mod OrganizationComponent { } /// Used to get a contract ipfs hash for access purpose - /// ### Returns + /// ### Returns /// - Contract: all the important info of the contract suitable for storage onchain. /// - The rest goes to IPFS fn get_contract(self: @ComponentState, contract_id: u256) -> Contract { diff --git a/src/contracts/core.cairo b/src/contracts/core.cairo index 24c3a56..c3b61c9 100644 --- a/src/contracts/core.cairo +++ b/src/contracts/core.cairo @@ -29,9 +29,7 @@ mod Core { use openzeppelin::upgrades::UpgradeableComponent; use openzeppelin::upgrades::interface::IUpgradeable; use starknet::storage::StoragePointerWriteAccess; - use starknet::{ - ClassHash, ContractAddress, get_block_timestamp, get_contract_address, - }; + use starknet::{ClassHash, ContractAddress, get_block_timestamp, get_contract_address}; use crate::interfaces::imember_manager::IMemberManager; component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); diff --git a/src/interfaces/iadmin_permission_manager.cairo b/src/interfaces/iadmin_permission_manager.cairo new file mode 100644 index 0000000..31242a9 --- /dev/null +++ b/src/interfaces/iadmin_permission_manager.cairo @@ -0,0 +1,169 @@ +use littlefinger::structs::admin_permissions::AdminPermission; +use starknet::ContractAddress; + +/// # IAdminPermissionManager +/// +/// This trait defines the public interface for an admin permission management component. +/// It outlines the essential functions for handling administrative permissions within an +/// organization, including granting, revoking, and querying permissions for administrators. This +/// interface enables fine-grained control over what actions different administrators can perform. +/// This interface is designed to be implemented by a Starknet component that manages +/// the lifecycle and assignment of administrative permissions. +#[starknet::interface] +pub trait IAdminPermissionManager { + /// # has_admin_permission + /// + /// Checks if a specific admin has a particular permission. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `admin`: The contract address of the admin to check. + /// - `permission`: The specific permission to verify. + /// + /// ## Returns + /// + /// A boolean indicating whether the admin has the specified permission. + fn has_admin_permission( + self: @TContractState, admin: ContractAddress, permission: AdminPermission, + ) -> bool; + + /// # get_admin_permissions + /// + /// Retrieves all permissions currently granted to a specific admin. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `admin`: The contract address of the admin whose permissions to retrieve. + /// + /// ## Returns + /// + /// An array of `AdminPermission` values representing all permissions granted to the admin. + fn get_admin_permissions( + self: @TContractState, admin: ContractAddress, + ) -> Array; + + /// # grant_admin_permission + /// + /// Grants a specific permission to an admin. This is a privileged action that requires + /// the caller to have GRANT_PERMISSIONS permission or be the owner. + /// + /// ## Parameters + /// + /// - `ref self: TContractState`: The current state of the contract. + /// - `admin`: The contract address of the admin to grant permission to. + /// - `permission`: The specific permission to grant. + fn grant_admin_permission( + ref self: TContractState, admin: ContractAddress, permission: AdminPermission, + ); + + /// # revoke_admin_permission + /// + /// Revokes a specific permission from an admin. This is a privileged action that requires + /// the caller to have GRANT_PERMISSIONS permission or be the owner. Owner permissions cannot be + /// revoked. + /// + /// ## Parameters + /// + /// - `ref self: TContractState`: The current state of the contract. + /// - `admin`: The contract address of the admin to revoke permission from. + /// - `permission`: The specific permission to revoke. + fn revoke_admin_permission( + ref self: TContractState, admin: ContractAddress, permission: AdminPermission, + ); + + /// # grant_all_admin_permissions + /// + /// Grants all available permissions to an admin at once. This is a privileged action + /// that requires the caller to have GRANT_PERMISSIONS permission or be the owner. + /// + /// ## Parameters + /// + /// - `ref self: TContractState`: The current state of the contract. + /// - `admin`: The contract address of the admin to grant all permissions to. + fn grant_all_admin_permissions(ref self: TContractState, admin: ContractAddress); + + /// # revoke_all_admin_permissions + /// + /// Revokes all permissions from an admin at once. This is a privileged action + /// that requires the caller to have GRANT_PERMISSIONS permission or be the owner. + /// Owner permissions cannot be revoked. + /// + /// ## Parameters + /// + /// - `ref self: TContractState`: The current state of the contract. + /// - `admin`: The contract address of the admin to revoke all permissions from. + fn revoke_all_admin_permissions(ref self: TContractState, admin: ContractAddress); + + /// # get_owner + /// + /// Retrieves the owner address of the contract. The owner has all permissions by default + /// and their permissions cannot be revoked. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// + /// ## Returns + /// + /// The `ContractAddress` of the contract owner. + fn get_owner(self: @TContractState) -> ContractAddress; + + /// # is_owner + /// + /// Checks if a given address is the contract owner. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `address`: The contract address to check for ownership. + /// + /// ## Returns + /// + /// A boolean indicating whether the address is the contract owner. + fn is_owner(self: @TContractState, address: ContractAddress) -> bool; + + /// # permissions_to_mask + /// + /// Converts an array of permissions to a bitmask representation for efficient storage and + /// operations. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `permissions`: An array of `AdminPermission` values to convert. + /// + /// ## Returns + /// + /// A `u16` bitmask representing the permissions. + fn permissions_to_mask(self: @TContractState, permissions: Array) -> u16; + + /// # permissions_from_mask + /// + /// Converts a bitmask representation back to an array of permissions. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `mask`: A `u16` bitmask representing permissions. + /// + /// ## Returns + /// + /// An array of `AdminPermission` values represented by the bitmask. + fn permissions_from_mask(self: @TContractState, mask: u16) -> Array; + + /// # is_valid_admin_mask + /// + /// Validates whether a bitmask represents a valid combination of admin permissions. + /// + /// ## Parameters + /// + /// - `self: @TContractState`: A snapshot of the contract's state. + /// - `mask`: A `u16` bitmask to validate. + /// + /// ## Returns + /// + /// A boolean indicating whether the bitmask is valid. + fn is_valid_admin_mask(self: @TContractState, mask: u16) -> bool; +} diff --git a/src/lib.cairo b/src/lib.cairo index 5224fb4..70554ad 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -5,6 +5,7 @@ pub mod contracts { } pub mod interfaces { pub mod dao_controller; + pub mod iadmin_permission_manager; pub mod icore; pub mod idisbursement; pub mod ifactory; @@ -14,6 +15,7 @@ pub mod interfaces { } pub mod components { + pub mod admin_permission_manager; pub mod dao_controller; pub mod disbursement; pub mod member_manager; @@ -21,6 +23,7 @@ pub mod components { } pub mod structs { + pub mod admin_permissions; pub mod base; pub mod core; pub mod dao_controller; @@ -32,10 +35,12 @@ pub mod structs { #[cfg(test)] pub mod tests { + pub mod test_admin_permission_manager; pub mod test_dao_controller; pub mod test_disbursement; pub mod test_member_manager; pub mod mocks { + pub mod mock_admin_permission_manager; pub mod mock_dao_controller; pub mod mock_disbursement; pub mod mock_member_manager; diff --git a/src/structs/admin_permissions.cairo b/src/structs/admin_permissions.cairo new file mode 100644 index 0000000..7e4ff32 --- /dev/null +++ b/src/structs/admin_permissions.cairo @@ -0,0 +1,145 @@ +use starknet::ContractAddress; + +/// Admin permission enums for different administrative actions +#[derive(Drop, Copy, Serde, PartialEq)] +pub enum AdminPermission { + ADD_MEMBER, + REMOVE_MEMBER, + SEND_MEMBER_INVITES, + SET_BASE_SALARIES, + CHANGE_BASE_SALARIES, + SET_DISBURSEMENT_SCHEDULES, + ADD_VAULT_TOKENS, + VAULT_FUNCTIONS, // All vault functions except deposit + GRANT_ADMIN_STATUS, + REVOKE_ADMIN_STATUS, + GRANT_PERMISSIONS, + REVOKE_PERMISSIONS, +} + +/// Trait for converting AdminPermission to felt252 for storage +pub impl AdminPermissionIntoFelt252 of Into { + fn into(self: AdminPermission) -> felt252 { + match self { + AdminPermission::ADD_MEMBER => 'ADD_MEMBER', + AdminPermission::REMOVE_MEMBER => 'REMOVE_MEMBER', + AdminPermission::SEND_MEMBER_INVITES => 'SEND_INVITES', + AdminPermission::SET_BASE_SALARIES => 'SET_SALARIES', + AdminPermission::CHANGE_BASE_SALARIES => 'CHANGE_SALARIES', + AdminPermission::SET_DISBURSEMENT_SCHEDULES => 'SET_SCHEDULES', + AdminPermission::ADD_VAULT_TOKENS => 'ADD_VAULT_TOKENS', + AdminPermission::VAULT_FUNCTIONS => 'VAULT_FUNCTIONS', + AdminPermission::GRANT_ADMIN_STATUS => 'GRANT_ADMIN_STATUS', + AdminPermission::REVOKE_ADMIN_STATUS => 'REVOKE_ADMIN_STATUS', + AdminPermission::GRANT_PERMISSIONS => 'GRANT_PERMISSIONS', + AdminPermission::REVOKE_PERMISSIONS => 'REVOKE_PERMISSIONS', + } + } +} + +/// Trait for converting felt252 back to AdminPermission +pub impl Felt252IntoAdminPermission of Into { + fn into(self: felt252) -> AdminPermission { + if self == 'ADD_MEMBER' { + AdminPermission::ADD_MEMBER + } else if self == 'REMOVE_MEMBER' { + AdminPermission::REMOVE_MEMBER + } else if self == 'SEND_INVITES' { + AdminPermission::SEND_MEMBER_INVITES + } else if self == 'SET_SALARIES' { + AdminPermission::SET_BASE_SALARIES + } else if self == 'CHANGE_SALARIES' { + AdminPermission::CHANGE_BASE_SALARIES + } else if self == 'SET_SCHEDULES' { + AdminPermission::SET_DISBURSEMENT_SCHEDULES + } else if self == 'ADD_VAULT_TOKENS' { + AdminPermission::ADD_VAULT_TOKENS + } else if self == 'VAULT_FUNCTIONS' { + AdminPermission::VAULT_FUNCTIONS + } else if self == 'GRANT_ADMIN_STATUS' { + AdminPermission::GRANT_ADMIN_STATUS + } else if self == 'REVOKE_ADMIN_STATUS' { + AdminPermission::REVOKE_ADMIN_STATUS + } else if self == 'GRANT_PERMISSIONS' { + AdminPermission::GRANT_PERMISSIONS + } else if self == 'REVOKE_PERMISSIONS' { + AdminPermission::REVOKE_PERMISSIONS + } else { + AdminPermission::ADD_MEMBER // Default fallback + } + } +} + +/// Trait for AdminPermission utilities +pub trait AdminPermissionTrait { + fn to_mask(self: AdminPermission) -> u16; + fn has_permission_from_mask(self: AdminPermission, mask: u16) -> bool; + fn get_all_permissions() -> Array; +} + +pub impl AdminPermissionImpl of AdminPermissionTrait { + fn to_mask(self: AdminPermission) -> u16 { + match self { + AdminPermission::ADD_MEMBER => 1, + AdminPermission::REMOVE_MEMBER => 2, + AdminPermission::SEND_MEMBER_INVITES => 4, + AdminPermission::SET_BASE_SALARIES => 8, + AdminPermission::CHANGE_BASE_SALARIES => 16, + AdminPermission::SET_DISBURSEMENT_SCHEDULES => 32, + AdminPermission::ADD_VAULT_TOKENS => 64, + AdminPermission::VAULT_FUNCTIONS => 128, + AdminPermission::GRANT_ADMIN_STATUS => 256, + AdminPermission::REVOKE_ADMIN_STATUS => 512, + AdminPermission::GRANT_PERMISSIONS => 1024, + AdminPermission::REVOKE_PERMISSIONS => 2048, + } + } + + fn has_permission_from_mask(self: AdminPermission, mask: u16) -> bool { + (mask & self.to_mask()) != 0 + } + + fn get_all_permissions() -> Array { + array![ + AdminPermission::ADD_MEMBER, + AdminPermission::REMOVE_MEMBER, + AdminPermission::SEND_MEMBER_INVITES, + AdminPermission::SET_BASE_SALARIES, + AdminPermission::CHANGE_BASE_SALARIES, + AdminPermission::SET_DISBURSEMENT_SCHEDULES, + AdminPermission::ADD_VAULT_TOKENS, + AdminPermission::VAULT_FUNCTIONS, + AdminPermission::GRANT_ADMIN_STATUS, + AdminPermission::REVOKE_ADMIN_STATUS, + AdminPermission::GRANT_PERMISSIONS, + AdminPermission::REVOKE_PERMISSIONS, + ] + } +} + +/// Events for permission management +#[derive(Drop, starknet::Event)] +pub struct AdminPermissionGranted { + pub permission: felt252, + pub admin: ContractAddress, + pub granted_by: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct AdminPermissionRevoked { + pub permission: felt252, + pub admin: ContractAddress, + pub revoked_by: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct AllAdminPermissionsGranted { + pub admin: ContractAddress, + pub granted_by: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct AllAdminPermissionsRevoked { + pub admin: ContractAddress, + pub revoked_by: ContractAddress, +} diff --git a/src/tests/mocks/mock_admin_permission_manager.cairo b/src/tests/mocks/mock_admin_permission_manager.cairo new file mode 100644 index 0000000..101eac2 --- /dev/null +++ b/src/tests/mocks/mock_admin_permission_manager.cairo @@ -0,0 +1,36 @@ +#[starknet::contract] +pub mod MockAdminPermissionManager { + use littlefinger::components::admin_permission_manager::AdminPermissionManagerComponent; + use starknet::ContractAddress; + + component!( + path: AdminPermissionManagerComponent, + storage: admin_permissions, + event: AdminPermissionManagerEvent, + ); + + #[abi(embed_v0)] + pub impl AdminPermissionManagerImpl = + AdminPermissionManagerComponent::AdminPermissionManagerImpl; + + pub impl AdminPermissionManagerInternalImpl = + AdminPermissionManagerComponent::AdminPermissionManagerInternalImpl; + + #[storage] + pub struct Storage { + #[substorage(v0)] + pub admin_permissions: AdminPermissionManagerComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + AdminPermissionManagerEvent: AdminPermissionManagerComponent::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, owner: ContractAddress) { + self.admin_permissions.initialize_admin_permissions(owner); + } +} diff --git a/src/tests/test_admin_permission_manager.cairo b/src/tests/test_admin_permission_manager.cairo new file mode 100644 index 0000000..bc6d22c --- /dev/null +++ b/src/tests/test_admin_permission_manager.cairo @@ -0,0 +1,496 @@ +use littlefinger::components::admin_permission_manager::AdminPermissionManagerComponent; +use littlefinger::interfaces::iadmin_permission_manager::{ + IAdminPermissionManagerDispatcher, IAdminPermissionManagerDispatcherTrait, +}; +use littlefinger::structs::admin_permissions::{ + AdminPermission, AdminPermissionGranted, AdminPermissionRevoked, AdminPermissionTrait, + AllAdminPermissionsGranted, AllAdminPermissionsRevoked, +}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, declare, spy_events, + start_cheat_caller_address, stop_cheat_caller_address, +}; +use starknet::{ContractAddress, contract_address_const}; + +fn deploy_mock_admin_permission_manager() -> IAdminPermissionManagerDispatcher { + let owner: ContractAddress = owner(); + let contract_class = declare("MockAdminPermissionManager").unwrap().contract_class(); + let mut calldata = array![owner.into()]; + let (contract_address, _) = contract_class.deploy(@calldata.into()).unwrap(); + IAdminPermissionManagerDispatcher { contract_address } +} + +fn owner() -> ContractAddress { + contract_address_const::<'owner'>() +} + +fn admin1() -> ContractAddress { + contract_address_const::<'admin1'>() +} + +fn admin2() -> ContractAddress { + contract_address_const::<'admin2'>() +} + +fn unauthorized_user() -> ContractAddress { + contract_address_const::<'unauthorized'>() +} + +#[test] +fn test_owner_initialization() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + + // Owner should be set correctly + assert(permission_manager.get_owner() == owner_addr, 'Owner not set correctly'); + assert(permission_manager.is_owner(owner_addr), 'Owner check failed'); + + // Owner should have all permissions by default + assert( + permission_manager.has_admin_permission(owner_addr, AdminPermission::ADD_MEMBER), + 'Owner missing ADD_MEMBER', + ); + assert( + permission_manager.has_admin_permission(owner_addr, AdminPermission::REMOVE_MEMBER), + 'Owner missing REMOVE_MEMBER', + ); + assert( + permission_manager.has_admin_permission(owner_addr, AdminPermission::GRANT_PERMISSIONS), + 'Owner missing GRANT_PERMS', + ); + + // Get all permissions for owner + let owner_permissions = permission_manager.get_admin_permissions(owner_addr); + let all_permissions = AdminPermissionTrait::get_all_permissions(); + assert(owner_permissions.len() == all_permissions.len(), 'Owner missing permissions'); +} + +#[test] +fn test_grant_single_permission() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Initially admin1 should not have ADD_MEMBER permission + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Admin1 no initial perm', + ); + + // Grant ADD_MEMBER permission to admin1 + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + // Now admin1 should have ADD_MEMBER permission + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Admin1 has ADD_MEMBER', + ); + + // Admin1 should not have other permissions + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::REMOVE_MEMBER), + 'Admin1 no REMOVE_MEMBER', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_revoke_single_permission() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant permission first + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Permission should be granted', + ); + + // Revoke the permission + permission_manager.revoke_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + // Permission should be revoked + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Permission should be revoked', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_grant_all_permissions() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant all permissions to admin1 + permission_manager.grant_all_admin_permissions(admin1_addr); + + // Admin1 should now have all permissions + let admin1_permissions = permission_manager.get_admin_permissions(admin1_addr); + let all_permissions = AdminPermissionTrait::get_all_permissions(); + assert(admin1_permissions.len() == all_permissions.len(), 'Admin1 has all perms'); + + // Check specific permissions + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Admin1 missing ADD_MEMBER', + ); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::VAULT_FUNCTIONS), + 'Admin1 missing VAULT_FUNCS', + ); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::GRANT_PERMISSIONS), + 'Admin1 missing GRANT_PERMS', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_revoke_all_permissions() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant all permissions first + permission_manager.grant_all_admin_permissions(admin1_addr); + + // Verify admin1 has permissions + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'Admin1 should have permissions', + ); + + // Revoke all permissions + permission_manager.revoke_all_admin_permissions(admin1_addr); + + // Admin1 should have no permissions + let admin1_permissions = permission_manager.get_admin_permissions(admin1_addr); + assert(admin1_permissions.len() == 0, 'Admin1 has no perms'); + + // Check specific permissions are revoked + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'ADD_MEMBER should be revoked', + ); + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::GRANT_PERMISSIONS), + 'GRANT_PERMS revoked', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +#[should_panic(expected: 'Not authorized to grant')] +fn test_unauthorized_grant_permission() { + let permission_manager = deploy_mock_admin_permission_manager(); + let unauthorized_addr = unauthorized_user(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, unauthorized_addr); + + // This should fail as unauthorized user cannot grant permissions + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +#[should_panic(expected: 'Not authorized to revoke')] +fn test_unauthorized_revoke_permission() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let unauthorized_addr = unauthorized_user(); + let admin1_addr = admin1(); + + // First grant permission as owner + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + stop_cheat_caller_address(permission_manager.contract_address); + + // Try to revoke as unauthorized user - should fail + start_cheat_caller_address(permission_manager.contract_address, unauthorized_addr); + permission_manager.revoke_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +#[should_panic(expected: 'Cannot revoke from owner')] +fn test_cannot_revoke_from_owner() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // This should fail as owner permissions cannot be revoked + permission_manager.revoke_admin_permission(owner_addr, AdminPermission::ADD_MEMBER); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_delegated_permission_management() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + let admin2_addr = admin2(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant GRANT_PERMISSIONS to admin1 + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::GRANT_PERMISSIONS); + + stop_cheat_caller_address(permission_manager.contract_address); + + // Now admin1 should be able to grant permissions to admin2 + start_cheat_caller_address(permission_manager.contract_address, admin1_addr); + + permission_manager.grant_admin_permission(admin2_addr, AdminPermission::ADD_MEMBER); + + // Verify admin2 has the permission + assert( + permission_manager.has_admin_permission(admin2_addr, AdminPermission::ADD_MEMBER), + 'Admin2 has ADD_MEMBER', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_permission_bitmask_operations() { + let permission_manager = deploy_mock_admin_permission_manager(); + + // Test permissions to mask conversion + let permissions = array![ + AdminPermission::ADD_MEMBER, + AdminPermission::REMOVE_MEMBER, + AdminPermission::GRANT_PERMISSIONS, + ]; + + let mask = permission_manager.permissions_to_mask(permissions); + assert(mask > 0, 'Mask should be non-zero'); + + // Test mask to permissions conversion + let converted_permissions = permission_manager.permissions_from_mask(mask); + assert(converted_permissions.len() == 3, 'Should have 3 permissions'); + + // Test mask validation + assert(permission_manager.is_valid_admin_mask(mask), 'Mask should be valid'); + assert(!permission_manager.is_valid_admin_mask(0), 'Zero mask should be invalid'); +} + +#[test] +fn test_event_emission_on_grant() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + let mut spy = spy_events(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant permission and check event + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + spy + .assert_emitted( + @array![ + ( + permission_manager.contract_address, + AdminPermissionManagerComponent::Event::AdminPermissionGranted( + AdminPermissionGranted { + permission: AdminPermission::ADD_MEMBER.into(), + admin: admin1_addr, + granted_by: owner_addr, + }, + ), + ), + ], + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_event_emission_on_revoke() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant permission first + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + let mut spy = spy_events(); + + // Revoke permission and check event + permission_manager.revoke_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + + spy + .assert_emitted( + @array![ + ( + permission_manager.contract_address, + AdminPermissionManagerComponent::Event::AdminPermissionRevoked( + AdminPermissionRevoked { + permission: AdminPermission::ADD_MEMBER.into(), + admin: admin1_addr, + revoked_by: owner_addr, + }, + ), + ), + ], + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_event_emission_on_grant_all() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + let mut spy = spy_events(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant all permissions and check event + permission_manager.grant_all_admin_permissions(admin1_addr); + + spy + .assert_emitted( + @array![ + ( + permission_manager.contract_address, + AdminPermissionManagerComponent::Event::AllAdminPermissionsGranted( + AllAdminPermissionsGranted { admin: admin1_addr, granted_by: owner_addr }, + ), + ), + ], + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_event_emission_on_revoke_all() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant all permissions first + permission_manager.grant_all_admin_permissions(admin1_addr); + + let mut spy = spy_events(); + + // Revoke all permissions and check event + permission_manager.revoke_all_admin_permissions(admin1_addr); + + spy + .assert_emitted( + @array![ + ( + permission_manager.contract_address, + AdminPermissionManagerComponent::Event::AllAdminPermissionsRevoked( + AllAdminPermissionsRevoked { admin: admin1_addr, revoked_by: owner_addr }, + ), + ), + ], + ); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_get_admin_permissions_empty() { + let permission_manager = deploy_mock_admin_permission_manager(); + let admin1_addr = admin1(); + + // Admin1 should have no permissions initially + let permissions = permission_manager.get_admin_permissions(admin1_addr); + assert(permissions.len() == 0, 'Admin1 has no perms'); +} + +#[test] +fn test_get_admin_permissions_partial() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant specific permissions + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::SET_BASE_SALARIES); + + let admin1_permissions = permission_manager.get_admin_permissions(admin1_addr); + assert(admin1_permissions.len() == 2, 'Admin1 has 2 perms'); + + stop_cheat_caller_address(permission_manager.contract_address); +} + +#[test] +fn test_multiple_permission_operations() { + let permission_manager = deploy_mock_admin_permission_manager(); + let owner_addr = owner(); + let admin1_addr = admin1(); + + start_cheat_caller_address(permission_manager.contract_address, owner_addr); + + // Grant multiple permissions individually + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER); + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::REMOVE_MEMBER); + permission_manager.grant_admin_permission(admin1_addr, AdminPermission::SET_BASE_SALARIES); + + // Verify all are granted + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'ADD_MEMBER should be granted', + ); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::REMOVE_MEMBER), + 'REMOVE_MEMBER should be granted', + ); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::SET_BASE_SALARIES), + 'SET_BASE_SALARIES granted', + ); + + // Revoke one permission + permission_manager.revoke_admin_permission(admin1_addr, AdminPermission::REMOVE_MEMBER); + + // Verify selective revocation + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::ADD_MEMBER), + 'ADD_MEMBER still granted', + ); + assert( + !permission_manager.has_admin_permission(admin1_addr, AdminPermission::REMOVE_MEMBER), + 'REMOVE_MEMBER should be revoked', + ); + assert( + permission_manager.has_admin_permission(admin1_addr, AdminPermission::SET_BASE_SALARIES), + 'SET_BASE_SALARIES granted', + ); + + stop_cheat_caller_address(permission_manager.contract_address); +}