diff --git a/README.md b/README.md index d594d59..d4683ed 100644 --- a/README.md +++ b/README.md @@ -134,91 +134,11 @@ Documentation of all methods provided by the derived implementation of `Upgradab Enables role-based access control for contract methods. A method with restricted access can only be called _successfully_ by accounts that have been granted one of the whitelisted roles. If a restricted method is called by an account with insufficient permissions, it panics. -Each role is managed by admins who may grant the role to accounts and revoke it from them. In addition, there are super admins that have admin permissions for every role. +Each role is managed by admins who may grant the role to accounts and revoke it from them. In addition, there are super admins that have admin permissions for every role. The sets of accounts that are (super) admins and grantees are stored in the contract's state. -The sets of accounts that are (super) admins and grantees are stored in the contract's state. +[This contract](/near-plugins/tests/contracts/access_controllable/src/lib.rs) provides an example of using `AccessControllable`. It is compiled, deployed on chain and interacted with in [integration tests](/near-plugins/tests/access_controllable.rs). -```rust -/// Roles are represented by enum variants. -/// -/// Deriving `AccessControlRole` ensures `Role` can be used in -/// `AccessControllable`. -#[derive(AccessControlRole, Deserialize, Serialize, Copy, Clone)] -#[serde(crate = "near_sdk::serde")] -pub enum Role { - SkipperByOne, - SkipperByAny, - Resetter, -} - -#[access_control(role_type(Role))] -#[near_bindgen] -#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] -pub struct Contract { - counter: u64, -} - -#[near_bindgen] -impl Counter { - /// Setup access control in the constructor. - #[init] - pub fn new() -> Self { - let contract = Self { - counter: 0, - // Initialize `AccessControllable` plugin state. - __acl: Default::default(), - }; - - // Add the contract itself as super admin. - near_sdk::require!( - contract.acl_init_super_admin(near_sdk::env::predecessor_account_id()), - "Failed to initialize super admin", - ); - - // Specify an account to be added as admin for a specific role. - let skipper_by_one_admin_account_id: AccountId = "alice.near".parse().unwrap(); - - // Add an admin for `Role::SkipperByOne`. This is possible since the - // contract was just made a super admin. - let result = contract.acl_add_admin( - Role::SkipperByOne.into(), - skipper_by_one_admin_account_id, - ); - near_sdk::require!(Some(true) == result, "Failed to add admin"); - - // Specify an account to be granted a specific role. - let skipper_by_any_grantee_account_id: AccountId = "bob.near".parse().unwrap(); - - // Grant a role. Also possible since the contract is super admin. - let result = contract.acl_grant_role( - Role::SkpperByAny.into(), - skipper_by_any_grantee_account_id, - ); - near_sdk::require!(Some(true) == result, "Failed to grant role"); - } - - /// This method has no access control. Anyone can call it successfully. - pub fn increase(&mut self) { - self.counter += 1; - } - - /// Only an account that was granted either `Role::SkipperByOne` or - /// `Role::SkipperByAny` may successfully call this method. - #[access_control_any(roles(Role::SkipperByOne, Role::SkipperByAny))] - pub fn skip_one(&mut self) { - self.counter += 2; - } - - /// Only an account that was granted `Role:Resetter` may successfully call - /// this method. - #[access_control_any(roles(Role::Resetter))] - pub fn reset(&mut self) { - self.counter = 0; - } -} -``` - -The derived implementation of `AccessControllable` provides more methods that are documented in the [definition of the trait](/near-plugins/src/access_controllable.rs). More usage patterns are explained in [examples](/examples/access-controllable-examples/) and in [integration tests](/near-plugins/tests/access_controllable.rs). +Documentation of all methods provided by `AccessControllable` is available in the [definition of the trait](/near-plugins/src/access_controllable.rs). ## Internal Architecture diff --git a/examples/access-controllable-examples/README.md b/examples/access-controllable-examples/README.md deleted file mode 100644 index 74c3524..0000000 --- a/examples/access-controllable-examples/README.md +++ /dev/null @@ -1,452 +0,0 @@ -# Example of using the Access Control plugin - -An access control mechanism that allows you to specify which groups of users can access certain functions. - -```rust -use near_plugins::AccessControllable; -use near_plugins::AccessControlRole; -use near_plugins_derive::access_control; -use near_plugins_derive::access_control_any; -use near_sdk::near_bindgen; -use borsh::{BorshSerialize, BorshDeserialize}; -use near_sdk::env; - -/// All types of access groups -#[derive(AccessControlRole, Clone, Copy)] -pub enum UsersGroups { - GroupA, - GroupB, -} - -#[near_bindgen] -#[access_control(role_type(UsersGroups))] -#[derive(Default, BorshSerialize, BorshDeserialize)] -struct Counter { - counter: u64, -} - -#[near_bindgen] -impl Counter { - /// In the constructor we set up a super admin, - /// which can control the member lists of all user groups - #[init] - pub fn new() -> Self { - let mut contract: Counter = Self{ - counter: 0, - __acl: __Acl::default(), - }; - - contract.acl_init_super_admin(near_sdk::env::predecessor_account_id()); - - contract - } - - /// unprotected function, every one can call this function - pub fn unprotected(&mut self) { - self.counter += 1; - } - - /// only the users from GroupA can call this method - #[access_control_any(roles(UsersGroups::GroupA))] - pub fn level_a_incr(&mut self) { - self.counter += 1; - } - - /// only the users from GroupA or GroupB can call this method - #[access_control_any(roles(UsersGroups::GroupA, UsersGroups::GroupB))] - pub fn level_ab_incr(&mut self) { - self.counter += 1; - } - - - /// view method for get current counter value, every one can use it - pub fn get_counter(&self) -> u64 { - self.counter - } -} -``` - -## The contract methods description -### acl_storage_prefix -`acl_storage_prefix` is a method that returns the common prefix of keys for storing the members and the admins of groups. -`__acl` by default. - -```shell -$ near call acl_storage_prefix --accountId -Scheduling a call: .acl_storage_prefix() -Doing account.functionCall() -Transaction Id -To see the transaction in the transaction explorer, please open this url in your browser -https://explorer.testnet.near.org/transactions/ -[ 95, 95, 97, 99, 108 ] -$ python3 ->>> print(' '.join(str(b) for b in bytes("__acl", 'utf8'))) -95 95 97 99 108 -``` - -Example of changing acl storage prefix key: -```rust -#[near_bindgen] -#[access_control(role_type(UsersGroups), storage_prefix = "__custom_prefix")] -#[derive(Default, BorshSerialize, BorshDeserialize)] -struct Counter { - counter: u64, -} -``` - -### acl_init_super_admin -`acl_init_super_admin` is a method that adds `account_id` as a super-admin _without_ checking any permissions -in case there are no super-admins. Do nothing if at least one super-admin exists. This function can be used to add a super-admin during contract initialization. -Moreover, it may provide a recovery mechanism if (mistakenly) all super-admins have been removed. - -**Return value:** the return value indicates whether `account_id` was added as super-admin. - -It is `#[private]` in the implementation provided by this trait, i.e. only the contract itself may call this method. - -```shell -$ near call acl_init_super_admin '{"account_id": ""}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"super_admin_added", - "data":{ - "account":"", - "by":"" - } -} -``` - -### acl_is_super_admin -`acl_is_super_admin` is a _view_ method that checks that account has super admin rights. -Super admin can control the member list of each group and control the admin list for each group. - -```shell -$ near view acl_is_super_admin '{"account_id": "" }' -View call: .acl_is_super_admin({"account_id": ""}) -true -``` - -### acl_add_admin -`acl_add_admin` is a method that adds a new admin for a specific group. -Admins' rights don't allow running group-specific functions, but group admins can control the group member list. -This method can be run by an admin of a specific group or by a super admin. - -**Return value:** in case of sufficient permissions, the returned `Some(bool)` indicates -whether `account_id` is a new admin for `role`. Without permissions, -`None` is returned and internal state is not modified. - -```shell -$ near call acl_add_admin '{"role": "GroupA", "account_id": ""}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"admin_added", - "data": { - "role":"GroupA", - "account":"", - "by":"" - } -} -``` - -### acl_is_admin -`acl_is_admin` is a _view_ method that checks if the account has an admin right for the specified group. For super admin, it will return true for every group. - -```shell -$ near view acl_is_admin '{"role": "GroupA", "account_id": ""}' -View call: .acl_is_admin({"role": "GroupA", "account_id": ""}) -true -``` - -### acl_revoke_admin -`acl_revoke_admin` is a method that removes the group admin right for a specific account. Can be executed by an admin of this group or by a super admin. - -**Return value:** in case of sufficient permissions, the returned `Some(bool)` indicates -whether `account_id` was an admin for `role`. Without permissions, -`None` is returned and internal state is not modified. - -```shell -$ near call acl_revoke_admin '{"role": "GroupA", "account_id": ""}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"admin_revoked", - "data":{ - "role":"GroupA", - "account":"", - "by":"" - } -} -``` - -### acl_renounce_admin -`acl_renounce_admin` is a method that removes the group admin right for an account that calls the method. - -**Return value:** returns whether the predecessor was an admin for `role`. - -```shell -$ near call acl_renounce_admin '{"role": "GroupA"}' --accountId -true -``` - -After calling that method, Alice will not have the admin right for GroupA anymore. - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"admin_revoked", - "data":{ - "role":"GroupA", - "account":"", - "by":"" - } -} -``` -### acl_revoke_role -`acl_revoke_role` is a method that removes the specified account from the list of the group members. -Only a group admin or a super admin can execute this function. - -**Return value:** in case of sufficient permissions, the returned `Some(bool)` indicates -whether `account_id` was a grantee of `role`. Without permissions, -`None` is returned and internal state is not modified. - -```shell -$ near call acl_revoke_role '{"role": "GroupA", "account_id": ""}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"role_revoked", - "data": { - "role":"GroupA", - "from":"", - "by":"" - } -} -``` - -### acl_renounce_role -`acl_renounce_role` is a method that removes the caller account from the member list of the group. Can be called by anyone. - -**Return value:** returns whether it was a grantee of `role`. - -```shell -$ near call acl_renounce_role '{"role": "GroupA"}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"role_revoked", - "data": { - "role":"GroupA", - "from":"", - "by":"" - } -} -``` -### acl_grant_role -`acl_grant_role` is a method that adds the account to the group member list. Can be executed only by a group admin or by a super admin. - -**Return value:** in case of sufficient permissions, the returned `Some(bool)` indicates -whether `account_id` is a new grantee of `role`. Without permissions, -`None` is returned and internal state is not modified. - -```shell -$ near call acl_grant_role '{"role": "GroupA", "account_id": ""}' --accountId -true -``` - -If the method succeeds, the following event will be emitted: -```json -{ - "standard":"AccessControllable", - "version":"1.0.0", - "event":"role_granted", - "data": { - "role":"GroupA", - "to":"", - "by":"" - } -} -``` - -### acl_has_role -`acl_has_role` is a _view_ method for checking if the account is a member of the specified group. - -```shell -$ near view acl_has_role '{"role": "GroupA", "account_id": ""}' -View call: .acl_has_role({"role": "GroupA", "account_id": ""}) -true -``` - -### acl_has_any_role -`acl_has_any_role` is a _view_ method for checking if an account is a member of at least one of the specified groups. - -```shell -$ near view acl_has_any_role '{"roles": ["GroupA", "GroupB"], "account_id": ""}' -View call: .acl_has_any_role({"roles": ["GroupA", "GroupB"], "account_id": ""}) -true -``` - -### acl_get_super_admins -`acl_get_super_admins` is a _view_ method that shows some super admins. It will skip first `skip` admins and return not more than `limit` number of super admins. - -```shell -$ near view acl_get_super_admins '{"skip": 0, "limit": 2}' -View call: .acl_get_super_admins({"skip": 0, "limit": 2}) -[ '' ] -``` - -### acl_get_admins -`acl_get_admins` is a _view_ method that shows some admins of the group. It will skip first `skip` admins and return not more than `limit` number of admins. - -```shell -$ near view acl_get_admins '{"role": "GroupA", "skip": 0, "limit": 2}' -View call: .acl_get_admins({"role": "GroupA", "skip": 0, "limit": 2}) -[ '' ] -``` - -### acl_get_grantees -`acl_get_grantess` is a _view_ method that shows some members of the group. It will skip the first `skip` members and return not more than `limit` number of members. - -```shell -$ near view acl_get_grantess '{"role": "GroupA", "skip": 0, "limit": 2}' -View call: .acl_get_grantess({"role": "GroupA", "skip": 0, "limit": 2}) -[ '' ] -``` - -## Preparation steps for demonstration -In that document, we are providing some examples of using a contract with an access control plugin. You also can explore the usage examples in the tests in `./access_controllable_base/src/lib.rs` and in `./access_control_role_base/src/lib.rs`. For running tests, please take a look at the **Test running instruction** section. - -1. **Creating an account on testnet** - For demonstration let's create 3 accounts: ``, ``, `` - ```shell - $ near create-account ..testnet --masterAccount .testnet --initialBalance 10 - $ near create-account ..testnet --masterAccount .testnet --initialBalance 10 - $ near create-account ..testnet --masterAccount .testnet --initialBalance 10 - ``` - - In the next sections, we will refer to the `..testnet` as ``, - to the `..testnet` as ``, and to the `..testnet` as `` for simplicity. - -2. **Compile Contract to wasm file** - For compiling the contract, just run the `access_controllable_base/build.sh` script. The target file with compiled contract will be `../target/wasm32-unknown-unknown/release/access_controllable_base.wasm` - - ```shell - $ cd access_controllable_base - $ ./build.sh - $ cd .. - ``` - -3. **Deploy and init a contract** - ```shell - $ near deploy --accountId --wasmFile ../target/wasm32-unknown-unknown/release/access_controllable_base.wasm --initFunction new --initArgs '{}' - ``` - -## Example of using the contract with access control plugin -### Calling the methods with access control -For using the method, `level_a_incr` you should be a member of GroupA. Alice is not a member of any group, so she can't use this method. - -```shell -$ near call level_a_incr --accountId -ERROR -$ near view get_counter -0 -``` - -Let's make Alice a member of GroupA. -```shell -$ near call acl_grant_role '{"role": "GroupA", "account_id": ""}' --accountId -``` - -Now Alice is a member of GroupA and can call the `level_a_incr` method -```shell -$ near call level_a_incr --accountId -$ near view get_counter -1 -``` - -As well as calls the `level_ab_incr` method, which allowed for both GroupA and GroupB members. -```shell -$ near call level_ab_incr --accountId -$ near view get_counter -2 -``` - -### Admin of the group not a member of the group -Note, the admin of the group may not be a member of this group. For example, the `` is a super admin, but he -can't execute the `level_a_incr` method. - -```shell -$ near view get_counter -2 -$ near call level_a_incr --accountId -ERROR -$ near view get_counter -2 -``` - -### Multiple super admins -There may be multiple super admins. - -By default, only `acl_init_super_admin` is exposed on the contract. To add more super admins or to remove super admins, internal functions can be used. - -To make this functionality publicly available, you could add functions like the following to your contract: - -```rust -/// method for adding new super admin -pub fn add_super_admin(&mut self, new_super_admin_account_id: &AccountId) { - ::near_sdk::require!(self.acl_is_super_admin(near_sdk::env::predecessor_account_id()), - "Method can be run only by super admin"); - self.__acl.add_super_admin_unchecked(new_super_admin_account_id); -} - -/// method for removing super admin -pub fn remove_super_admin(&mut self, super_admin_account_id: &AccountId) { - ::near_sdk::require!(self.acl_is_super_admin(near_sdk::env::predecessor_account_id()), - "Method can be run only by super admin"); - self.__acl.revoke_super_admin_unchecked(&super_admin_account_id); -} -``` - -An illustration of adding two administrators can be seen in the test `two_super_admin` in `access/contrllable_base/lib.rs` - -## Tests running instruction -Tests in `access_controllable_base/src/lib.rs` contain examples of interaction with a contract. - -For running test: -1. Generate `wasm` file by running `access_controllable_base/build.sh` script. The target file will be `../target/wasm32-unknown-unknown/release/access_controllable_base.wasm` -2. Run tests `cargo test` - -```shell -$ cd access_controllable_base -$ ./build.sh -$ cargo test -``` - -For tests, we use `workspaces` library and `sandbox` environment. For details, you can explore `../near-plugins-test-utils` crate. \ No newline at end of file diff --git a/examples/access-controllable-examples/access_control_role_base/Cargo.toml b/examples/access-controllable-examples/access_control_role_base/Cargo.toml deleted file mode 100644 index 97ae676..0000000 --- a/examples/access-controllable-examples/access_control_role_base/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "access_control_role_base" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -near-plugins = { path = "../../../near-plugins" } -near-plugins-derive = { path = "../../../near-plugins-derive" } -bitflags = "1.3.2" -borsh = "*" -near-sdk = "4.0.0" \ No newline at end of file diff --git a/examples/access-controllable-examples/access_control_role_base/src/lib.rs b/examples/access-controllable-examples/access_control_role_base/src/lib.rs deleted file mode 100644 index 2b97c4e..0000000 --- a/examples/access-controllable-examples/access_control_role_base/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -use near_plugins::AccessControlRole; -use borsh::{BorshDeserialize, BorshSerialize}; - -#[derive(AccessControlRole, Clone, Copy)] -pub enum Positions { - LevelA, - LevelB, - LevelC -} - -#[cfg(test)] -mod tests { - use near_plugins::AccessControlRole; - use crate::Positions; - - #[test] - fn base_scenario() { - let role: Positions = Positions::LevelA; - - assert_eq!(Positions::acl_super_admin_permission(), 1); - assert_eq!(role.acl_permission(), 1 << 1); - assert_eq!(role.acl_admin_permission(), 1 << 2); - - //https://docs.rs/bitflags/latest/bitflags/ - assert_eq!(crate::RoleFlags::LEVELA.bits, role.acl_permission() as u128); - } -} diff --git a/examples/access-controllable-examples/access_controllable_base/Cargo.toml b/examples/access-controllable-examples/access_controllable_base/Cargo.toml deleted file mode 100644 index 5223ccc..0000000 --- a/examples/access-controllable-examples/access_controllable_base/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "access_controllable_base" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -near-sdk = "4.0.0" -near-plugins = { path = "../../../near-plugins" } -near-plugins-derive = { path = "../../../near-plugins-derive" } -borsh = "0.9.3" -bitflags = "*" - -[dev-dependencies] -near-primitives = "0.14.0" -workspaces = "0.6" -tokio = { version = "1.1", features = ["rt", "macros"] } -serde_json = "1.0.74" -near-plugins-test-utils = { path = "../../near-plugins-test-utils" } - diff --git a/examples/access-controllable-examples/access_controllable_base/build.sh b/examples/access-controllable-examples/access_controllable_base/build.sh deleted file mode 100755 index 4900904..0000000 --- a/examples/access-controllable-examples/access_controllable_base/build.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -mkdir -p ../../res - -RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release - -cp ../../target/wasm32-unknown-unknown/release/access_controllable_base.wasm ../../res/ \ No newline at end of file diff --git a/examples/access-controllable-examples/access_controllable_base/src/lib.rs b/examples/access-controllable-examples/access_controllable_base/src/lib.rs deleted file mode 100644 index 5cc685f..0000000 --- a/examples/access-controllable-examples/access_controllable_base/src/lib.rs +++ /dev/null @@ -1,204 +0,0 @@ -use borsh::{BorshDeserialize, BorshSerialize}; -use near_plugins::AccessControlRole; -use near_plugins::AccessControllable; -use near_plugins_derive::access_control; -use near_plugins_derive::access_control_any; -use near_sdk::{AccountId, env}; -use near_sdk::near_bindgen; - -/// All types of access groups -#[derive(AccessControlRole, Clone, Copy)] -pub enum UsersGroups { - GroupA, - GroupB, -} - -#[near_bindgen] -#[access_control(role_type(UsersGroups))] -#[derive(Default, BorshSerialize, BorshDeserialize)] -struct Counter { - counter: u64, -} - -#[near_bindgen] -impl Counter { - /// In the constructor we set up a super admin, - /// which can control the member lists of all user groups - #[init] - pub fn new() -> Self { - let mut contract: Counter = Self { - counter: 0, - __acl: __Acl::default(), - }; - - contract.acl_init_super_admin(near_sdk::env::predecessor_account_id()); - - contract - } - - /// unprotected function, every one can call this function - pub fn unprotected(&mut self) { - self.counter += 1; - } - - /// only the users from GroupA can call this method - #[access_control_any(roles(UsersGroups::GroupA))] - pub fn level_a_incr(&mut self) { - self.counter += 1; - } - - /// only the users from GroupA or GroupB can call this method - #[access_control_any(roles(UsersGroups::GroupA, UsersGroups::GroupB))] - pub fn level_ab_incr(&mut self) { - self.counter += 1; - } - - /// view method for get current counter value, every one can use it - pub fn get_counter(&self) -> u64 { - self.counter - } - - /// method for adding new super admin - pub fn add_super_admin(&mut self, new_super_admin_account_id: &AccountId) { - ::near_sdk::require!(self.acl_is_super_admin(near_sdk::env::predecessor_account_id()), - "Method can be run only by super admin"); - self.__acl.add_super_admin_unchecked(new_super_admin_account_id); - } - - /// method for removing super admin - pub fn remove_super_admin(&mut self, super_admin_account_id: &AccountId) { - ::near_sdk::require!(self.acl_is_super_admin(near_sdk::env::predecessor_account_id()), - "Method can be run only by super admin"); - self.__acl.revoke_super_admin_unchecked(&super_admin_account_id); - } -} - -#[cfg(test)] -mod tests { - use crate::UsersGroups; - use near_plugins_test_utils::*; - use serde_json::json; - - const WASM_FILEPATH: &str = - "../../target/wasm32-unknown-unknown/release/access_controllable_base.wasm"; - - #[tokio::test] - async fn base_scenario() { - let (contract_holder, contract) = get_contract(WASM_FILEPATH).await; - assert!(call!(contract, "new").await); - - check_counter(&contract, 0).await; - - assert!(call!(contract, "unprotected").await); - - check_counter(&contract, 1).await; - - let alice = get_subaccount(&contract_holder, "alice").await; - - let is_super_admin: bool = view!( - contract, - "acl_is_super_admin", - &json!({"account_id": alice.id()}) - ); - assert!(!is_super_admin); - - assert!(!call!(&alice, contract, "level_a_incr").await); - check_counter(&contract, 1).await; - - assert!( - call!( - contract, - "acl_grant_role", - &json!({"role": String::from(UsersGroups::GroupA), "account_id": alice.id()}) - ) - .await - ); - - let alice_has_role: bool = view!( - contract, - "acl_has_role", - &json!({"role": String::from(UsersGroups::GroupA), "account_id": alice.id()}) - ); - assert!(alice_has_role); - - assert!(call!(&alice, contract, "level_a_incr").await); - - check_counter(&contract, 2).await; - - let bob = get_subaccount(&contract_holder, "bob").await; - assert!( - call!( - contract, - "acl_add_admin", - &json!({"role": String::from(UsersGroups::GroupA), "account_id": bob.id()}) - ) - .await - ); - - let bob_is_admin: bool = view!( - contract, - "acl_is_admin", - &json!({"role": String::from(UsersGroups::GroupA), "account_id": bob.id()}) - ); - assert!(bob_is_admin); - - assert!(!call!(&bob, contract, "level_a_incr").await); - - check_counter(&contract, 2).await; - - assert!(call!(&alice, contract, "level_ab_incr").await); - check_counter(&contract, 3).await; - - assert!(!call!(&bob, contract, "level_ab_incr").await); - check_counter(&contract, 3).await; - - assert!( - call!( - contract, - "acl_grant_role", - &json!({"role": String::from(UsersGroups::GroupB), "account_id": bob.id()}) - ) - .await - ); - assert!(call!(&bob, contract, "level_ab_incr").await); - check_counter(&contract, 4).await; - - assert!(!call!(&bob, contract, "level_a_incr").await); - check_counter(&contract, 4).await; - - assert!(call!(&bob, contract, "acl_renounce_admin", &json!({"role": String::from(UsersGroups::GroupA)})).await); - assert!(call!(&alice, contract, "acl_renounce_role", &json!({"role": String::from(UsersGroups::GroupA)})).await); - } - - #[tokio::test] - async fn two_super_admin() { - let (contract_holder, contract) = get_contract(WASM_FILEPATH).await; - assert!(call!(contract, "new").await); - - let alice = get_subaccount(&contract_holder, "alice").await; - let is_admin: bool = view!( - contract, - "acl_is_super_admin", - &json!({"account_id": alice.id()}) - ); - - assert!(!is_admin); - - assert!(call!(contract, "add_super_admin", &json!({"new_super_admin_account_id": alice.id()})).await); - - let is_admin: bool = view!( - contract, - "acl_is_super_admin", - &json!({"account_id": alice.id()}) - ); - - assert!(is_admin); - - let is_admin: bool = view!( - contract, - "acl_is_super_admin", - &json!({"account_id": contract.id()}) - ); - assert!(is_admin); - } -} diff --git a/near-plugins/src/access_controllable.rs b/near-plugins/src/access_controllable.rs index 7aa0eb2..34a8c14 100644 --- a/near-plugins/src/access_controllable.rs +++ b/near-plugins/src/access_controllable.rs @@ -16,22 +16,52 @@ use near_sdk::AccountId; /// /// [does not support]: https://github.com/near/near-sdk-rs/blob/9d99077c6acfde68c06845f2a1eb2b5ed7983401/near-sdk/compilation_tests/impl_generic.stderr pub trait AccessControllable { - /// Returns the storage prefix for collections related to access control. + /// Returns the storage prefix for collections related to access control. By + /// default `b"__acl"` is used. + /// + /// Attribute `storage_prefix` can be used to set a different prefix: + /// + /// ```ignore + /// #[access_controllable(storage_prefix="CUSTOM_KEY")] + /// struct Contract { /* ... */} + /// ``` fn acl_storage_prefix() -> &'static [u8]; /// Adds `account_id` as super-admin __without__ checking any permissions in - /// case there are no super-admins. This function can be used to add a - /// super-admin during contract initialization. Moreover, it may provide a - /// recovery mechanism if (mistakenly) all super-admins have been removed. + /// case there are no super-admins. If there is already a super-admin, it + /// has no effect. This function can be used to add a super-admin during + /// contract initialization. Moreover, it may provide a recovery mechanism + /// if (mistakenly) all super-admins have been removed. /// /// The return value indicates whether `account_id` was added as /// super-admin. /// /// It is `#[private]` in the implementation provided by this trait, i.e. /// only the contract itself may call this method. + /// + /// If a super-admin is added, the following event will be emitted: + /// + /// ```json + /// { + /// "standard":"AccessControllable", + /// "version":"1.0.0", + /// "event":"super_admin_added", + /// "data":{ + /// "account":"", + /// "by":"" + /// } + /// } + /// ``` + /// + /// Despite the restrictions of this method, there might be multiple + /// super-admins. Adding more than one admin requires the use of internal + /// methods. The default implementation of `AccessControllable` provided by + /// this trait offers `add_super_admin_unchecked.` fn acl_init_super_admin(&mut self, account_id: AccountId) -> bool; - /// Returns whether `account_id` is a super-admin. + /// Returns whether `account_id` is a super-admin. A super-admin has admin + /// permissions for every role. However, a super-admin is not considered + /// grantee of any role. fn acl_is_super_admin(&self, account_id: AccountId) -> bool; /// Makes `account_id` an admin provided that the predecessor has sufficient @@ -42,10 +72,29 @@ pub trait AccessControllable { /// `None` is returned and internal state is not modified. /// /// Note that any role may have multiple (or zero) admins. + /// + /// If an admin is added, the following event will be emitted: + /// + /// ```json + /// { + /// "standard":"AccessControllable", + /// "version":"1.0.0", + /// "event":"admin_added", + /// "data": { + /// "role":"", + /// "account":"", + /// "by":"" + /// } + /// } + /// ``` fn acl_add_admin(&mut self, role: String, account_id: AccountId) -> Option; /// Returns whether `account_id` is an admin for `role`. Super-admins are /// admins for _every_ role. + /// + /// Note that adding an account as admin for `role` does not make that + /// account a grantee of `role`. Instead, `role` has to be granted + /// explicitly. The same applies to super-admins. fn acl_is_admin(&self, role: String, account_id: AccountId) -> bool; /// Revokes admin permissions for `role` from `account_id` provided that the @@ -55,10 +104,28 @@ pub trait AccessControllable { /// In case of sufficient permissions, the returned `Some(bool)` indicates /// whether `account_id` was an admin for `role`. Without permissions, /// `None` is returned and internal state is not modified. + /// + /// If an admin is revoked, the following event will be emitted: + /// + /// ```json + /// { + /// "standard":"AccessControllable", + /// "version":"1.0.0", + /// "event":"admin_revoked", + /// "data":{ + /// "role":"", + /// "account":"", + /// "by":"" + /// } + /// } + /// ``` fn acl_revoke_admin(&mut self, role: String, account_id: AccountId) -> Option; /// Revokes admin permissions for `role` from the predecessor. Returns /// whether the predecessor was an admin for `role`. + /// + /// If an admin is revoked, the event described in + /// [`Self::acl_revoke_admin`] will be emitted. fn acl_renounce_admin(&mut self, role: String) -> bool; /// Grants `role` to `account_id` provided that the predecessor has @@ -67,9 +134,26 @@ pub trait AccessControllable { /// In case of sufficient permissions, the returned `Some(bool)` indicates /// whether `account_id` is a new grantee of `role`. Without permissions, /// `None` is returned and internal state is not modified. + /// + /// If a role is granted, the following event will be emitted: + /// + /// ```json + /// { + /// "standard":"AccessControllable", + /// "version":"1.0.0", + /// "event":"role_granted", + /// "data": { + /// "role":"", + /// "to":"", + /// "by":"" + /// } + /// } + /// ``` fn acl_grant_role(&mut self, role: String, account_id: AccountId) -> Option; - /// Returns whether `account_id` has been granted `role`. + /// Returns whether `account_id` has been granted `role`. Note that adding + /// an account as (super-)admin for `role` does not make that account a + /// grantee of `role`. Instead, `role` has to be granted explicitly. fn acl_has_role(&self, role: String, account_id: AccountId) -> bool; /// Revokes `role` from `account_id` provided that the predecessor has @@ -78,10 +162,28 @@ pub trait AccessControllable { /// In case of sufficient permissions, the returned `Some(bool)` indicates /// whether `account_id` was a grantee of `role`. Without permissions, /// `None` is returned and internal state is not modified. + /// + /// If a role is revoked, the following event will be emitted: + /// + /// ```json + /// { + /// "standard":"AccessControllable", + /// "version":"1.0.0", + /// "event":"role_revoked", + /// "data": { + /// "role":"", + /// "from":"", + /// "by":"" + /// } + /// } + /// ``` fn acl_revoke_role(&mut self, role: String, account_id: AccountId) -> Option; /// Revokes `role` from the predecessor and returns whether it was a grantee /// of `role`. + /// + /// If a role is revoked, the event described in [`Self::acl_revoke_role`] + /// will be emitted. fn acl_renounce_role(&mut self, role: String) -> bool; /// Returns whether `account_id` has been granted any of the `roles`. diff --git a/near-plugins/tests/access_controllable.rs b/near-plugins/tests/access_controllable.rs index 1d3ef98..c7f0467 100644 --- a/near-plugins/tests/access_controllable.rs +++ b/near-plugins/tests/access_controllable.rs @@ -7,16 +7,17 @@ use common::utils::{ assert_insufficient_acl_permissions, assert_private_method_failure, assert_success_with, }; use near_sdk::serde_json::json; +use std::collections::HashMap; use std::convert::TryFrom; use std::path::Path; use workspaces::network::Sandbox; use workspaces::result::ExecutionFinalResult; -use workspaces::{Account, Contract, Worker}; +use workspaces::{Account, AccountId, Contract, Worker}; const PROJECT_PATH: &str = "./tests/contracts/access_controllable"; /// All roles which are defined in the contract in [`PROJECT_PATH`]. -const ALL_ROLES: [&str; 3] = ["LevelA", "LevelB", "LevelC"]; +const ALL_ROLES: [&str; 3] = ["Increaser", "Skipper", "Resetter"]; /// Bundles resources required in tests. struct Setup { @@ -33,13 +34,36 @@ impl Setup { self.contract.contract().as_account() } + /// Deploys the contract and calls the initialization method without passing any accounts to be + /// added as admin or grantees. async fn new() -> anyhow::Result { + Self::new_with_admins_and_grantees(Default::default(), Default::default()).await + } + + /// Deploys the contract and passes `admins` and `grantees` to the initialization method. Note + /// that accounts corresponding to the ids in `admins` and `grantees` are _not_ created. + async fn new_with_admins_and_grantees( + admins: HashMap, + grantees: HashMap, + ) -> anyhow::Result { let worker = workspaces::sandbox().await?; let wasm = common::repo::compile_project(&Path::new(PROJECT_PATH), "access_controllable").await?; let contract = AccessControllableContract::new(worker.dev_deploy(&wasm).await?); let account = worker.dev_create_account().await?; + contract + .contract() + .call("new") + .args_json(json!({ + "admins": admins, + "grantees": grantees, + })) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(Self { worker, contract, @@ -81,12 +105,12 @@ impl Setup { } } -async fn call_restricted_greeting( +async fn call_skip_one( contract: &Contract, caller: &Account, ) -> workspaces::Result { caller - .call(contract.id(), "restricted_greeting") + .call(contract.id(), "skip_one") .args_json(()) .max_gas() .transact() @@ -95,33 +119,48 @@ async fn call_restricted_greeting( /// Smoke test of contract setup and basic functionality. #[tokio::test] -async fn test_set_and_get_status() -> anyhow::Result<()> { +async fn test_increase_and_get_counter() -> anyhow::Result<()> { let Setup { contract, account, .. } = Setup::new().await?; let contract = contract.contract(); - let message = "hello world"; account - .call(contract.id(), "set_status") - .args_json(json!({ - "message": message, - })) + .call(contract.id(), "increase") .max_gas() .transact() .await? .into_result()?; - let res: String = account - .call(contract.id(), "get_status") - .args_json(json!({ - "account_id": account.id(), - })) + let res: u64 = account + .call(contract.id(), "get_counter") .view() .await? .json()?; - assert_eq!(res, message); + assert_eq!(res, 1); + Ok(()) +} + +#[tokio::test] +async fn test_acl_initialization_in_constructor() -> anyhow::Result<()> { + let admin_id: AccountId = "admin.acl_test.near".parse().unwrap(); + let grantee_id: AccountId = "grantee.acl_test.near".parse().unwrap(); + let setup = Setup::new_with_admins_and_grantees( + HashMap::from([("Increaser".to_string(), admin_id.clone())]), + HashMap::from([("Resetter".to_string(), grantee_id.clone())]), + ) + .await?; + + setup + .contract + .assert_acl_is_admin(true, "Increaser", &admin_id) + .await; + setup + .contract + .assert_acl_has_role(true, "Resetter", &grantee_id) + .await; + Ok(()) } @@ -345,7 +384,7 @@ async fn test_acl_is_admin() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelA"; + let role = "Increaser"; let is_admin = contract.acl_is_admin(&account, role, account.id()).await?; assert_eq!(is_admin, false); @@ -370,7 +409,7 @@ async fn test_acl_add_admin() -> anyhow::Result<()> { .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelA"; + let role = "Increaser"; let acc_adding_admin = account; let acc_to_be_admin = worker.dev_create_account().await?; @@ -413,7 +452,7 @@ async fn test_acl_add_admin_unchecked() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelA"; + let role = "Increaser"; contract .assert_acl_is_admin(false, role, account.id()) @@ -436,7 +475,7 @@ async fn test_acl_add_admin_unchecked() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_revoke_admin() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelB"; + let role = "Skipper"; let admin = setup.new_account_as_admin(&[role]).await?; setup @@ -451,7 +490,7 @@ async fn test_acl_revoke_admin() -> anyhow::Result<()> { .acl_revoke_admin(&revoker, role, admin.id()) .await?; assert_eq!(res, None); - let revoker = setup.new_account_as_admin(&["LevelA"]).await?; + let revoker = setup.new_account_as_admin(&["Increaser"]).await?; let res = setup .contract .acl_revoke_admin(&revoker, role, admin.id()) @@ -489,7 +528,7 @@ async fn test_acl_revoke_admin() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_renounce_admin() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelC"; + let role = "Resetter"; // An account which is isn't admin calls `acl_renounce_admin`. let res = setup @@ -517,51 +556,53 @@ async fn test_acl_renounce_admin() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_revoke_admin_unchecked() -> anyhow::Result<()> { let setup = Setup::new().await?; - let account = setup.new_account_as_admin(&["LevelA", "LevelC"]).await?; + let account = setup + .new_account_as_admin(&["Increaser", "Resetter"]) + .await?; setup .contract - .assert_acl_is_admin(true, "LevelA", account.id()) + .assert_acl_is_admin(true, "Increaser", account.id()) .await; setup .contract - .assert_acl_is_admin(true, "LevelC", account.id()) + .assert_acl_is_admin(true, "Resetter", account.id()) .await; // Revoke admin permissions for one of the roles. let res = setup .contract - .acl_revoke_admin_unchecked(setup.contract_account(), "LevelA", account.id()) + .acl_revoke_admin_unchecked(setup.contract_account(), "Increaser", account.id()) .await?; assert_success_with(res, true); setup .contract - .assert_acl_is_admin(false, "LevelA", account.id()) + .assert_acl_is_admin(false, "Increaser", account.id()) .await; setup .contract - .assert_acl_is_admin(true, "LevelC", account.id()) + .assert_acl_is_admin(true, "Resetter", account.id()) .await; // Revoke admin permissions for the other role too. let res = setup .contract - .acl_revoke_admin_unchecked(setup.contract_account(), "LevelC", account.id()) + .acl_revoke_admin_unchecked(setup.contract_account(), "Resetter", account.id()) .await?; assert_success_with(res, true); setup .contract - .assert_acl_is_admin(false, "LevelA", account.id()) + .assert_acl_is_admin(false, "Increaser", account.id()) .await; setup .contract - .assert_acl_is_admin(false, "LevelC", account.id()) + .assert_acl_is_admin(false, "Resetter", account.id()) .await; // Revoking behaves as expected if the permission is not present. let res = setup .contract - .acl_revoke_admin_unchecked(setup.contract_account(), "LevelC", account.id()) + .acl_revoke_admin_unchecked(setup.contract_account(), "Resetter", account.id()) .await?; assert_success_with(res, false); @@ -574,7 +615,7 @@ async fn test_acl_has_role() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelA"; + let role = "Increaser"; let has_role = contract.acl_has_role(&account, role, account.id()).await?; assert_eq!(has_role, false); @@ -599,7 +640,7 @@ async fn test_acl_grant_role() -> anyhow::Result<()> { .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelB"; + let role = "Skipper"; let granter = account; let grantee = worker.dev_create_account().await?; @@ -642,7 +683,7 @@ async fn test_acl_grant_role_unchecked() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let contract_account = contract.contract().as_account(); - let role = "LevelA"; + let role = "Increaser"; contract .assert_acl_has_role(false, role, account.id()) @@ -665,7 +706,7 @@ async fn test_acl_grant_role_unchecked() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_revoke_role() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelB"; + let role = "Skipper"; let grantee = setup.new_account_with_roles(&[role]).await?; setup @@ -680,7 +721,7 @@ async fn test_acl_revoke_role() -> anyhow::Result<()> { .acl_revoke_role(&revoker, role, grantee.id()) .await?; assert_eq!(res, None); - let revoker = setup.new_account_as_admin(&["LevelA"]).await?; + let revoker = setup.new_account_as_admin(&["Increaser"]).await?; let res = setup .contract .acl_revoke_role(&revoker, role, grantee.id()) @@ -718,7 +759,7 @@ async fn test_acl_revoke_role() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_renounce_role() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelC"; + let role = "Resetter"; // An account which is isn't grantee calls `acl_renounce_role`. let res = setup @@ -746,51 +787,53 @@ async fn test_acl_renounce_role() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_revoke_role_unchecked() -> anyhow::Result<()> { let setup = Setup::new().await?; - let account = setup.new_account_with_roles(&["LevelA", "LevelC"]).await?; + let account = setup + .new_account_with_roles(&["Increaser", "Resetter"]) + .await?; setup .contract - .assert_acl_has_role(true, "LevelA", account.id()) + .assert_acl_has_role(true, "Increaser", account.id()) .await; setup .contract - .assert_acl_has_role(true, "LevelC", account.id()) + .assert_acl_has_role(true, "Resetter", account.id()) .await; // Revoke one of the roles. let res = setup .contract - .acl_revoke_role_unchecked(setup.contract_account(), "LevelA", account.id()) + .acl_revoke_role_unchecked(setup.contract_account(), "Increaser", account.id()) .await?; assert_success_with(res, true); setup .contract - .assert_acl_has_role(false, "LevelA", account.id()) + .assert_acl_has_role(false, "Increaser", account.id()) .await; setup .contract - .assert_acl_has_role(true, "LevelC", account.id()) + .assert_acl_has_role(true, "Resetter", account.id()) .await; // Revoke the other role too. let res = setup .contract - .acl_revoke_role_unchecked(setup.contract_account(), "LevelC", account.id()) + .acl_revoke_role_unchecked(setup.contract_account(), "Resetter", account.id()) .await?; assert_success_with(res, true); setup .contract - .assert_acl_has_role(false, "LevelA", account.id()) + .assert_acl_has_role(false, "Increaser", account.id()) .await; setup .contract - .assert_acl_has_role(false, "LevelC", account.id()) + .assert_acl_has_role(false, "Resetter", account.id()) .await; // Revoking behaves as expected if the role is not granted. let res = setup .contract - .acl_revoke_role_unchecked(setup.contract_account(), "LevelC", account.id()) + .acl_revoke_role_unchecked(setup.contract_account(), "Resetter", account.id()) .await?; assert_success_with(res, false); @@ -801,48 +844,51 @@ async fn test_acl_revoke_role_unchecked() -> anyhow::Result<()> { async fn test_attribute_access_control_any() -> anyhow::Result<()> { let setup = Setup::new().await?; let raw_contract = setup.contract.contract(); - let method_name = "restricted_greeting"; - let allowed_roles = vec!["LevelA".to_string(), "LevelC".to_string()]; - let expected_result = "hello world".to_string(); + let method_name = "skip_one"; + let allowed_roles = vec!["Increaser".to_string(), "Skipper".to_string()]; // Account without any of the required permissions is restricted. let account = setup.new_account_with_roles(&[]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; + let res = call_skip_one(raw_contract, &account).await?; assert_insufficient_acl_permissions(res, method_name, allowed_roles.clone()); - let account = setup.new_account_with_roles(&["LevelB"]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; + let account = setup.new_account_with_roles(&["Resetter"]).await?; + let res = call_skip_one(raw_contract, &account).await?; assert_insufficient_acl_permissions(res, method_name, allowed_roles.clone()); // A super-admin which has not been granted the role is restricted. let super_admin = setup.new_super_admin_account().await?; - let res = call_restricted_greeting(raw_contract, &super_admin).await?; + let res = call_skip_one(raw_contract, &super_admin).await?; assert_insufficient_acl_permissions(res, method_name, allowed_roles.clone()); // An admin for a permitted role is restricted (no grantee of role itself). - let admin = setup.new_account_as_admin(&["LevelA"]).await?; - let res = call_restricted_greeting(raw_contract, &admin).await?; + let admin = setup.new_account_as_admin(&["Increaser"]).await?; + let res = call_skip_one(raw_contract, &admin).await?; assert_insufficient_acl_permissions(res, method_name, allowed_roles.clone()); // Account with one of the required permissions succeeds. - let account = setup.new_account_with_roles(&["LevelA"]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; - assert_success_with(res, expected_result.clone()); - let account = setup.new_account_with_roles(&["LevelC"]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; - assert_success_with(res, expected_result.clone()); - let account = setup.new_account_with_roles(&["LevelA", "LevelB"]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; - assert_success_with(res, expected_result.clone()); + let account = setup.new_account_with_roles(&["Increaser"]).await?; + let res = call_skip_one(raw_contract, &account).await?; + assert_success_with(res, 2); + let account = setup.new_account_with_roles(&["Skipper"]).await?; + let res = call_skip_one(raw_contract, &account).await?; + assert_success_with(res, 4); + let account = setup + .new_account_with_roles(&["Increaser", "Resetter"]) + .await?; + let res = call_skip_one(raw_contract, &account).await?; + assert_success_with(res, 6); // Account with both permissions succeeds. - let account = setup.new_account_with_roles(&["LevelA", "LevelC"]).await?; - let res = call_restricted_greeting(raw_contract, &account).await?; - assert_success_with(res, expected_result.clone()); let account = setup - .new_account_with_roles(&["LevelA", "LevelB", "LevelC"]) + .new_account_with_roles(&["Increaser", "Skipper"]) + .await?; + let res = call_skip_one(raw_contract, &account).await?; + assert_success_with(res, 8); + let account = setup + .new_account_with_roles(&["Increaser", "Skipper", "Resetter"]) .await?; - let res = call_restricted_greeting(raw_contract, &account).await?; - assert_success_with(res, expected_result.clone()); + let res = call_skip_one(raw_contract, &account).await?; + assert_success_with(res, 10); Ok(()) } @@ -932,7 +978,7 @@ async fn test_acl_get_super_admins() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_get_admins() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelB"; + let role = "Skipper"; let admin_ids = vec![ setup.new_account_as_admin(&[role]).await?, @@ -1003,7 +1049,7 @@ async fn test_acl_get_admins() -> anyhow::Result<()> { #[tokio::test] async fn test_acl_get_grantees() -> anyhow::Result<()> { let setup = Setup::new().await?; - let role = "LevelA"; + let role = "Increaser"; let grantee_ids = vec![ setup.new_account_with_roles(&[role]).await?, @@ -1101,7 +1147,7 @@ async fn test_acl_add_admin_unchecked_is_private() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let res = contract - .acl_add_admin_unchecked(&account, "LevelA", account.id()) + .acl_add_admin_unchecked(&account, "Increaser", account.id()) .await?; assert_private_method_failure(res, "acl_add_admin_unchecked"); Ok(()) @@ -1113,7 +1159,7 @@ async fn test_acl_revoke_admin_unchecked_is_private() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let res = contract - .acl_revoke_admin_unchecked(&account, "LevelA", account.id()) + .acl_revoke_admin_unchecked(&account, "Increaser", account.id()) .await?; assert_private_method_failure(res, "acl_revoke_admin_unchecked"); Ok(()) @@ -1125,7 +1171,7 @@ async fn test_acl_grant_role_unchecked_is_private() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let res = contract - .acl_grant_role_unchecked(&account, "LevelA", account.id()) + .acl_grant_role_unchecked(&account, "Increaser", account.id()) .await?; assert_private_method_failure(res, "acl_grant_role_unchecked"); Ok(()) @@ -1137,7 +1183,7 @@ async fn test_acl_revoke_role_unchecked_is_private() -> anyhow::Result<()> { contract, account, .. } = Setup::new().await?; let res = contract - .acl_revoke_role_unchecked(&account, "LevelA", account.id()) + .acl_revoke_role_unchecked(&account, "Increaser", account.id()) .await?; assert_private_method_failure(res, "acl_revoke_role_unchecked"); Ok(()) diff --git a/near-plugins/tests/contracts/access_controllable/src/lib.rs b/near-plugins/tests/contracts/access_controllable/src/lib.rs index 96493ce..e0ed74a 100644 --- a/near-plugins/tests/contracts/access_controllable/src/lib.rs +++ b/near-plugins/tests/contracts/access_controllable/src/lib.rs @@ -1,79 +1,137 @@ use near_plugins::{access_control, access_control_any, AccessControlRole, AccessControllable}; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::serde::{Deserialize, Serialize}; -use near_sdk::{env, log, near_bindgen, AccountId}; +use near_sdk::{env, near_bindgen, AccountId, PanicOnDefault}; use std::collections::HashMap; /// Roles are represented by enum variants. +/// +/// Deriving `AccessControlRole` ensures `Role` can be used in `AccessControllable`. #[derive(AccessControlRole, Deserialize, Serialize, Copy, Clone)] #[serde(crate = "near_sdk::serde")] pub enum Role { - /// Represents LevelA. - LevelA, - /// Represents LevelB. - LevelB, - /// Represents LevelC. - LevelC, + /// Grantees of this role may call the contract method `skip_one`. + Increaser, + /// Grantees of this role may call the contract method `skip_one`. + Skipper, + /// Grantees of this role may call the contract method `reset`. + Resetter, } +/// Pass `Role` to the `access_controllable` macro. #[access_control(role_type(Role))] #[near_bindgen] -#[derive(Default, BorshDeserialize, BorshSerialize)] -pub struct StatusMessage { - records: HashMap, +#[derive(PanicOnDefault, BorshDeserialize, BorshSerialize)] +pub struct Counter { + counter: u64, } #[near_bindgen] -impl StatusMessage { - // Adding an initial super-admin can be done via trait method - // `AccessControllable::acl_init_super_admin`, which is automatically - // implemented and exported for the contract by `#[access_controllable]`. - // - // Once an account is (super-)admin, it may add other admins and grant - // roles. - // - // In addition, there are internal `*_unchecked` methods for example: - // - // ``` - // self.__acl.add_admin_unchecked(role, account_id); - // self.__acl.grant_role_unchecked(role, account_id); - // ``` - // - // **Attention**: Contracts should call `__acl.*_unchecked` methods only - // from within methods with attribute `#[init]` or `#[private]`. - - #[payable] - pub fn set_status(&mut self, message: String) { - let account_id = env::signer_account_id(); - log!("{} set_status with message {}", account_id, message); - self.records.insert(account_id, message); +impl Counter { + /// Constructor of the contract which optionally adds access control admins and grants roles if + /// either of the maps passed as parameters contains account ids. In that case, the contract + /// itself is made super admin, which permits it to add admins and grantees for every role. + /// + /// Both `admins` and `grantees` map the string representation of a role to an account id. With + /// standard `serde` deserialization, the string representation of a role corresponds to the + /// identifier of the enum variant, i.e. `"Updater"` for `Role::Updater`. + #[init] + pub fn new(admins: HashMap, grantees: HashMap) -> Self { + let mut contract = Self { + counter: 0, + // Initialize `AccessControllable` plugin state. + __acl: Default::default(), + }; + + if admins.len() > 0 || grantees.len() > 0 { + // First we make the contract itself super admin to allow it adding admin and grantees. + // That can be done via trait method `AccessControllable::acl_init_super_admin`, which is + // automatically implemented and exported for the contract by `#[access_controllable]`. + near_sdk::require!( + contract.acl_init_super_admin(env::current_account_id()), + "Failed to initialize super admin", + ); + + // Add admins. + for (role, account_id) in admins.into_iter() { + let result = contract.acl_add_admin(role, account_id); + near_sdk::require!(Some(true) == result, "Failed to add admin"); + } + + // Grant roles. + for (role, account_id) in grantees.into_iter() { + let result = contract.acl_grant_role(role, account_id); + near_sdk::require!(Some(true) == result, "Failed to grant role"); + } + + // Using internal `*_unchecked` methods is another option for adding (super) admins and + // granting roles, for example: + // + // ``` + // contract.__acl.add_admin_unchecked(role, account_id); + // contract.__acl.grant_role_unchecked(role, account_id); + // ``` + // + // **Attention**: for security reasons, `__acl.*_unchecked` methods should only be called + // from within methods with attribute `#[init]` or `#[private]`. + } + + contract } - pub fn get_status(&self, account_id: AccountId) -> Option { - log!("get_status for account_id {}", account_id); - self.records.get(&account_id).cloned() + /// Returns the current value of the counter. + /// + /// This method has no access control. Anyone can call it successfully. + pub fn get_counter(&self) -> u64 { + self.counter } - #[access_control_any(roles(Role::LevelA, Role::LevelC))] - pub fn restricted_greeting(&self) -> String { - "hello world".to_string() + /// Increases the counter by one and returns its new value. + /// + /// This method has no access control. Anyone can call it successfully. + pub fn increase(&mut self) -> u64 { + self.counter += 1; + self.counter } - // In addition, `AccessControllable` trait methods can be called directly: - // - // ``` - // pub fn foo(&self) { - // let role = Role::LevelA; - // if self.acl_has_role(role.into(), &env::predecessor_account_id()) { - // // .. - // } - // } - // ``` + /// Increases the counter by two and returns its new value. + /// + /// Only an account that was granted either `Role::Increaser` or `Role::Skipper` may + /// successfully call this method. + #[access_control_any(roles(Role::Increaser, Role::Skipper))] + pub fn skip_one(&mut self) -> u64 { + self.counter += 2; + self.counter + } + + /// Resets the counters value to zero. + /// + /// Only an account that was granted `Role:Resetter` may successfully call this method. + #[access_control_any(roles(Role::Resetter))] + pub fn reset(&mut self) { + self.counter = 0; + } + + /// The implementation of `AccessControllable` provided by `near-plugins` + /// adds further methods to the contract which are not part of the trait. + /// Most of them are implemented for the type that holds the plugin's state, + /// here `__acl`. + /// + /// This function shows how these methods can be exposed on the contract. + /// Usually this should involve security checks, for example requiring the + /// caller to be a super admin. + pub fn add_super_admin(&mut self, account_id: AccountId) -> bool { + near_sdk::require!( + self.acl_is_super_admin(env::predecessor_account_id()), + "Only super admins are allowed to add other super admins." + ); + self.__acl.add_super_admin_unchecked(&account_id) + } } /// Exposing internal methods to facilitate integration testing. #[near_bindgen] -impl StatusMessage { +impl Counter { #[private] pub fn acl_add_super_admin_unchecked(&mut self, account_id: AccountId) -> bool { self.__acl.add_super_admin_unchecked(&account_id)