diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce8c291..158bc10 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,51 +7,17 @@ on: pull_request: jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Python setup - uses: actions/setup-python@v3 - with: - python-version: '3.9' - cache: pip - cache-dependency-path: '**/requirements.txt' - - - name: Env setup - run: pip install -r requirements.txt - - - name: Lint Cairo code - run: find contracts -type f -name '*.cairo' | xargs cairo-format -c - - - name: Run black - run: black --check . - - - name: Run flake8 - run: flake8 - - - name: Run isort - run: isort --check-only --diff tests/ - test: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Python setup - uses: actions/setup-python@v3 + - uses: actions/checkout@v3 + - uses: software-mansion/setup-scarb@v1 with: - python-version: '3.9' - cache: pip - cache-dependency-path: '**/requirements.txt' - - - name: Env setup - run: pip install -r requirements.txt + scarb-version: "2.3.1" + - run: scarb fmt --check + - run: scarb build - - name: Run tests - run: pytest -sv -r A tests + - uses: foundry-rs/setup-snfoundry@v2 + with: + starknet-foundry-version: "0.11.0" + - run: snforge test diff --git a/.gitignore b/.gitignore index 5c6afb6..302fe0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -__pycache__ -.pytest_cache +target/ -# Hypothesis -.hypothesis +.snfoundry_cache/ \ No newline at end of file diff --git a/README.md b/README.md index a54d7c4..9fdb314 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,115 @@ # Member-based access control library for Cairo -![tests](https://github.com/lindy-labs/cairo-accesscontrol/actions/workflows/tests.yml/badge.svg) +![tests](https://github.com/lindy-labs/access_control/actions/workflows/tests.yml/badge.svg) -This library is an implementation of member-based access control in Cairo for [StarkNet](https://www.cairo-lang.org/docs/), which allows an address to be assigned multiple roles using a single storage mapping. +This library implements member-based access control as a component in Cairo for [Starknet](https://www.cairo-lang.org/docs/), which allows an address to be assigned multiple roles using a single storage mapping and in a single transaction, saving on storage and transaction costs. -The design of this library was originally inspired by OpenZeppelin's [access control library](https://github.com/OpenZeppelin/cairo-contracts/tree/main/src/openzeppelin/access/accesscontrol), as well as Python's [flags](https://docs.python.org/3/library/enum.html) and Vyper's [enums](https://docs.python.org/3/library/enum.html). +The design of this library was originally inspired by OpenZeppelin's [access control library](https://github.com/OpenZeppelin/cairo-contracts), as well as Python's [flags](https://docs.python.org/3/library/enum.html) and Vyper's [enums](https://docs.vyperlang.org/en/stable/types.html#enums). ## Overview -This library uses felt values in the form of 2n, where `n` is in the range `0 <= n <= 251`, to represent user-defined roles as members. +This library uses `u128` values in the form of 2n, where `n` is in the range `0 <= n < 128`, to represent user-defined roles as members. The primary benefit of this approach is that multiple roles can be granted or revoked using a single storage variable and in a single transaction, saving on storage and transaction costs. The only drawback is that users are limited to 128 roles per contract. -Roles should be defined in a separate Cairo contract as its own namespace. For example: +Note that this access control library also relies on an admin address with superuser privileges i.e. the admin can grant or revoke any roles for any address, including the admin itself. This may introduce certain trust assumptions for the admin depending on your usage of the library. + +We recommend users to define the roles in a separate Cairo file. For example: ```cairo -namespace Roles { - const MANAGER = 2 ** 0; - const STAFF = 2 ** 1; - const USER = 2 ** 2; +mod user_roles { + const MANAGER: u128 = 1; + const STAFF: u128 = 2; + const USER: u128 = 4; } ``` -Multiple roles can be represented as a single value by performing bitwise AND. Using the above example, an address can be assigned both the `MANAGER` and `STAFF` roles using a single value of 3 (equivalent to `Roles.MANAGER | Roles.STAFF` or `2 ** 0 + 2 ** 1`). +Multiple roles can be represented as a single value by performing bitwise AND. Using the above example, an address can be assigned both the `MANAGER` and `STAFF` roles using a single value of 3 (equivalent to `user_roles::MANAGER | user_roles::STAFF` or `2 ** 0 + 2 ** 1`). Similarly, multiple roles can be granted, revoked or checked for in a single transaction using bitwise operations: - granting role(s) is a bitwise AND operation of the currently assigned value and the value of the new role(s); - revoking role(s) is a bitwise AND operation of the currently assigned value and the complement (bitwise NOT) of the value of the role(s) to be revoked; and - checking for membership is a bitwise OR operation of the currently assigned value and the value of the role(s) being checked for. -Note that functions which rely on this access control library will require the `bitwise_ptr` implicit argument and `BitwiseBuiltin`. - - ## Usage -To use this library in a Cairo contract: -1. Include a copy of `accesscontrol_library.cairo` in your project, and import the library into the Cairo contract. -2. Define the available roles as constants in a namespace in a separate Cairo contract, and import this namespace into the Cairo contract. +To use this library, add the repository as a dependency in your `Scarb.toml`: -For example, assuming you have a `contracts/` folder with `accesscontrol_library.cairo` and `roles.cairo`, and you want to import both into a Cairo file within the same folder: +``` +[dependencies] +access_control = { git = "https://github.com/lindy-labs/access_control.git" } +``` +Next, define the available roles in a separate Cairo file: ```cairo -%lang starknet - -from starkware.cairo.common.cairo_builtins import BitwiseBuiltin, HashBuiltin -from contracts.accesscontrol_library import AccessControl -from contracts.roles import Roles - -@view -func is_manager{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(user: felt) -> (authorized: felt) { - let authorized: felt = AccessControl.has_role(Roles.MANAGER, user); - return (authorized,); +mod user_roles { + const MANAGER: u128 = 1; + const STAFF: u128 = 2; + const USER: u128 = 4; } +``` +then import both the component and the roles into your Cairo contract. + +For example, assuming you have a project named `my_project` in the top-level `Scarb.toml`, and a `src/` folder with the roles defined in a `user_roles` module in `roles.cairo`: +``` +use starknet::ContractAddress; -@external -func authorize{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin*}( - role: felt, user: felt -) { - AccessControl.assert_admin(); - AccessControl._grant_role(role, user); - return (); +#[starknet::interface] +trait IMockContract { + fn is_manager(self: @TContractState, user: ContractAddress) -> bool; } -@external -func manager_only_action{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin*}() { - AccessControl.assert_has_role(Roles.MANAGER); - // Insert logic here - return (); +#[starknet::contract] +mod mock_contract { + use access_control::access_control_component; + use my_project::roles::user_roles; + use starknet::ContractAddress; + use super::IMockContract; + + component!(path: access_control_component, storage: access_control, event: AccessControlEvent); + + #[abi(embed_v0)] + impl AccessControlPublic = access_control_component::AccessControl; + impl AccessControlHelpers = access_control_component::AccessControlHelpers; + + #[storage] + struct Storage { + #[substorage(v0)] + access_control: access_control_component::Storage + } + + #[event] + #[derive(Copy, Drop, starknet::Event)] + enum Event { + AccessControlEvent: access_control_component::Event + } + + #[constructor] + fn constructor(ref self: ContractState, admin: ContractAddress, roles: Option) { + self.access_control.initializer(admin, roles); + } + + #[abi(embed_v0)] + impl IMockContractImpl of IMockContract { + fn is_manager(self: @ContractState, user: ContractAddress) -> bool { + self.access_control.has_role(user_roles::MANAGER, user) + } + } } ``` -You can also refer to the test file `tests/test_accesscontrol.cairo` for another example. - -We have also included a set of external and view functions in `accesscontrol_external.cairo` that you can import into your Cairo contracts. - ## Development -### Set up the project +### Prerequisites -Clone the repository - -```bash -git clone git@github.com:lindy-labs/cairo-accesscontrol.git -``` - -`cd` into it and create a Python virtual environment: - -```bash -cd cairo-accesscontrol -python3 -m venv env -source env/bin/activate -``` - -Install dependencies: - -```bash -python -m pip install -r requirements.txt -``` +- [Cairo](https://github.com/starkware-libs/cairo) +- [Scarb](https://docs.swmansion.com/scarb) +- [Starknet Foundry](https://github.com/foundry-rs/starknet-foundry) ### Run tests To run the tests: ```bash -pytest +scarb test ``` ## Formal Verification diff --git a/Scarb.lock b/Scarb.lock new file mode 100644 index 0000000..63325eb --- /dev/null +++ b/Scarb.lock @@ -0,0 +1,14 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "access_control" +version = "0.1.0" +dependencies = [ + "snforge_std", +] + +[[package]] +name = "snforge_std" +version = "0.1.0" +source = "git+https://github.com/foundry-rs/starknet-foundry.git?tag=v0.11.0#5465c41541c44a7804d16318fab45a2f0ccec9e7" diff --git a/Scarb.toml b/Scarb.toml new file mode 100644 index 0000000..7ca8e5c --- /dev/null +++ b/Scarb.toml @@ -0,0 +1,23 @@ +[package] +name = "access_control" +version = "0.1.0" +cairo-version = "2.3.1" +authors = ["Lindy Labs"] +description = "Member-based access control component for Cairo" +readme = "README.md" +repository = "https://github.com/lindy-labs/access_control" +license-file = "LICENSE" +keywords = ["access control", "authorization", "cairo", "starknet"] + +[dependencies] +starknet = "2.3.1" +snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.11.0" } + +[lib] + +[tool.fmt] +sort-module-level-items = true +max-line-length = 120 + +[scripts] +test = "snforge test" \ No newline at end of file diff --git a/contracts/accesscontrol_external.cairo b/contracts/accesscontrol_external.cairo deleted file mode 100644 index 0dd1a63..0000000 --- a/contracts/accesscontrol_external.cairo +++ /dev/null @@ -1,70 +0,0 @@ -%lang starknet - -from starkware.cairo.common.cairo_builtins import BitwiseBuiltin, HashBuiltin - -from contracts.accesscontrol_library import AccessControl -from contracts.aliases import address, bool, ufelt - -// -// Getters -// - -@view -func get_roles{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(account: address) -> (roles: ufelt) { - let roles: ufelt = AccessControl.get_roles(account); - return (roles,); -} - -@view -func has_role{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(role: ufelt, account) -> (has_role: bool) { - let has_role: bool = AccessControl.has_role(role, account); - return (has_role,); -} - -@view -func get_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() -> ( - admin: address -) { - let admin: address = AccessControl.get_admin(); - return (admin,); -} - -// -// External -// - -@external -func grant_role{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(role: ufelt, account: address) { - AccessControl.grant_role(role, account); - return (); -} - -@external -func revoke_role{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(role: ufelt, account: address) { - AccessControl.revoke_role(role, account); - return (); -} - -@external -func renounce_role{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(role: ufelt, account: address) { - AccessControl.renounce_role(role, account); - return (); -} - -@external -func change_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( - new_admin: address -) { - AccessControl.change_admin(new_admin); - return (); -} diff --git a/contracts/accesscontrol_library.cairo b/contracts/accesscontrol_library.cairo deleted file mode 100644 index d158ce5..0000000 --- a/contracts/accesscontrol_library.cairo +++ /dev/null @@ -1,196 +0,0 @@ -%lang starknet - -from starkware.starknet.common.syscalls import get_caller_address -from starkware.cairo.common.cairo_builtins import BitwiseBuiltin, HashBuiltin -from starkware.cairo.common.bitwise import bitwise_and, bitwise_not, bitwise_or -from starkware.cairo.common.bool import TRUE -from starkware.cairo.common.math_cmp import is_not_zero - -from contracts.aliases import address, bool, ufelt - -// -// Events -// - -@event -func RoleGranted(role: ufelt, account: address) { -} - -@event -func RoleRevoked(role: ufelt, account: address) { -} - -@event -func AdminChanged(prev_admin: address, new_admin: address) { -} - -// -// Storage -// - -@storage_var -func accesscontrol_admin() -> (admin: address) { -} - -@storage_var -func accesscontrol_roles(account: address) -> (role: ufelt) { -} - -namespace AccessControl { - // - // Initializer - // - - func initializer{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( - admin: address - ) { - _set_admin(admin); - return (); - } - - // - // Modifier - // - - func assert_has_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt) { - alloc_locals; - let (caller: address) = get_caller_address(); - let authorized: bool = has_role(role, caller); - with_attr error_message("AccessControl: caller is missing role {role}") { - assert authorized = TRUE; - } - return (); - } - - func assert_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() { - alloc_locals; - let (caller: address) = get_caller_address(); - let admin: address = get_admin(); - with_attr error_message("AccessControl: caller is not admin") { - assert caller = admin; - } - return (); - } - - // - // Getters - // - - func get_roles{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( - account: address - ) -> ufelt { - let (roles: ufelt) = accesscontrol_roles.read(account); - return roles; - } - - func has_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt, account: address) -> bool { - let (roles: ufelt) = accesscontrol_roles.read(account); - // masks roles such that all bits are zero, except the bit(s) representing `role`, which may be zero or one - let (masked_roles: ufelt) = bitwise_and(roles, role); - let authorized: bool = is_not_zero(masked_roles); - return authorized; - } - - func get_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}() -> address { - let (admin: address) = accesscontrol_admin.read(); - return admin; - } - - // - // Externals - // - - func grant_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt, account: address) { - assert_admin(); - _grant_role(role, account); - return (); - } - - func revoke_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role, account) { - assert_admin(); - _revoke_role(role, account); - return (); - } - - func renounce_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt, account: address) { - let (caller: address) = get_caller_address(); - with_attr error_message("AccessControl: can only renounce roles for self") { - assert account = caller; - } - _revoke_role(role, account); - return (); - } - - func change_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( - new_admin: address - ) { - assert_admin(); - _set_admin(new_admin); - return (); - } - - // - // Unprotected - // - - func _grant_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt, account: address) { - let (roles: ufelt) = accesscontrol_roles.read(account); - let (updated_roles: ufelt) = bitwise_or(roles, role); - accesscontrol_roles.write(account, updated_roles); - RoleGranted.emit(role, account); - return (); - } - - func _revoke_role{ - syscall_ptr: felt*, - pedersen_ptr: HashBuiltin*, - range_check_ptr, - bitwise_ptr: BitwiseBuiltin*, - }(role: ufelt, account: address) { - let (roles: ufelt) = accesscontrol_roles.read(account); - let (revoked_complement: ufelt) = bitwise_not(role); - let (updated_roles: ufelt) = bitwise_and(roles, revoked_complement); - accesscontrol_roles.write(account, updated_roles); - RoleRevoked.emit(role, account); - return (); - } - - func _set_admin{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}( - new_admin: address - ) { - let prev_admin: address = get_admin(); - accesscontrol_admin.write(new_admin); - AdminChanged.emit(prev_admin, new_admin); - return (); - } -} diff --git a/contracts/aliases.cairo b/contracts/aliases.cairo deleted file mode 100644 index 8046bcf..0000000 --- a/contracts/aliases.cairo +++ /dev/null @@ -1,3 +0,0 @@ -using bool = felt; // 0 or 1 -using address = felt; // a StarkNet address -using ufelt = felt; // 'regular' felt diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 80809eb..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,4 +0,0 @@ -[tool.black] -line-length = 120 -target-version = ['py39'] -include = '\.pyi?$' diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2f0f4de..0000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -black==22.* -cairo-lang==0.10.0 -ecdsa==0.17.0 -fastecdsa==2.2.1 -flake8==4 -hypothesis==6.49.1 -isort==5 -marshmallow-dataclass==8.5.3 -pytest==7 -pytest-asyncio==0.15.1 -toml==0.10.2 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c4f0c7c..0000000 --- a/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -max-line-length=120 - -[isort] -profile=black -line_length=120 -multi_line_output=3 -use_parentheses=True -ensure_newline_before_comments=True -include_trailing_comma=True -known_first_party=tests diff --git a/src/access_control.cairo b/src/access_control.cairo new file mode 100644 index 0000000..c025e80 --- /dev/null +++ b/src/access_control.cairo @@ -0,0 +1,179 @@ +use starknet::ContractAddress; + +#[starknet::interface] +trait IAccessControl { + fn get_roles(self: @TContractState, account: ContractAddress) -> u128; + fn has_role(self: @TContractState, role: u128, account: ContractAddress) -> bool; + fn get_admin(self: @TContractState) -> ContractAddress; + fn get_pending_admin(self: @TContractState) -> ContractAddress; + fn grant_role(ref self: TContractState, role: u128, account: ContractAddress); + fn revoke_role(ref self: TContractState, role: u128, account: ContractAddress); + fn renounce_role(ref self: TContractState, role: u128); + fn set_pending_admin(ref self: TContractState, new_admin: ContractAddress); + fn accept_admin(ref self: TContractState); +} + +#[starknet::component] +mod access_control_component { + use starknet::contract_address::ContractAddressZeroable; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + admin: ContractAddress, + pending_admin: ContractAddress, + roles: LegacyMap:: + } + + #[event] + #[derive(Copy, Drop, starknet::Event, PartialEq)] + enum Event { + AdminChanged: AdminChanged, + NewPendingAdmin: NewPendingAdmin, + RoleGranted: RoleGranted, + RoleRevoked: RoleRevoked, + } + + #[derive(Copy, Drop, starknet::Event, PartialEq)] + struct AdminChanged { + old_admin: ContractAddress, + new_admin: ContractAddress + } + + #[derive(Copy, Drop, starknet::Event, PartialEq)] + struct NewPendingAdmin { + new_admin: ContractAddress + } + + #[derive(Copy, Drop, starknet::Event, PartialEq)] + struct RoleGranted { + user: ContractAddress, + role_granted: u128 + } + + #[derive(Copy, Drop, starknet::Event, PartialEq)] + struct RoleRevoked { + user: ContractAddress, + role_revoked: u128 + } + + #[embeddable_as(AccessControl)] + impl AccessControlPublic< + TContractState, +HasComponent + > of super::IAccessControl> { + // + // getters + // + + fn get_roles(self: @ComponentState, account: ContractAddress) -> u128 { + self.roles.read(account) + } + + fn has_role(self: @ComponentState, role: u128, account: ContractAddress) -> bool { + let roles: u128 = self.roles.read(account); + // masks roles such that all bits are zero, except the bit(s) representing `role`, which may be zero or one + let masked_roles: u128 = roles & role; + // if masked_roles is non-zero, the account has the queried role + masked_roles != 0 + } + + fn get_admin(self: @ComponentState) -> ContractAddress { + self.admin.read() + } + + fn get_pending_admin(self: @ComponentState) -> ContractAddress { + self.pending_admin.read() + } + + // + // setters + // + + fn grant_role(ref self: ComponentState, role: u128, account: ContractAddress) { + self.assert_admin(); + self.grant_role_helper(role, account); + } + + fn revoke_role(ref self: ComponentState, role: u128, account: ContractAddress) { + self.assert_admin(); + self.revoke_role_helper(role, account); + } + + fn renounce_role(ref self: ComponentState, role: u128) { + self.revoke_role_helper(role, get_caller_address()); + } + + fn set_pending_admin(ref self: ComponentState, new_admin: ContractAddress) { + self.assert_admin(); + self.set_pending_admin_helper(new_admin); + } + + // + // external + // + + fn accept_admin(ref self: ComponentState) { + let caller: ContractAddress = get_caller_address(); + assert(self.get_pending_admin() == caller, 'Caller not pending admin'); + self.set_admin_helper(caller); + + self.pending_admin.write(ContractAddressZeroable::zero()); + } + } + + #[generate_trait] + impl AccessControlHelpers< + TContractState, +HasComponent + > of AccessControlHelpersTrait { + fn initializer(ref self: ComponentState, admin: ContractAddress, roles: Option) { + self.set_admin_helper(admin); + if roles.is_some() { + self.grant_role_helper(roles.unwrap(), admin); + } + } + + // + // asserts + // + + fn assert_has_role(self: @ComponentState, role: u128) { + assert(self.has_role(role, get_caller_address()), 'Caller missing role'); + } + + fn assert_admin(self: @ComponentState) { + assert(self.admin.read() == get_caller_address(), 'Caller not admin'); + } + + // + // internal + // + + fn set_admin_helper(ref self: ComponentState, new_admin: ContractAddress) { + let old_admin = self.admin.read(); + self.admin.write(new_admin); + + self.emit(AdminChanged { old_admin, new_admin }); + } + + fn set_pending_admin_helper(ref self: ComponentState, new_admin: ContractAddress) { + self.pending_admin.write(new_admin); + + self.emit(NewPendingAdmin { new_admin }); + } + + fn grant_role_helper(ref self: ComponentState, role: u128, account: ContractAddress) { + let roles: u128 = self.roles.read(account); + self.roles.write(account, roles | role); + + self.emit(RoleGranted { user: account, role_granted: role }); + } + + fn revoke_role_helper(ref self: ComponentState, role: u128, account: ContractAddress) { + let roles: u128 = self.roles.read(account); + let updated_roles: u128 = roles & (~role); + self.roles.write(account, updated_roles); + + self.emit(RoleRevoked { user: account, role_revoked: role }); + } + } +} diff --git a/src/lib.cairo b/src/lib.cairo new file mode 100644 index 0000000..24e8045 --- /dev/null +++ b/src/lib.cairo @@ -0,0 +1,8 @@ +mod access_control; + +use access_control::access_control_component; +#[cfg(test)] +mod tests { + mod mock_access_control; + mod test_access_control; +} diff --git a/src/tests/mock_access_control.cairo b/src/tests/mock_access_control.cairo new file mode 100644 index 0000000..5f37c86 --- /dev/null +++ b/src/tests/mock_access_control.cairo @@ -0,0 +1,28 @@ +#[starknet::contract] +mod mock_access_control { + use access_control::access_control_component; + use starknet::ContractAddress; + + component!(path: access_control_component, storage: access_control, event: AccessControlEvent); + + #[abi(embed_v0)] + impl AccessControlPublic = access_control_component::AccessControl; + impl AccessControlHelpers = access_control_component::AccessControlHelpers; + + #[storage] + struct Storage { + #[substorage(v0)] + access_control: access_control_component::Storage + } + + #[event] + #[derive(Copy, Drop, starknet::Event, PartialEq)] + enum Event { + AccessControlEvent: access_control_component::Event + } + + #[constructor] + fn constructor(ref self: ContractState, admin: ContractAddress, roles: Option) { + self.access_control.initializer(admin, roles); + } +} diff --git a/src/tests/test_access_control.cairo b/src/tests/test_access_control.cairo new file mode 100644 index 0000000..55fe618 --- /dev/null +++ b/src/tests/test_access_control.cairo @@ -0,0 +1,296 @@ +mod test_access_control { + use access_control::access_control_component::{AccessControlPublic, AccessControlHelpers}; + use access_control::access_control_component; + use access_control::tests::mock_access_control::mock_access_control; + use snforge_std::cheatcodes::events::EventAssertions; + use snforge_std::{ + spy_events, SpyOn, EventSpy, EventFetcher, event_name_hash, Event, start_prank, CheatTarget, test_address, + PrintTrait + }; + use starknet::contract_address::{ContractAddress, ContractAddressZeroable, contract_address_try_from_felt252}; + // + // Constants + // + + // mock roles + const R1: u128 = 1_u128; + const R2: u128 = 2_u128; + const R3: u128 = 128_u128; + const R4: u128 = 256_u128; + + const ADMIN_ADDR: felt252 = 'access control admin'; + + fn admin() -> ContractAddress { + contract_address_try_from_felt252(ADMIN_ADDR).unwrap() + } + + fn badguy() -> ContractAddress { + contract_address_try_from_felt252('bad guy').unwrap() + } + + fn user() -> ContractAddress { + contract_address_try_from_felt252('user').unwrap() + } + + fn zero_addr() -> ContractAddress { + ContractAddressZeroable::zero() + } + + // + // Test setup + // + + fn state() -> mock_access_control::ContractState { + mock_access_control::contract_state_for_testing() + } + + fn setup(caller: ContractAddress) -> mock_access_control::ContractState { + let mut state = state(); + state.access_control.initializer(admin(), Option::None); + + start_prank(CheatTarget::All, caller); + + state + } + + fn set_pending_admin( + ref state: mock_access_control::ContractState, caller: ContractAddress, pending_admin: ContractAddress + ) { + start_prank(CheatTarget::All, caller); + state.set_pending_admin(pending_admin); + } + + fn default_grant(ref state: mock_access_control::ContractState) { + let u = user(); + state.grant_role(R1, u); + state.grant_role(R2, u); + } + + // + // Tests + // + + #[test] + fn test_initializer() { + let mut spy = spy_events(SpyOn::One(test_address())); + + let admin = admin(); + + let state = setup(admin); + + assert(state.get_admin() == admin, 'initialize wrong admin'); + + let expected_events = array![ + ( + test_address(), + access_control_component::Event::AdminChanged( + access_control_component::AdminChanged { old_admin: zero_addr(), new_admin: admin(), } + ) + ), + ]; + spy.fetch_events(); + + let (_, event) = spy.events.at(0); + + assert(spy.events.len() == 1, 'wrong number of events'); + assert(*event.keys[1] == event_name_hash('AdminChanged'), 'wrong event name'); + assert(*event.data[0] == 0, 'should be zero address'); + assert(*event.data[1] == ADMIN_ADDR, 'should be admin adddress'); + } + + #[test] + fn test_grant_role() { + let mut state = setup(admin()); + + let mut spy = spy_events(SpyOn::One(test_address())); + + default_grant(ref state); + + let u = user(); + assert(state.has_role(R1, u), 'role R1 not granted'); + assert(state.has_role(R2, u), 'role R2 not granted'); + assert(state.get_roles(u) == R1 + R2, 'not all roles granted'); + + spy.fetch_events(); + + assert(spy.events.len() == 2, 'wrong number of events'); + + let (_, event) = spy.events.at(0); + assert(*event.keys[1] == event_name_hash('RoleGranted'), 'wrong event name'); + assert(*event.data[0] == u.into(), 'wrong user in event #1'); + assert(*event.data[1] == R1.into(), 'wrong role in event #1'); + + let (_, event) = spy.events.at(1); + assert(*event.keys[1] == event_name_hash('RoleGranted'), 'wrong event name'); + assert(*event.data[0] == u.into(), 'wrong user in event #2'); + assert(*event.data[1] == R2.into(), 'wrong role in event #2'); + } + + #[test] + #[should_panic(expected: ('Caller not admin',))] + fn test_grant_role_not_admin() { + let mut state = setup(badguy()); + state.grant_role(R2, badguy()); + } + + #[test] + fn test_grant_role_multiple_users() { + let mut state = setup(admin()); + default_grant(ref state); + + let u = user(); + let u2 = contract_address_try_from_felt252('user 2').unwrap(); + state.grant_role(R2 + R3 + R4, u2); + assert(state.get_roles(u) == R1 + R2, 'wrong roles for u'); + assert(state.get_roles(u2) == R2 + R3 + R4, 'wrong roles for u2'); + } + + #[test] + fn test_revoke_role() { + let mut state = setup(admin()); + default_grant(ref state); + + let mut spy = spy_events(SpyOn::One(test_address())); + + let u = user(); + state.revoke_role(R1, u); + assert(state.has_role(R1, u) == false, 'role R1 not revoked'); + assert(state.has_role(R2, u), 'role R2 not kept'); + assert(state.get_roles(u) == R2, 'incorrect roles'); + + spy.fetch_events(); + + assert(spy.events.len() == 1, 'wrong number of events'); + + let (_, event) = spy.events.at(0); + assert(*event.keys[1] == event_name_hash('RoleRevoked'), 'wrong event name'); + assert(*event.data[0] == u.into(), 'wrong user in event'); + assert(*event.data[1] == R1.into(), 'wrong role in event'); + } + + #[test] + #[should_panic(expected: ('Caller not admin',))] + fn test_revoke_role_not_admin() { + let mut state = setup(admin()); + start_prank(CheatTarget::All, badguy()); + state.revoke_role(R1, user()); + } + + #[test] + fn test_renounce_role() { + let mut state = setup(admin()); + default_grant(ref state); + + let mut spy = spy_events(SpyOn::One(test_address())); + + let u = user(); + start_prank(CheatTarget::All, u); + state.renounce_role(R1); + assert(state.has_role(R1, u) == false, 'R1 role kept'); + + // renouncing non-granted role should pass + let non_existent_role: u128 = 64; + state.renounce_role(non_existent_role); + + spy.fetch_events(); + + assert(spy.events.len() == 2, 'wrong number of events'); + + let (_, event) = spy.events.at(0); + assert(*event.keys[1] == event_name_hash('RoleRevoked'), 'wrong event name'); + assert(*event.data[0] == u.into(), 'wrong user in event #1'); + assert(*event.data[1] == R1.into(), 'wrong role in event #1'); + + let (_, event) = spy.events.at(1); + assert(*event.keys[1] == event_name_hash('RoleRevoked'), 'wrong event name'); + assert(*event.data[0] == u.into(), 'wrong user in event #2'); + assert(*event.data[1] == non_existent_role.into(), 'wrong role in event #2'); + } + + #[test] + fn test_set_pending_admin() { + let mut state = setup(admin()); + + let mut spy = spy_events(SpyOn::One(test_address())); + + let pending_admin = user(); + state.set_pending_admin(pending_admin); + assert(state.get_pending_admin() == pending_admin, 'pending admin not changed'); + + spy.fetch_events(); + + assert(spy.events.len() == 1, 'wrong number of events'); + + let (_, event) = spy.events.at(0); + assert(*event.keys[1] == event_name_hash('NewPendingAdmin'), 'wrong event name'); + assert(*event.data[0] == pending_admin.into(), 'wrong user in event'); + } + + #[test] + #[should_panic(expected: ('Caller not admin',))] + fn test_set_pending_admin_not_admin() { + let mut state = setup(admin()); + start_prank(CheatTarget::All, badguy()); + state.set_pending_admin(badguy()); + } + + #[test] + fn test_accept_admin() { + let current_admin = admin(); + let mut state = setup(current_admin); + + let pending_admin = user(); + set_pending_admin(ref state, current_admin, pending_admin); + + let mut spy = spy_events(SpyOn::One(test_address())); + + start_prank(CheatTarget::All, pending_admin); + state.accept_admin(); + + assert(state.get_admin() == pending_admin, 'admin not changed'); + assert(state.get_pending_admin().is_zero(), 'pending admin not reset'); + + spy.fetch_events(); + + assert(spy.events.len() == 1, 'wrong number of events'); + + let (_, event) = spy.events.at(0); + assert(*event.keys[1] == event_name_hash('AdminChanged'), 'wrong event name'); + assert(*event.data[0] == current_admin.into(), 'wrong old admin in event'); + assert(*event.data[1] == pending_admin.into(), 'wrong new admin in event'); + } + + #[test] + #[should_panic(expected: ('Caller not pending admin',))] + fn test_accept_admin_not_pending_admin() { + let current_admin = admin(); + let mut state = setup(current_admin); + + let pending_admin = user(); + set_pending_admin(ref state, current_admin, pending_admin); + + start_prank(CheatTarget::All, badguy()); + state.accept_admin(); + } + + #[test] + fn test_assert_has_role() { + let mut state = setup(admin()); + default_grant(ref state); + + start_prank(CheatTarget::All, user()); + // should not throw + state.access_control.assert_has_role(R1); + state.access_control.assert_has_role(R1 + R2); + } + + #[test] + #[should_panic(expected: ('Caller missing role',))] + fn test_assert_has_role_panics() { + let mut state = setup(admin()); + default_grant(ref state); + + start_prank(CheatTarget::All, user()); + state.access_control.assert_has_role(R3); + } +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/roles.cairo b/tests/roles.cairo deleted file mode 100644 index 54d4a52..0000000 --- a/tests/roles.cairo +++ /dev/null @@ -1,5 +0,0 @@ -namespace AccRoles { - const EXECUTE = 2 ** 0; - const WRITE = 2 ** 1; - const READ = 2 ** 2; -} diff --git a/tests/test_accesscontrol.cairo b/tests/test_accesscontrol.cairo deleted file mode 100644 index 21e0429..0000000 --- a/tests/test_accesscontrol.cairo +++ /dev/null @@ -1,78 +0,0 @@ -%lang starknet - -from starkware.cairo.common.bool import TRUE -from starkware.cairo.common.cairo_builtins import BitwiseBuiltin, HashBuiltin - -from contracts.accesscontrol_library import AccessControl -// these imported public functions are part of the contract's interface -from contracts.accesscontrol_external import ( - change_admin, - get_admin, - get_roles, - grant_role, - has_role, - renounce_role, - revoke_role, -) -from tests.roles import AccRoles - -from contracts.aliases import address, bool, ufelt -// -// Access Control - Constructor -// - -@constructor -func constructor{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(admin: address) { - AccessControl.initializer(admin); - return (); -} - -// -// Access Control - Modifiers -// - -@view -func assert_has_role{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(role: ufelt) { - AccessControl.assert_has_role(role); - return (); -} - -@view -func assert_admin{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}() { - AccessControl.assert_admin(); - return (); -} - -// -// Access Control - Getters -// - -@view -func can_execute{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(user: address) -> (authorized: bool) { - let authorized: bool = AccessControl.has_role(AccRoles.EXECUTE, user); - return (authorized,); -} - -@view -func can_write{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(user) -> (authorized: bool) { - let authorized: bool = AccessControl.has_role(AccRoles.WRITE, user); - return (authorized,); -} - -@view -func can_read{ - syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr, bitwise_ptr: BitwiseBuiltin* -}(user) -> (authorized: bool) { - let authorized: bool = AccessControl.has_role(AccRoles.READ, user); - return (authorized,); -} diff --git a/tests/test_accesscontrol.py b/tests/test_accesscontrol.py deleted file mode 100644 index df65222..0000000 --- a/tests/test_accesscontrol.py +++ /dev/null @@ -1,252 +0,0 @@ -import asyncio -from enum import IntEnum -from itertools import combinations -from typing import Callable, List, Tuple - -import pytest -from starkware.starknet.testing.contract import StarknetContract -from starkware.starknet.testing.objects import StarknetCallInfo -from starkware.starknet.testing.starknet import Starknet -from starkware.starkware_utils.error_handling import StarkException - -from tests.utils import assert_event_emitted, compile_contract, str_to_felt - -FALSE = 0 -TRUE = 1 - -ACC_OWNER = str_to_felt("acc owner") -NEW_ACC_OWNER = str_to_felt("new acc owner") -ACC_USER = str_to_felt("acc user") -BAD_GUY = str_to_felt("bad guy") - - -class Roles(IntEnum): - EXECUTE = 1 - WRITE = 2 - READ = 4 - - -SUDO_USER: int = sum([r.value for r in Roles]) - -ROLES_COMBINATIONS: List[Tuple[Roles, ...]] = [] - -for i in range(1, len(Roles) + 1): - for j in combinations(Roles, i): - ROLES_COMBINATIONS.append(j) - - -@pytest.fixture(scope="session") -def event_loop(): - return asyncio.new_event_loop() - - -@pytest.fixture(scope="session") -async def starknet_session() -> Starknet: - starknet = await Starknet.empty() - return starknet - - -@pytest.fixture -async def acc(starknet_session) -> StarknetContract: - contract = compile_contract("tests/test_accesscontrol.cairo") - return await starknet_session.deploy(contract_class=contract, constructor_calldata=[ACC_OWNER]) - - -@pytest.fixture -async def sudo_user(acc): - # Grant user all permissions - await acc.grant_role(SUDO_USER, ACC_USER).execute(caller_address=ACC_OWNER) - - -@pytest.fixture -async def ACC_change_admin(acc) -> StarknetCallInfo: - tx = await acc.change_admin(NEW_ACC_OWNER).execute(caller_address=ACC_OWNER) - return tx - - -@pytest.fixture -async def ACC_new_admin(acc, ACC_change_admin) -> StarknetContract: - return acc - - -@pytest.fixture -def ACC_both(request) -> StarknetContract: - """ - Wrapper fixture to pass two different instances of acc to `pytest.parametrize`, - before and after change of admin. - - Returns a tuple of the acc contract and the caller - """ - caller = ACC_OWNER if request.param == "acc" else NEW_ACC_OWNER - return (request.getfixturevalue(request.param), caller) - - -@pytest.mark.asyncio -async def test_ACC_setup(acc): - admin = (await acc.get_admin().execute()).result.admin - assert admin == ACC_OWNER - - await acc.assert_admin().execute(caller_address=ACC_OWNER) - - with pytest.raises(StarkException, match="AccessControl: caller is not admin"): - await acc.assert_admin().execute(caller_address=NEW_ACC_OWNER) - - -@pytest.mark.asyncio -async def test_change_admin(acc, ACC_change_admin): - # Check event - assert_event_emitted( - ACC_change_admin, - acc.contract_address, - "AdminChanged", - [ACC_OWNER, NEW_ACC_OWNER], - ) - - # Check admin - admin = (await acc.get_admin().execute()).result.admin - assert admin == NEW_ACC_OWNER - - await acc.assert_admin().execute(caller_address=NEW_ACC_OWNER) - - with pytest.raises(StarkException, match="AccessControl: caller is not admin"): - await acc.assert_admin().execute(caller_address=ACC_OWNER) - - -@pytest.mark.asyncio -async def test_change_admin_unauthorized(acc): - with pytest.raises(StarkException, match="AccessControl: caller is not admin"): - await acc.change_admin(BAD_GUY).execute(caller_address=BAD_GUY) - - -@pytest.mark.parametrize("given_roles", ROLES_COMBINATIONS) -@pytest.mark.parametrize("revoked_roles", ROLES_COMBINATIONS) -@pytest.mark.parametrize("ACC_both", ["acc", "ACC_new_admin"], indirect=["ACC_both"]) -@pytest.mark.asyncio -async def test_grant_and_revoke_role(ACC_both, given_roles, revoked_roles): - acc, admin = ACC_both - - # Compute value of given role - given_role_value = sum([r.value for r in given_roles]) - - tx = await acc.grant_role(given_role_value, ACC_USER).execute(caller_address=admin) - - # Check event - assert_event_emitted(tx, acc.contract_address, "RoleGranted", [given_role_value, ACC_USER]) - - # Check role - role = (await acc.get_roles(ACC_USER).execute()).result.roles - assert role == given_role_value - - # Check roles granted - for r in Roles: - role_value = r.value - - # Check `has_role` - has_role = (await acc.has_role(role_value, ACC_USER).execute()).result.has_role - - # Check getter - role_name = r.name.lower() - getter: Callable = acc.get_contract_function(f"can_{role_name}") - can_perform_role = (await getter(ACC_USER).execute()).result.authorized - - expected = TRUE if r in given_roles else FALSE - assert has_role == can_perform_role == expected - - # Grant the role again to confirm behaviour is correct - await acc.grant_role(given_role_value, ACC_USER).execute(caller_address=admin) - role = (await acc.get_roles(ACC_USER).execute()).result.roles - assert role == given_role_value - - # Compute value of revoked role - revoked_role_value = sum([r.value for r in revoked_roles]) - - tx = await acc.revoke_role(revoked_role_value, ACC_USER).execute(caller_address=admin) - - # Check event - assert_event_emitted(tx, acc.contract_address, "RoleRevoked", [revoked_role_value, ACC_USER]) - - # Check role - updated_role = (await acc.get_roles(ACC_USER).execute()).result.roles - expected_role = given_role_value & (~revoked_role_value) - assert updated_role == expected_role - - # Check roles remaining - updated_role_list = [i for i in given_roles if i not in revoked_roles] - for r in Roles: - role_value = r.value - - # Check `has_role` - has_role = (await acc.has_role(role_value, ACC_USER).execute()).result.has_role - - # Check getter - role_name = r.name.lower() - getter: Callable = acc.get_contract_function(f"can_{role_name}") - can_perform_role = (await getter(ACC_USER).execute()).result.authorized - - if r in updated_role_list: - assert has_role == can_perform_role == TRUE - await acc.assert_has_role(role_value).execute(caller_address=ACC_USER) - else: - assert has_role == can_perform_role == FALSE - with pytest.raises( - StarkException, - match=f"AccessControl: caller is missing role {role_value}", - ): - await acc.assert_has_role(role_value).execute(caller_address=ACC_USER) - - # Revoke the role again to confirm behaviour is as intended - await acc.revoke_role(revoked_role_value, ACC_USER).execute(caller_address=admin) - updated_role = (await acc.get_roles(ACC_USER).execute()).result.roles - assert updated_role == expected_role - - -@pytest.mark.usefixtures("sudo_user") -@pytest.mark.asyncio -async def test_role_actions_unauthorized(acc): - with pytest.raises(StarkException, match="AccessControl: caller is not admin"): - await acc.grant_role(SUDO_USER, BAD_GUY).execute(caller_address=BAD_GUY) - - with pytest.raises(StarkException, match="AccessControl: caller is not admin"): - await acc.revoke_role(SUDO_USER, ACC_USER).execute(caller_address=BAD_GUY) - - with pytest.raises(StarkException, match="AccessControl: can only renounce roles for self"): - await acc.renounce_role(SUDO_USER, ACC_USER).execute(caller_address=BAD_GUY) - - -@pytest.mark.parametrize("renounced_roles", ROLES_COMBINATIONS) -@pytest.mark.usefixtures("sudo_user") -@pytest.mark.asyncio -async def test_renounce_role(acc, renounced_roles): - renounced_role_value = sum([r.value for r in renounced_roles]) - tx = await acc.renounce_role(renounced_role_value, ACC_USER).execute(caller_address=ACC_USER) - - assert_event_emitted(tx, acc.contract_address, "RoleRevoked", [renounced_role_value, ACC_USER]) - - # Check role - updated_role = (await acc.get_roles(ACC_USER).execute()).result.roles - expected_role = SUDO_USER & (~renounced_role_value) - assert updated_role == expected_role - - # Check roles remaining - updated_role_list = [i for i in Roles if i not in renounced_roles] - for r in Roles: - role_value = r.value - - # Check `has_role` - has_role = (await acc.has_role(role_value, ACC_USER).execute()).result.has_role - - # Check getter - role_name = r.name.lower() - getter: Callable = acc.get_contract_function(f"can_{role_name}") - can_perform_role = (await getter(ACC_USER).execute()).result.authorized - - if r in updated_role_list: - assert has_role == can_perform_role == TRUE - await acc.assert_has_role(role_value).execute(caller_address=ACC_USER) - else: - assert has_role == can_perform_role == FALSE - with pytest.raises( - StarkException, - match=f"AccessControl: caller is missing role {role_value}", - ): - await acc.assert_has_role(role_value).execute(caller_address=ACC_USER) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index f1f6ec5..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,53 +0,0 @@ -import os -from functools import cache -from typing import Callable, Iterable, List, Union - -from starkware.starknet.business_logic.execution.objects import Event -from starkware.starknet.compiler.compile import compile_starknet_files -from starkware.starknet.public.abi import get_selector_from_name -from starkware.starknet.services.api.contract_class import ContractClass - - -def here() -> str: - return os.path.abspath(os.path.dirname(__file__)) - - -def contract_path(rel_contract_path: str) -> str: - return os.path.join(here(), "..", rel_contract_path) - - -@cache -def compile_contract(rel_contract_path: str) -> ContractClass: - contract_src = contract_path(rel_contract_path) - tld = os.path.join(here(), "..") - return compile_starknet_files( - [contract_src], - debug_info=True, - disable_hint_validation=True, - cairo_path=[tld], - ) - - -def assert_event_emitted( - tx_exec_info, from_address, name, data: Union[None, Callable[[List[int]], bool], Iterable] = None -): - key = get_selector_from_name(name) - - if isinstance(data, Callable): - assert any([data(e.data) for e in tx_exec_info.raw_events if e.from_address == from_address and key in e.keys]) - elif data is not None: - assert ( - Event( - from_address=from_address, - keys=[key], - data=data, - ) - in tx_exec_info.raw_events - ) - else: # data=None - assert any([e for e in tx_exec_info.raw_events if e.from_address == from_address and key in e.keys]) - - -def str_to_felt(text: str) -> int: - b_text = bytes(text, "ascii") - return int.from_bytes(b_text, "big")