(3 minute read)
Over at nayms.io we're building an on-chain insurance contracts marketplace which has a complex role hierarchies stipulating who can do what and in what context.
For instance, a given insurance policy has an asset manager, client manager and broker. Each of these people themselves belong to entities (companies) and may have varying roles within their entities, e.g. entity admin, manager, entity representative, etc.
Thus, within our smart contracts we needed to be able to first of all express these role permissions as well as apply them to functions calls throughout our contract ecosystem.
In order to accomplish this we decided to encode this permission data into its own contract - an Access Control List (ACL).
Here were the rough requirements:
Ability to assign roles within a specific context/namespace
For example, a user may be the Entity manager for Entity1 but not for Entity2.
Contexts can be any bytes32 values, though in practice the context for a contract is taken to be the hash of its address (hence why we have the generateContextFromAddress() method.
Ability to enable users to assign roles for other users
Ability to do capability-based lookups
Instead of asking "does this user have role X?", we want to be able to ask "is this user capable of doing Y?". "Role groups" in represent capabilities.
A role group consists of one or more roles.
A role can belong to more than one role group.
Each role group is global across all contexts, i.e. a Role group A in context X has the same set of roles as Role group A in context Y.
Ability for a contract to assign any role within its own context
For example, when a policy contract is deployed it should should be able to assign its owner within its constructor.
Otherwise, only a system admin would be able to assign the initial role for a contract's context.
Here is the ACL interface:
interface IACL {
// admin
function isAdmin(address _addr) external view returns (bool);
function addAdmin(address _addr) external;
function removeAdmin(address _addr) external;
// contexts
function getNumContexts() external view returns (uint256);
function getContextAtIndex(uint256 _index) external view returns (bytes32);
function getNumUsersInContext(bytes32 _context) external view returns (uint256);
function getUserInContextAtIndex(bytes32 _context, uint _index) external view returns (address);
// users
function getNumContextsForUser(address _addr) external view returns (uint256);
function getContextForUserAtIndex(address _addr, uint256 _index) external view returns (bytes32);
function userSomeHasRoleInContext(bytes32 _context, address _addr) external view returns (bool);
// role groups
function hasRoleInGroup(bytes32 _context, address _addr, bytes32 _roleGroup) external view returns (bool);
function setRoleGroup(bytes32 _roleGroup, bytes32[] calldata _roles) external;
function isRoleGroup(bytes32 _roleGroup) external view returns (bool);
function getRoleGroup(bytes32 _roleGroup) external view returns (bytes32[] memory);
function getRoleGroupsForRole(bytes32 _role) external view returns (bytes32[] memory);
// roles
function hasRole(bytes32 _context, address _addr, bytes32 _role) external view returns (bool);
function hasAnyRole(bytes32 _context, address _addr, bytes32[] calldata _roles) external view returns (bool);
function assignRole(bytes32 _context, address _addr, bytes32 _role) external;
function unassignRole(bytes32 _context, address _addr, bytes32 _role) external;
function getRolesForUser(bytes32 _context, address _addr) external view returns (bytes32[] memory);
// who can assign roles
function addAssigner(bytes32 _roleToAssign, bytes32 _assignerRoleGroup) external;
function removeAssigner(bytes32 _roleToAssign, bytes32 _assignerRoleGroup) external;
function getAssigners(bytes32 _role) external view returns (bytes32[] memory);
function canAssign(bytes32 _context, address _addr, bytes32 _role) external view returns (bool);
// utility methods
function generateContextFromAddress (address _addr) external pure returns (bytes32);
}
Note: The implementation of this interface is available here.
As you can see there are various additional getter functions added to ease lookups.
When the ACL is deployed the following roles and role groups are setup:
// setup role groups
acl.setRoleGroup(ROLEGROUPS.ASSET_MANAGERS, [ ROLES.ASSET_MANAGER ])
acl.setRoleGroup(ROLEGROUPS.BROKERS, [ROLES.BROKER])
acl.setRoleGroup(ROLEGROUPS.CLIENT_MANAGERS, [ ROLES.CLIENT_MANAGER ])
acl.setRoleGroup(ROLEGROUPS.ENTITY_ADMINS, [ ROLES.ENTITY_ADMIN, ROLES.SOLE_PROP, ROLES.NAYM ])
acl.setRoleGroup(ROLEGROUPS.ENTITY_MANAGERS, [ ROLES.ENTITY_MANAGER ])
acl.setRoleGroup(ROLEGROUPS.FUND_MANAGERS, [ ROLES.SOLE_PROP, ROLES.ENTITY_ADMIN, ROLES.NAYM ])
acl.setRoleGroup(ROLEGROUPS.POLICY_APPROVERS, [ ROLES.ASSET_MANAGER, ROLES.BROKER, ROLES.CLIENT_MANAGER, ROLES.SOLE_PROP ])
acl.setRoleGroup(ROLEGROUPS.POLICY_CREATORS, [ ROLES.ENTITY_MANAGER ])
acl.setRoleGroup(ROLEGROUPS.POLICY_OWNERS, [ROLES.POLICY_OWNER])
acl.setRoleGroup(ROLEGROUPS.SYSTEM_ADMINS, [ROLES.SYSTEM_ADMIN])
acl.setRoleGroup(ROLEGROUPS.SYSTEM_MANAGERS, [ROLES.SYSTEM_MANAGER])
acl.setRoleGroup(ROLEGROUPS.TRADERS, [ROLES.NAYM, ROLES.ENTITY_REP, ROLES.SOLE_PROP])
// setup which role groups can assign which roles
acl.addAssigner(ROLES.ASSET_MANAGER, ROLEGROUPS.POLICY_OWNERS)
acl.addAssigner(ROLES.BROKER, ROLEGROUPS.POLICY_OWNERS)
acl.addAssigner(ROLES.CLIENT_MANAGER, ROLEGROUPS.POLICY_OWNERS)
acl.addAssigner(ROLES.ENTITY_ADMIN, ROLEGROUPS.SYSTEM_MANAGERS)
acl.addAssigner(ROLES.ENTITY_MANAGER, ROLEGROUPS.ENTITY_ADMINS)
acl.addAssigner(ROLES.ENTITY_REP, ROLEGROUPS.ENTITY_MANAGERS)
acl.addAssigner(ROLES.NAYM, ROLEGROUPS.SYSTEM_MANAGERS)
acl.addAssigner(ROLES.SOLE_PROP, ROLEGROUPS.SYSTEM_MANAGERS)
acl.addAssigner(ROLES.SYSTEM_MANAGER, ROLEGROUPS.SYSTEM_ADMINS)
The msg.sender who deploys the ACL is assigned the SYSTEM_ADMIN (System administrator) role in the System context - this context is just the hash of the address of the ACL contract. Any role assigned within this context automatically has global access - for example, if a user is assigned the ENTITY_ADMIN role within the System context then they effectively have that role for all contexts. This is obviously a very powerful feature and thus, in the ACL implementation we have added a restriction such that only only System administrators can assign roles within the System context.
In the actual ACL implementation you'll note the following:
Role groups can only be modified by those with the System admin role in the System context.
Roles in the System context can only be assigned/unassigned by a System admin.
Roles in other contexts can be assigned either by the context owner (e.g. a contract whose address is hashed to obtain the context string) or by a rolegroup that is able to assign
the given role.
By default, all contracts which talk to the ACL inherit from the AccessControl base contract:
contract AccessControl is EternalStorage {
// BEGIN: Generated by script outputRoleConstants.js
// DO NOT MANUALLY MODIFY THESE VALUES!
bytes32 constant public ROLE_ASSET_MANAGER = 0xb22c97cdfeff9f8e27e626a2e4d355245c2c3cfe84c20e5c9cacbf0f1c6f3b2a;
...
// END: Generated by script outputRoleConstants.js
constructor (address _acl) public {
dataAddress["acl"] = _acl;
dataBytes32["aclContext"] = acl().generateContextFromAddress(address(this));
}
modifier assertIsAdmin () {
require(isAdmin(msg.sender), 'must be admin');
_;
}
function isAdmin (address _addr) public view returns (bool) {
return acl().isAdmin(_addr);
}
function inRoleGroup (address _addr, bytes32 _roleGroup) public view returns (bool) {
return inRoleGroupWithContext(aclContext(), _addr, _roleGroup);
}
function hasRole (address _addr, bytes32 _role) public view returns (bool) {
return hasRoleWithContext(aclContext(), _addr, _role);
}
function inRoleGroupWithContext (bytes32 _ctx, address _addr, bytes32 _roleGroup) public view returns (bool) {
return acl().hasRoleInGroup(_ctx, _addr, _roleGroup);
}
function hasRoleWithContext (bytes32 _ctx, address _addr, bytes32 _role) public view returns (bool) {
return acl().hasRole(_ctx, _addr, _role);
}
function acl () internal view returns (IACL) {
return IACL(dataAddress["acl"]);
}
function aclContext () public view returns (bytes32) {
return dataBytes32["aclContext"];
}
}
The AccessControl base contract provides various lookup functions and modifiers, and also sets up the active context for the inheriting contract. It also defines the role and role group constants.
The EntityImpl contract is a good example of how the AccessControl base contract gets used:
contract EntityImpl is ... {
modifier assertCanCreatePolicy () {
require(inRoleGroup(msg.sender, ROLEGROUP_POLICY_CREATORS), 'must be policy creator');
_;
}
function createPolicy(...) public assertCanCreatePolicy {...}
}
The policy contract sets its Policy owner in its constructor, and is an example of a contract assigning a role within its own context:
contract Policy is ... {
constructor (..., address _policyOwner) ... public {
// set policy owner
acl().assignRole(aclContext(), _policyOwner, ROLE_POLICY_OWNER);
}
}
Though most contracts in our architecture are upgradeable we designed the ACL to be non-upgradeable by default. We want its code to be immutable - after all, access control underpins the security of our system in terms of who is able to do what within the contracts.
Also note that the architecture shown above is not specific to our product. You can deploy and use our ACL for your own app using our NPM package.