From 78ef4116ab3aa54e82c0036121594168ad3de17e Mon Sep 17 00:00:00 2001 From: tserg <8017125+tserg@users.noreply.github.com> Date: Wed, 28 Sep 2022 11:31:42 +0800 Subject: [PATCH] first commit --- .github/workflows/tests.yml | 57 ++++++ .gitignore | 5 + LICENSE | 21 +++ README.md | 118 ++++++++++++ contracts/accesscontrol_external.cairo | 70 +++++++ contracts/accesscontrol_library.cairo | 196 +++++++++++++++++++ contracts/aliases.cairo | 3 + pyproject.toml | 4 + requirements.txt | 11 ++ setup.cfg | 11 ++ tests/__init__.py | 0 tests/roles.cairo | 5 + tests/test_accesscontrol.cairo | 78 ++++++++ tests/test_accesscontrol.py | 252 +++++++++++++++++++++++++ tests/utils.py | 53 ++++++ 15 files changed, 884 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 contracts/accesscontrol_external.cairo create mode 100644 contracts/accesscontrol_library.cairo create mode 100644 contracts/aliases.cairo create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/roles.cairo create mode 100644 tests/test_accesscontrol.cairo create mode 100644 tests/test_accesscontrol.py create mode 100644 tests/utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ce8c291 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,57 @@ +name: Tests + +on: + push: + branches: + - main + 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 + with: + python-version: '3.9' + cache: pip + cache-dependency-path: '**/requirements.txt' + + - name: Env setup + run: pip install -r requirements.txt + + - name: Run tests + run: pytest -sv -r A tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c6afb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.pytest_cache + +# Hypothesis +.hypothesis diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a3c2c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Lindy Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a54d7c4 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Member-based access control library for Cairo + +![tests](https://github.com/lindy-labs/cairo-accesscontrol/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. + +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). + +## 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. + +Roles should be defined in a separate Cairo contract as its own namespace. For example: + +```cairo +namespace Roles { + const MANAGER = 2 ** 0; + const STAFF = 2 ** 1; + const USER = 2 ** 2; +} +``` + +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`). + +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. + +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: + +```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,); +} + +@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 (); +} + +@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 (); +} +``` + +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 + +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 +``` + +### Run tests + +To run the tests: + +```bash +pytest +``` + +## Formal Verification +The Access Control library is not currently formally verified, but it will soon be formally verified by Lindy Labs' formal verification unit. + + +## Contribute + +We welcome contributions of any kind! Please feel free to submit an issue or open a PR if you have a solution to an existing bug. + +## License + +This library is released under the [MIT License](LICENSE). diff --git a/contracts/accesscontrol_external.cairo b/contracts/accesscontrol_external.cairo new file mode 100644 index 0000000..0dd1a63 --- /dev/null +++ b/contracts/accesscontrol_external.cairo @@ -0,0 +1,70 @@ +%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 new file mode 100644 index 0000000..d158ce5 --- /dev/null +++ b/contracts/accesscontrol_library.cairo @@ -0,0 +1,196 @@ +%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 new file mode 100644 index 0000000..8046bcf --- /dev/null +++ b/contracts/aliases.cairo @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..80809eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +line-length = 120 +target-version = ['py39'] +include = '\.pyi?$' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f0f4de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..c4f0c7c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,11 @@ +[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/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/roles.cairo b/tests/roles.cairo new file mode 100644 index 0000000..54d4a52 --- /dev/null +++ b/tests/roles.cairo @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..21e0429 --- /dev/null +++ b/tests/test_accesscontrol.cairo @@ -0,0 +1,78 @@ +%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 new file mode 100644 index 0000000..df65222 --- /dev/null +++ b/tests/test_accesscontrol.py @@ -0,0 +1,252 @@ +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 new file mode 100644 index 0000000..f1f6ec5 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,53 @@ +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")