diff --git a/contracts/accessControl/package.json b/contracts/accessControl/package.json new file mode 100644 index 00000000..2423b1b5 --- /dev/null +++ b/contracts/accessControl/package.json @@ -0,0 +1,32 @@ +{ + "name": "@openzeppelin-compact/access-control", + "private": true, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "compact": "compact-compiler", + "build": "compact-builder && tsc", + "test": "vitest run", + "types": "tsc -p tsconfig.json --noEmit", + "clean": "git clean -fXd" + }, + "dependencies": { + "@openzeppelin-compact/compact": "workspace:^" + }, + "devDependencies": { + "@types/node": "22.14.0", + "ts-node": "^10.9.2", + "typescript": "^5.2.2", + "vitest": "^3.1.3" + } +} diff --git a/contracts/accessControl/src/AccessControl.compact b/contracts/accessControl/src/AccessControl.compact new file mode 100644 index 00000000..248c5f2b --- /dev/null +++ b/contracts/accessControl/src/AccessControl.compact @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MIT + +pragma language_version >= 0.15.0; + +/** + * @module AccessControl + * @description An unshielded AccessControl library. + * This module provides a role-based access control mechanism, where roles can be used to + * represent a set of permissions. + * + * Roles are referred to by their `Bytes<32>` identifier. These should be exposed + * in the top-level contract and be unique. One way to achieve this is by + * using `export sealed ledger` hash digests that are initialized in the top-level contract: + * + * ```typescript + * import CompactStandardLibrary; + * import "./node_modules/@openzeppelin-compact/accessControl/src/AccessControl" prefix AccessControl_; + * + * export sealed ledger MY_ROLE: Bytes<32>; + * + * constructor() { + * MY_ROLE = persistent_hash>(pad(32, "MY_ROLE")); + * } + * ``` + * + * To restrict access to a circuit, use {assertOnlyRole}: + * + * ```typescript + * circuit foo(): [] { + * assertOnlyRole(MY_ROLE); + * ... + * } + * ``` + * + * Roles can be granted and revoked dynamically via the {grantRole} and + * {revokeRole} circuits. Each role has an associated admin role, and only + * accounts that have a role's admin role can call {grantRole} and {revokeRole}. + * + * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means + * that only accounts with this role will be able to grant or revoke other + * roles. More complex role relationships can be created by using + * {_setRoleAdmin}. To set a custom `DEFAULT_ADMIN_ROLE`, implement the `Initializable` + * module and set `DEFAULT_ADMIN_ROLE` in the `initialize()` circuit. + * + * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to + * grant and revoke this role. Extra precautions should be taken to secure + * accounts that have been granted it. + * + * @notice Roles can only be granted to ZswapCoinPublicKeys + * through the main role approval circuits (`grantRole` and `_grantRole`). + * In other words, role approvals to contract addresses are disallowed through these + * circuits. + * This is because Compact currently does not support contract-to-contract calls which means + * if a contract is granted a role, the contract cannot directly call the protected + * circuit. + * + * @notice This module does offer an experimental circuit that allows roles to be granted + * to contract addresses (`_unsafeGrantRole`). + * Note that the circuit name is very explicit ("unsafe") with this experimental circuit. + * Until contract-to-contract calls are supported, + * there is no direct way for a contract to call protected circuits. + * + * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls + * are supported. + * + * @notice Missing Features and Improvements: + * + * - Role events + * - An ERC165-like interface + */ +module AccessControl { + import CompactStandardLibrary; + import "../../node_modules/@openzeppelin-compact/utils/src/Utils" prefix Utils_; + + /** + * @description Mapping from a role identifier -> account -> its permissions. + * @type {Bytes<32>} roleId - A hash representing a role identifier. + * @type {Map, Boolean>} hasRole - A mapping from an account to a + * Boolean determining if the account is approved for a role. + * @type {Map} + * @type {Map, Map, Boolean>} _operatorRoles +  */ + export ledger _operatorRoles: Map, Map, Boolean>>; + + /** + * @description Mapping from a role identifier to an admin role identifier. + * @type {Bytes<32>} roleId - A hash representing a role identifier. + * @type {Bytes<32>} adminId - A hash representing an admin identifier. + * @type {Map} + * @type {Map, Bytes<32>>} _adminRoles +  */ + export ledger _adminRoles: Map, Bytes<32>>; + + export ledger DEFAULT_ADMIN_ROLE: Bytes<32>; + + /** + * @description Returns `true` if `account` has been granted `roleId`. + * + * @circuitInfo + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - The account to query. + * @return {Boolean} - Whether the account has the specified role. +   */ + export circuit hasRole(roleId: Bytes<32>, account: Either): Boolean { + if ( + _operatorRoles.member(roleId) && + _operatorRoles + .lookup(roleId) + .member(account) + ) { + return _operatorRoles + .lookup(roleId) + .lookup(account); + } else { + return false; + } + } + + /** + * @description Reverts if `own_public_key()` is missing `roleId`. + * + * @circuitInfo + * + * Requirements: + * + * - The caller must have `roleId`. + * - The caller must not be a ContractAddress + * + * @param {Bytes<32>} roleId - The role identifier. + * @return {[]} - Empty tuple. + */ + export circuit assertOnlyRole(roleId: Bytes<32>): [] { + _checkRole(roleId, left(own_public_key())); + } + + /** + * @description Reverts if `account` is missing `roleId`. + * + * @circuitInfo + * + * Requirements: + * + * - `account` must have `roleId`. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - The account to query. + * @return {[]} - Empty tuple. + */ + export circuit _checkRole(roleId: Bytes<32>, account: Either): [] { + assert hasRole(roleId, account) "AccessControl: unauthorized account"; + } + + /** + * @description Returns the admin role that controls `roleId` or + * a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}. + * + * To change a role’s admin use {_setRoleAdmin}. + * + * @circuitInfo + * + * @param {Bytes<32>} roleId - The role identifier. + * @return {Bytes<32>} roleAdmin - The admin role that controls `roleId`. + */ + export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { + if (_adminRoles.member(roleId)) { + return _adminRoles.lookup(roleId); + } + return default>; + } + + /** + * @description Grants `roleId` to `account`. + * + * @circuitInfo + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * - The caller must have `roleId`'s admin role. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit grantRole(roleId: Bytes<32>, account: Either): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _grantRole(roleId, account); + } + + /** + * @description Revokes `roleId` from `account`. + * + * @circuitInfo + * + * Requirements: + * + * - The caller must have `roleId`'s admin role. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit revokeRole(roleId: Bytes<32>, account: Either): [] { + assertOnlyRole(getRoleAdmin(roleId)); + _revokeRole(roleId, account); + } + + /** + * @description Revokes `roleId` from the calling account. + * + * @notice Roles are often managed via {grantRole} and {revokeRole}: this circuit's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * @circuitInfo + * + * Requirements: + * + * - The caller must be `callerConfirmation`. + * - The caller must not be a `ContractAddress`. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} callerConfirmation - A ZswapCoinPublicKey or ContractAddress. + * @return {[]} - Empty tuple. + */ + export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either): [] { + assert callerConfirmation == left(own_public_key()) "AccessControl: bad confirmation"; + + _revokeRole(roleId, callerConfirmation); + } + + /** + * @description Sets `adminRole` as `roleId`'s admin role. + * + * @circuitInfo + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} adminRole - The admin role identifier. + * @return {[]} - Empty tuple. + */ + export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { + _adminRoles.insert(roleId, adminRole); + } + + /** + * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + * Internal circuit without access restriction. + * + * @circuitInfo + * + * Requirements: + * + * - `account` must not be a ContractAddress. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted. + */ + export circuit _grantRole(roleId: Bytes<32>, account: Either): Boolean { + assert !Utils_isContractAddress(account) "AccessControl: unsafe role approval"; + return _unsafeGrantRole(roleId, account); + } + + /** + * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + * Internal circuit without access restriction. It does NOT check if the role is granted to a ContractAddress. + * + * @circuitInfo + * + * @notice External smart contracts cannot call the token contract at this time, so granting a role to an ContractAddress may + * render a circuit permanently inaccessible. + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @return {Boolean} roleGranted - A boolean indicating if `role` was granted. + */ + export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either): Boolean { + if (hasRole(roleId, account)) { + return false; + } + + if (!_operatorRoles.member(roleId)) { + _operatorRoles.insert( + roleId, + default, + Boolean + >> + ); + _operatorRoles + .lookup(roleId) + .insert(account, true); + return true; + } + + _operatorRoles.lookup(roleId).insert(account, true); + return true; + } + + /** + * @description Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked. + * Internal circuit without access restriction. + * + * @circuitInfo + * + * @param {Bytes<32>} roleId - The role identifier. + * @param {Bytes<32>} adminRole - The admin role identifier. + * @return {Boolean} roleRevoked - A boolean indicating if `roleId` was revoked. + */ + export circuit _revokeRole(roleId: Bytes<32>, account: Either): Boolean { + if (!hasRole(roleId, account)) { + return false; + } + + _operatorRoles + .lookup(roleId) + .insert(account, false); + return true; + } +} diff --git a/contracts/accessControl/src/test/AccessControl.test.ts b/contracts/accessControl/src/test/AccessControl.test.ts new file mode 100644 index 00000000..72bbb453 --- /dev/null +++ b/contracts/accessControl/src/test/AccessControl.test.ts @@ -0,0 +1,429 @@ +import { + type CoinPublicKey, + convert_bigint_to_Uint8Array, +} from '@midnight-ntwrk/compact-runtime'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { AccessControlSimulator } from './simulators/AccessControlSimulator.js'; +import * as utils from './utils/address.js'; + +// Callers +const OPERATOR_1 = utils.toHexPadded('OPERATOR_1'); +const ADMIN = utils.toHexPadded('ADMIN'); +const CUSTOM_ADMIN = utils.toHexPadded('CUSTOM_ADMIN'); +const UNAUTHORIZED = utils.toHexPadded('UNAUTHORIZED'); +const OPERATOR_CONTRACT = utils.toHexPadded('OPERATOR_CONTRACT'); + +// Encoded PK/Addresses +const Z_OPERATOR_1 = utils.createEitherTestUser('OPERATOR_1'); +const Z_OPERATOR_2 = utils.createEitherTestUser('OPERATOR_2'); +const Z_ADMIN = utils.createEitherTestUser('ADMIN'); +const Z_CUSTOM_ADMIN = utils.createEitherTestUser('CUSTOM_ADMIN'); +const Z_UNAUTHORIZED = utils.createEitherTestUser('UNAUTHORIZED'); +const Z_OPERATOR_CONTRACT = + utils.createEitherTestContractAddress('OPERATOR_CONTRACT'); + +// Roles +const DEFAULT_ADMIN_ROLE = utils.zeroUint8Array(); +const OPERATOR_ROLE_1 = convert_bigint_to_Uint8Array(32, 1n); +const OPERATOR_ROLE_2 = convert_bigint_to_Uint8Array(32, 2n); +const OPERATOR_ROLE_3 = convert_bigint_to_Uint8Array(32, 3n); +const CUSTOM_ADMIN_ROLE = convert_bigint_to_Uint8Array(32, 4n); +const UNINITIALIZED_ROLE = convert_bigint_to_Uint8Array(32, 5n); + +let accessControl: AccessControlSimulator; +let caller: CoinPublicKey; + +const callerTypes = { + contract: OPERATOR_CONTRACT, + pubkey: OPERATOR_1, +}; + +const operatorTypes = [ + ['contract', Z_OPERATOR_CONTRACT], + ['pubkey', Z_OPERATOR_1], +] as const; + +const operatorRoles = [OPERATOR_ROLE_1, OPERATOR_ROLE_2]; +const operatorPKs = [Z_OPERATOR_1, Z_OPERATOR_2, Z_OPERATOR_CONTRACT]; + +describe('AccessControl', () => { + beforeEach(() => { + accessControl = new AccessControlSimulator(); + }); + + describe('hasRole', () => { + beforeEach(() => { + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + }); + + it('should return true when operator has a role', () => { + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('should return false when unauthorized', () => { + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED)).toBe( + false, + ); + }); + + it('should return false when role does not exist', () => { + expect(accessControl.hasRole(UNINITIALIZED_ROLE, Z_OPERATOR_1)).toBe( + false, + ); + }); + }); + + describe('assertOnlyRole', () => { + beforeEach(() => { + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + }); + + it('should allow operator with role to call', () => { + caller = OPERATOR_1; + expect(() => + accessControl.assertOnlyRole(OPERATOR_ROLE_1, caller), + ).not.toThrow(); + }); + + it('should throw if caller is unauthorized', () => { + caller = UNAUTHORIZED; + expect(() => + accessControl.assertOnlyRole(OPERATOR_ROLE_1, caller), + ).toThrow('AccessControl: unauthorized account'); + }); + + it('should throw if ContractAddress with role is caller', () => { + caller = OPERATOR_CONTRACT; + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + + expect(() => + accessControl.assertOnlyRole(OPERATOR_ROLE_1, caller), + ).toThrow('AccessControl: unauthorized account'); + }); + }); + + describe('_checkRole', () => { + beforeEach(() => { + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + }); + + describe.each(operatorTypes)( + 'when the operator is a %s', + (_operatorType, _operator) => { + it(`should not throw if ${_operatorType} has role`, () => { + expect(() => + accessControl._checkRole(OPERATOR_ROLE_1, _operator), + ).not.toThrow(); + }); + }, + ); + + it('should throw if operator is unauthorized', () => { + expect(() => + accessControl._checkRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED), + ).toThrow('AccessControl: unauthorized account'); + }); + }); + + describe('getRoleAdmin', () => { + it('should return default admin role if admin role not set', () => { + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + DEFAULT_ADMIN_ROLE, + ); + }); + + it('should return custom admin role if set', () => { + accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + }); + }); + + describe('grantRole', () => { + beforeEach(() => { + accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + caller = ADMIN; + }); + + it('admin should grant role', () => { + accessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('admin should grant multiple roles', () => { + for (let i = 0; i < operatorRoles.length; i++) { + // length - 1 because we test ContractAddress separately + for (let j = 0; j < operatorPKs.length - 1; j++) { + accessControl.grantRole(operatorRoles[i], operatorPKs[j], caller); + expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( + true, + ); + } + } + }); + + it('should throw if operator grants role', () => { + accessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller); + + caller = OPERATOR_1; + expect(() => { + accessControl.grantRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED, caller); + }).toThrow('AccessControl: unauthorized account'); + }); + + it('should throw if admin grants role to ContractAddress', () => { + expect(() => { + accessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT, caller); + }).toThrow('AccessControl: unsafe role approval'); + }); + }); + + describe('revokeRole', () => { + beforeEach(() => { + accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + }); + + describe.each(operatorTypes)( + 'when the operator is a %s', + (_operatorType, _operator) => { + it('admin should revoke role', () => { + caller = ADMIN; + + accessControl.revokeRole(OPERATOR_ROLE_1, _operator, caller); + expect(accessControl.hasRole(OPERATOR_ROLE_1, _operator)).toBe(false); + }); + + it('should throw if operator revokes role', () => { + caller = callerTypes[_operatorType]; + + expect(() => { + accessControl.revokeRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED, caller); + }).toThrow('AccessControl: unauthorized account'); + }); + }, + ); + + it('admin should revoke multiple roles', () => { + caller = ADMIN; + + for (let i = 0; i < operatorRoles.length; i++) { + for (let j = 0; j < operatorPKs.length; j++) { + accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]); + accessControl.revokeRole(operatorRoles[i], operatorPKs[j], caller); + expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( + false, + ); + } + } + }); + }); + + describe('renounceRole', () => { + beforeEach(() => { + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + caller = OPERATOR_1; + }); + + it('should allow operator to renounce own role', () => { + accessControl.renounceRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(false); + }); + + it('ContractAddress renounce should throw', () => { + caller = OPERATOR_CONTRACT; + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + + expect(() => { + accessControl.renounceRole( + OPERATOR_ROLE_1, + Z_OPERATOR_CONTRACT, + caller, + ); + }).toThrow('AccessControl: bad confirmation'); + }); + + it('unauthorized renounce should throw', () => { + caller = UNAUTHORIZED; + expect(() => { + accessControl.renounceRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller); + }).toThrow('AccessControl: bad confirmation'); + }); + }); + + describe('_setRoleAdmin', () => { + beforeEach(() => { + accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + }); + + it('should set role admin', () => { + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + }); + + it('should set multiple role admins', () => { + accessControl._setRoleAdmin(OPERATOR_ROLE_2, CUSTOM_ADMIN_ROLE); + accessControl._setRoleAdmin(OPERATOR_ROLE_3, CUSTOM_ADMIN_ROLE); + + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_1)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_2)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + expect(accessControl.getRoleAdmin(OPERATOR_ROLE_3)).toEqual( + CUSTOM_ADMIN_ROLE, + ); + }); + + it('should authorize new admin to grant / revoke roles', () => { + caller = CUSTOM_ADMIN; + + accessControl._grantRole(CUSTOM_ADMIN_ROLE, Z_CUSTOM_ADMIN); + accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + + expect(() => + accessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller), + ).not.toThrow(); + expect(() => + accessControl.revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller), + ).not.toThrow(); + }); + + it('should disallow previous admin from granting / revoking roles', () => { + caller = ADMIN; + + accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + accessControl._grantRole(CUSTOM_ADMIN_ROLE, Z_CUSTOM_ADMIN); + accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + + expect(() => + accessControl.grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller), + ).toThrow('AccessControl: unauthorized account'); + expect(() => + accessControl.revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1, caller), + ).toThrow('AccessControl: unauthorized account'); + }); + }); + + describe('_grantRole', () => { + it('should grant role', () => { + expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( + true, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('should return false if hasRole already', () => { + expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( + true, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + + expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( + false, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('should fail to grant role to a ContractAddress', () => { + expect(() => { + accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + }).toThrow('AccessControl: unsafe role approval'); + }); + + it('should grant multiple roles', () => { + for (let i = 0; i < operatorRoles.length; i++) { + // length - 1 because we test ContractAddress in the above test + for (let j = 0; j < operatorPKs.length - 1; j++) { + accessControl._grantRole(operatorRoles[i], operatorPKs[j]); + expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( + true, + ); + } + } + }); + }); + + describe('_unsafeGrantRole', () => { + it('should grant role', () => { + expect( + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), + ).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('should return false if hasRole already', () => { + expect( + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), + ).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + + expect( + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), + ).toBe(false); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + }); + + it('should grant role to a ContractAddress', () => { + expect( + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT), + ).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT)).toBe( + true, + ); + }); + + it('should grant multiple roles', () => { + for (let i = 0; i < operatorRoles.length; i++) { + for (let j = 0; j < operatorPKs.length; j++) { + expect( + accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]), + ).toBe(true); + expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( + true, + ); + } + } + }); + }); + + describe('_revokeRole', () => { + describe.each(operatorTypes)( + 'when the operator is a %s', + (_, _operator) => { + it('should revoke role', () => { + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, _operator); + expect(accessControl._revokeRole(OPERATOR_ROLE_1, _operator)).toBe( + true, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, _operator)).toBe(false); + }); + }, + ); + + it('should return false if account does not have role', () => { + expect(accessControl._revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( + false, + ); + }); + + it('should revoke multiple roles', () => { + for (let i = 0; i < operatorRoles.length; i++) { + for (let j = 0; j < operatorPKs.length; j++) { + accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]); + expect( + accessControl._revokeRole(operatorRoles[i], operatorPKs[j]), + ).toBe(true); + expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( + false, + ); + } + } + }); + }); +}); diff --git a/contracts/accessControl/src/test/mocks/MockAccessControl.compact b/contracts/accessControl/src/test/mocks/MockAccessControl.compact new file mode 100644 index 00000000..739643ea --- /dev/null +++ b/contracts/accessControl/src/test/mocks/MockAccessControl.compact @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +pragma language_version >= 0.15.0; + +import CompactStandardLibrary; + +import "../../AccessControl" prefix AccessControl_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe, AccessControl_DEFAULT_ADMIN_ROLE }; + +export circuit hasRole(roleId: Bytes<32>, account: Either): Boolean { + return AccessControl_hasRole(roleId, account); +} + +export circuit assertOnlyRole(roleId: Bytes<32>): [] { + AccessControl_assertOnlyRole(roleId); +} + +export circuit _checkRole(roleId: Bytes<32>, account: Either): [] { + AccessControl__checkRole(roleId, account); +} + +export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { + return AccessControl_getRoleAdmin(roleId); +} + +export circuit grantRole(roleId: Bytes<32>, account: Either): [] { + AccessControl_grantRole(roleId, account); +} + +export circuit revokeRole(roleId: Bytes<32>, account: Either): [] { + AccessControl_revokeRole(roleId, account); +} + +export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either): [] { + AccessControl_renounceRole(roleId, callerConfirmation); +} + +export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { + AccessControl__setRoleAdmin(roleId, adminRole); +} + +export circuit _grantRole(roleId: Bytes<32>, account: Either): Boolean { + return AccessControl__grantRole(roleId, account); +} + +export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either): Boolean { + return AccessControl__unsafeGrantRole(roleId, account); +} + +export circuit _revokeRole(roleId: Bytes<32>, account: Either): Boolean { + return AccessControl__revokeRole(roleId, account); +} diff --git a/contracts/accessControl/src/test/simulators/AccessControlSimulator.ts b/contracts/accessControl/src/test/simulators/AccessControlSimulator.ts new file mode 100644 index 00000000..b1eac3b2 --- /dev/null +++ b/contracts/accessControl/src/test/simulators/AccessControlSimulator.ts @@ -0,0 +1,298 @@ +import { + type CircuitContext, + type CoinPublicKey, + type ContractState, + QueryContext, + constructorContext, + emptyZswapLocalState, +} from '@midnight-ntwrk/compact-runtime'; +import { sampleContractAddress } from '@midnight-ntwrk/zswap'; +import { + type ContractAddress, + type Either, + type Ledger, + Contract as MockAccessControl, + type ZswapCoinPublicKey, + ledger, +} from '../../artifacts/MockAccessControl/contract/index.cjs'; // Combined imports +import { + type AccessControlPrivateState, + AccessControlWitnesses, +} from '../../witnesses/AccessControlWitnesses.js'; +import type { IContractSimulator } from '../types/test.js'; + +/** + * @description A simulator implementation of a AccessControl contract for testing purposes. + * @template P - The private state type, fixed to AccessControlPrivateState. + * @template L - The ledger type, fixed to Contract.Ledger. + */ +export class AccessControlSimulator + implements IContractSimulator +{ + /** @description The underlying contract instance managing contract logic. */ + readonly contract: MockAccessControl; + + /** @description The deployed address of the contract. */ + readonly contractAddress: string; + + /** @description The current circuit context, updated by contract operations. */ + circuitContext: CircuitContext; + + /** + * @description Initializes the mock contract. + */ + constructor() { + this.contract = new MockAccessControl( + AccessControlWitnesses, + ); + const { + currentPrivateState, + currentContractState, + currentZswapLocalState, + } = this.contract.initialState(constructorContext({}, '0'.repeat(64))); + this.circuitContext = { + currentPrivateState, + currentZswapLocalState, + originalState: currentContractState, + transactionContext: new QueryContext( + currentContractState.data, + sampleContractAddress(), + ), + }; + this.contractAddress = this.circuitContext.transactionContext.address; + } + + /** + * @description Retrieves the current public ledger state of the contract. + * @returns The ledger state as defined by the contract. + */ + public getCurrentPublicState(): Ledger { + return ledger(this.circuitContext.transactionContext.state); + } + + /** + * @description Retrieves the current private state of the contract. + * @returns The private state of type AccessControlPrivateState. + */ + public getCurrentPrivateState(): AccessControlPrivateState { + return this.circuitContext.currentPrivateState; + } + + /** + * @description Retrieves the current contract state. + * @returns The contract state object. + */ + public getCurrentContractState(): ContractState { + return this.circuitContext.originalState; + } + + /** + * @description Retrieves an account's permission for `roleId`. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + * @returns Whether an account has a specified role. + */ + public hasRole( + roleId: Uint8Array, + account: Either, + ): boolean { + return this.contract.impureCircuits.hasRole( + this.circuitContext, + roleId, + account, + ).result; + } + + /** + * @description Retrieves an account's permission for `roleId`. + * @param caller - Optional. Sets the caller context if provided. + * @param roleId - The role identifier. + */ + public assertOnlyRole(roleId: Uint8Array, caller?: CoinPublicKey) { + const res = this.contract.impureCircuits.assertOnlyRole( + { + ...this.circuitContext, + currentZswapLocalState: caller + ? emptyZswapLocalState(caller) + : this.circuitContext.currentZswapLocalState, + }, + roleId, + ); + + this.circuitContext = res.context; + } + + /** + * @description Retrieves an account's permission for `roleId`. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public _checkRole( + roleId: Uint8Array, + account: Either, + ) { + this.circuitContext = this.contract.impureCircuits._checkRole( + this.circuitContext, + roleId, + account, + ).context; + } + + /** + * @description Retrieves `roleId`'s admin identifier. + * @param roleId - The role identifier. + * @returns The admin identifier for `roleId`. + */ + public getRoleAdmin(roleId: Uint8Array): Uint8Array { + return this.contract.impureCircuits.getRoleAdmin( + this.circuitContext, + roleId, + ).result; + } + + /** + * @description Grants an account permissions to use `roleId`. + * @param caller - Optional. Sets the caller context if provided. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public grantRole( + roleId: Uint8Array, + account: Either, + caller?: CoinPublicKey, + ) { + const res = this.contract.impureCircuits.grantRole( + { + ...this.circuitContext, + currentZswapLocalState: caller + ? emptyZswapLocalState(caller) + : this.circuitContext.currentZswapLocalState, + }, + roleId, + account, + ); + + this.circuitContext = res.context; + } + + /** + * @description Revokes an account's permission to use `roleId`. + * @param caller - Optional. Sets the caller context if provided. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public revokeRole( + roleId: Uint8Array, + account: Either, + caller?: CoinPublicKey, + ) { + const res = this.contract.impureCircuits.revokeRole( + { + ...this.circuitContext, + currentZswapLocalState: caller + ? emptyZswapLocalState(caller) + : this.circuitContext.currentZswapLocalState, + }, + roleId, + account, + ); + + this.circuitContext = res.context; + } + + /** + * @description Revokes `roleId` from the calling account. + * @param caller - Optional. Sets the caller context if provided. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public renounceRole( + roleId: Uint8Array, + account: Either, + caller?: CoinPublicKey, + ) { + const res = this.contract.impureCircuits.renounceRole( + { + ...this.circuitContext, + currentZswapLocalState: caller + ? emptyZswapLocalState(caller) + : this.circuitContext.currentZswapLocalState, + }, + roleId, + account, + ); + + this.circuitContext = res.context; + } + + /** + * @description Sets the admin identifier for `roleId`. + * @param roleId - The role identifier. + * @param adminId - The admin role identifier. + */ + public _setRoleAdmin(roleId: Uint8Array, adminId: Uint8Array) { + this.circuitContext = this.contract.impureCircuits._setRoleAdmin( + this.circuitContext, + roleId, + adminId, + ).context; + } + + /** + * @description Grants an account permissions to use `roleId`. Internal function without access restriction. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public _grantRole( + roleId: Uint8Array, + account: Either, + ): boolean { + const res = this.contract.impureCircuits._grantRole( + this.circuitContext, + roleId, + account, + ); + + this.circuitContext = res.context; + return res.result; + } + + /** + * @description Grants an account permissions to use `roleId`. Internal function without access restriction. + * DOES NOT restrict sending to a ContractAddress. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public _unsafeGrantRole( + roleId: Uint8Array, + account: Either, + ): boolean { + const res = this.contract.impureCircuits._unsafeGrantRole( + this.circuitContext, + roleId, + account, + ); + + this.circuitContext = res.context; + return res.result; + } + + /** + * @description Revokes an account's permission to use `roleId`. Internal function without access restriction. + * @param roleId - The role identifier. + * @param account - A ZswapCoinPublicKey or a ContractAddress. + */ + public _revokeRole( + roleId: Uint8Array, + account: Either, + ): boolean { + const res = this.contract.impureCircuits._revokeRole( + this.circuitContext, + roleId, + account, + ); + + this.circuitContext = res.context; + return res.result; + } +} diff --git a/contracts/accessControl/src/test/types/test.ts b/contracts/accessControl/src/test/types/test.ts new file mode 100644 index 00000000..7a909543 --- /dev/null +++ b/contracts/accessControl/src/test/types/test.ts @@ -0,0 +1,26 @@ +import type { + CircuitContext, + ContractState, +} from '@midnight-ntwrk/compact-runtime'; + +/** + * Generic interface for mock contract implementations. + * @template P - The type of the contract's private state. + * @template L - The type of the contract's ledger (public state). + */ +export interface IContractSimulator { + /** The contract's deployed address. */ + readonly contractAddress: string; + + /** The current circuit context. */ + circuitContext: CircuitContext

; + + /** Retrieves the current ledger state. */ + getCurrentPublicState(): L; + + /** Retrieves the current private state. */ + getCurrentPrivateState(): P; + + /** Retrieves the current contract state. */ + getCurrentContractState(): ContractState; +} diff --git a/contracts/accessControl/src/test/utils/address.ts b/contracts/accessControl/src/test/utils/address.ts new file mode 100644 index 00000000..0e23e909 --- /dev/null +++ b/contracts/accessControl/src/test/utils/address.ts @@ -0,0 +1,77 @@ +import { + convert_bigint_to_Uint8Array, + encodeCoinPublicKey, +} from '@midnight-ntwrk/compact-runtime'; +import { encodeContractAddress } from '@midnight-ntwrk/ledger'; +import type * as Compact from '../../artifacts/MockAccessControl/contract/index.cjs'; + +const PREFIX_ADDRESS = '0200'; + +/** + * @description Converts an ASCII string to its hexadecimal representation, + * left-padded with zeros to a specified length. Useful for generating + * fixed-size hex strings for encoding. + * @param str ASCII string to convert. + * @param len Total desired length of the resulting hex string. Defaults to 64. + * @returns Hexadecimal string representation of `str`, padded to `length` characters. + */ +export const toHexPadded = (str: string, len = 64) => + Buffer.from(str, 'ascii').toString('hex').padStart(len, '0'); + +/** + * @description Generates ZswapCoinPublicKey from `str` for testing purposes. + * @param str String to hexify and encode. + * @returns Encoded `ZswapCoinPublicKey`. + */ +export const encodeToPK = (str: string): Compact.ZswapCoinPublicKey => ({ + bytes: encodeCoinPublicKey(toHexPadded(str)), +}); + +/** + * @description Generates ContractAddress from `str` for testing purposes. + * Prepends 32-byte hex with PREFIX_ADDRESS before encoding. + * @param str String to hexify and encode. + * @returns Encoded `ZswapCoinPublicKey`. + */ +export const encodeToAddress = (str: string): Compact.ContractAddress => ({ + bytes: encodeContractAddress(PREFIX_ADDRESS + toHexPadded(str)), +}); + +/** + * @description Generates an Either object for ZswapCoinPublicKey for testing. + * For use when an Either argument is expected. + * @param str String to hexify and encode. + * @returns Defined Either object for ZswapCoinPublicKey. + */ +export const createEitherTestUser = (str: string) => ({ + is_left: true, + left: encodeToPK(str), + right: encodeToAddress(''), +}); + +/** + * @description Generates an Either object for ContractAddress for testing. + * For use when an Either argument is expected. + * @param str String to hexify and encode. + * @returns Defined Either object for ContractAddress. + */ +export const createEitherTestContractAddress = (str: string) => ({ + is_left: false, + left: encodeToPK(''), + right: encodeToAddress(str), +}); + +export const zeroUint8Array = (length = 32) => + convert_bigint_to_Uint8Array(length, 0n); + +export const ZERO_KEY = { + is_left: true, + left: { bytes: zeroUint8Array() }, + right: encodeToAddress(''), +}; + +export const ZERO_ADDRESS = { + is_left: false, + left: encodeToPK(''), + right: { bytes: zeroUint8Array() }, +}; diff --git a/contracts/accessControl/src/witnesses/AccessControlWitnesses.ts b/contracts/accessControl/src/witnesses/AccessControlWitnesses.ts new file mode 100644 index 00000000..df179630 --- /dev/null +++ b/contracts/accessControl/src/witnesses/AccessControlWitnesses.ts @@ -0,0 +1,3 @@ +// This is how we type an empty object. +export type AccessControlPrivateState = Record; +export const AccessControlWitnesses = {}; diff --git a/contracts/accessControl/tsconfig.build.json b/contracts/accessControl/tsconfig.build.json new file mode 100644 index 00000000..f1132509 --- /dev/null +++ b/contracts/accessControl/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/test/**/*.ts"], + "compilerOptions": {} +} diff --git a/contracts/accessControl/tsconfig.json b/contracts/accessControl/tsconfig.json new file mode 100644 index 00000000..4ae082c4 --- /dev/null +++ b/contracts/accessControl/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "lib": [ + "ES2022" + ], + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strict": true, + "isolatedModules": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/contracts/accessControl/vitest.config.ts b/contracts/accessControl/vitest.config.ts new file mode 100644 index 00000000..785b792e --- /dev/null +++ b/contracts/accessControl/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/test/**/*.test.ts'], + reporters: 'verbose', + }, +}); diff --git a/docs/antora.yml b/docs/antora.yml index 59aafeb7..b7d19e89 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -5,4 +5,4 @@ nav: - modules/ROOT/nav.adoc asciidoc: attributes: - page-sidebar-collapse-default: 'FungibleToken,NonFungibleToken,MultiToken,Ownable' + page-sidebar-collapse-default: 'FungibleToken,NonFungibleToken,MultiToken,Access Control, Ownable' diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 8256f111..b0241e11 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -16,4 +16,7 @@ *** xref:api/multitoken.adoc[API Reference] ** xref:ownable.adoc[Ownable] -*** xref:api/ownable.adoc[API Reference] \ No newline at end of file +*** xref:api/ownable.adoc[API Reference] + +** xref:accessControl.adoc[Access Control] +*** xref:api/accessControl.adoc[API Reference] \ No newline at end of file diff --git a/docs/modules/ROOT/pages/accessControl.adoc b/docs/modules/ROOT/pages/accessControl.adoc new file mode 100644 index 00000000..fcc7fb12 --- /dev/null +++ b/docs/modules/ROOT/pages/accessControl.adoc @@ -0,0 +1,179 @@ +:accessControl-guide: xref:accessControl.adoc[AccessControl guide] +:role-based-access: https://en.wikipedia.org/wiki/Role-based_access_control[Role-Based Access Control (RBAC)] + + += Access Control + +An unshielded Access Control library. +This module provides a role-based access control mechanism, where roles can be used to represent a set of permissions providing the flexibility to create different levels of account authorization. + +Roles can be enforced using the `assertOnlyRole` circuit. Separately, you will be able to define rules for how accounts can be granted a role, have it revoked, and more. + +This module does not require initialization; however, one must implement the `Initializable` module in `AccessControl` if a custom `DEFAULT_ADMIN_ROLE` is required. + +== Role-Based Access Control + +While the simplicity of _ownership_ can be useful for simple systems or quick prototyping, different levels of authorization are often needed. +You may want for an account to have permission to ban users from a system, but not create new tokens. +{role-based-access} offers flexibility in this regard. + +In essence, we will be defining multiple _roles_, each allowed to perform different sets of actions. +An account may have, for example, 'moderator', 'minter' or 'admin' roles, which you will then check for instead of simply using `assertOnlyOwner`. +This check can be enforced through the `assertOnlyRole` circuit. +Separately, you will be able to define rules for how accounts can be granted a role, have it revoked, and more. + +Most software uses access control systems that are role-based: some users are regular users, some may be supervisors or managers, and a few will often have administrative privileges. + +=== Using `AccessControl` + +The Compact contracts library provides `AccessControl` for implementing role-based access control. +Its usage is straightforward: for each role that you want to define, +you will create a new role identifier that is used to grant, revoke, and check if an account has that role. + +Here’s a simple example of using `AccessControl` with xref:fungibleToken.adoc[FungibleToken] to define a 'minter' role, which allows accounts that have this role to create new tokens: + +```ts +// AccessControlMinter.compact + +pragma language_version >= 0.15.0; + +import CompactStandardLibrary; +import "./node_modules/@openzeppelin-compact/access-control/src/AccessControl" prefix AccessControl_; +import "./node_modules/@openzeppelin-compact/fungible-token/src/FungibleToken" prefix FungibleToken_; + +export sealed ledger MINTER_ROLE: Bytes<32>; + +/** + * Initialize FungibleToken and MINTER_ROLE + */ +constructor( + name: Opaque<"string">, + symbol: Opaque<"string">, + decimals: Uint<8>, + minter: Either +) { + FungibleToken_initialize(name, symbol, decimals); + MINTER_ROLE = persistent_hash>(pad(32, "MINTER_ROLE")); + AccessControl__grantRole(MINTER_ROLE, minter); +} + +export circuit mint(recipient: Either, value: Uint<128>): [] { + AccessControl_assertOnlyRole(MINTER_ROLE); + FungibleToken__mint(recipient, value); +} +``` + +NOTE: Make sure you fully understand how xref:api/accessControl.adoc#accessControl[AccessControl] works before using it on your system, or copy-pasting the examples from this guide. + +While clear and explicit, this isn’t anything we wouldn’t have been able to achieve with xref:ownable.adoc[Ownable]. Indeed, where `AccessControl` shines is in scenarios where granular permissions are required, which can be implemented by defining _multiple_ roles. + +Let’s augment our FungibleToken example by also defining a 'burner' role, which lets accounts destroy tokens. + +```ts +// AccessControlMinter.compact + +pragma language_version >= 0.15.0; + +import CompactStandardLibrary; +import "./node_modules/@openzeppelin-compact/access-control/src/AccessControl" prefix AccessControl_; +import "./node_modules/@openzeppelin-compact/fungible-token/src/FungibleToken" prefix FungibleToken_; + +export sealed ledger MINTER_ROLE: Bytes<32>; +export sealed ledger BURNER_ROLE: Bytes<32>; + +/** + * Initialize FungibleToken and MINTER_ROLE + */ +constructor( + name: Opaque<"string">, + symbol: Opaque<"string">, + decimals: Uint<8>, + minter: Either, + burner: Either +) { + FungibleToken_initialize(name, symbol, decimals); + MINTER_ROLE = persistent_hash>(pad(32, "MINTER_ROLE")); + BURNER_ROLE = persistent_hash>(pad(32, "BURNER_ROLE")); + AccessControl__grantRole(MINTER_ROLE, minter); + AccessControl__grantRole(BURNER_ROLE, burner); +} + +export circuit mint(recipient: Either, value: Uint<128>): [] { + AccessControl_assertOnlyRole(MINTER_ROLE); + FungibleToken__mint(recipient, value); +} + +export circuit burn(recipient: Either, value: Uint<128>): [] { + AccessControl_assertOnlyRole(BURNER_ROLE); + FungibleToken__burn(recipient, value); +} +``` + +So clean! By splitting concerns this way, more granular levels of permission may be implemented than were possible with the simpler _ownership_ approach to access control. +Limiting what each component of a system is able to do is known as the https://en.wikipedia.org/wiki/Principle_of_least_privilege[principle of least privilege], and is a good security practice. +Note that each account may still have more than one role, if so desired. + +=== Granting and Revoking Roles + +The FungibleToken example above uses `_grantRole`, an internal circuit that is useful when programmatically assigning roles (such as during construction). But what if we later want to grant the 'minter' role to additional accounts? + +By default, *accounts with a role cannot grant it or revoke it from other accounts*: all having a role does is making the `hasRole` check pass. To grant and revoke roles dynamically, you will need help from the _role’s admin_. + +Every role has an associated admin role, which grants permission to call the `grantRole` and `revokeRole` circuits. A role can be granted or revoked by using these if the calling account has the corresponding admin role. Multiple roles may have the same admin role to make management easier. A role’s admin can even be the same role itself, which would cause accounts with that role to be able to also grant and revoke it. + +This mechanism can be used to create complex permissioning structures resembling organizational charts, but it also provides an easy way to manage simpler applications. `AccessControl` includes a special role, called `DEFAULT_ADMIN_ROLE`, which acts as the *default admin role for all roles*. An account with this role will be able to manage any other role, unless `_setRoleAdmin` is used to select a new admin role. + +Since it is the admin for all roles by default, and in fact it is also its own admin, this role carries significant risk. + +Let’s take a look at the FungibleToken example, this time taking advantage of the default admin role: + +```ts +// AccessControlMinter.compact + +pragma language_version >= 0.15.0; + +import CompactStandardLibrary; +import "./node_modules/@openzeppelin-compact/access-control/src/AccessControl" prefix AccessControl_; +import "./node_modules/@openzeppelin-compact/fungible-token/src/FungibleToken" prefix FungibleToken_; + +export sealed ledger MINTER_ROLE: Bytes<32>; +export sealed ledger BURNER_ROLE: Bytes<32>; + +/** + * Initialize FungibleToken and MINTER_ROLE + */ +constructor( + name: Opaque<"string">, + symbol: Opaque<"string">, + decimals: Uint<8>, +) { + FungibleToken_initialize(name, symbol, decimals); + MINTER_ROLE = persistent_hash>(pad(32, "MINTER_ROLE")); + BURNER_ROLE = persistent_hash>(pad(32, "BURNER_ROLE")); + // Grant the contract deployer the default admin role: it will be able + // to grant and revoke any roles + AccessControl__grantRole(AccessControl_DEFAULT_ADMIN_ROLE, left(own_public_key())); +} + +export circuit mint(recipient: Either, value: Uint<128>): [] { + AccessControl_assertOnlyRole(MINTER_ROLE); + FungibleToken__mint(recipient, value); +} + +export circuit burn(recipient: Either, value: Uint<128>): [] { + AccessControl_assertOnlyRole(BURNER_ROLE); + FungibleToken__burn(recipient, value); +} +``` + +Note that, unlike the previous examples, no accounts are granted the 'minter' or 'burner' roles. However, because those roles' admin role is the default admin role, and _that_ role was granted to `own_public_key()`, that same account can call `grantRole` to give minting or burning permission, and `revokeRole` to remove it. + +Dynamic role allocation is often a desirable property, for example in systems where trust in a participant may vary over time. It can also be used to support use cases such as KYC, where the list of role-bearers may not be known up-front, or may be prohibitively expensive to include in a single transaction. + +=== Experimental features + +This module offers an experimental circuit that allow access control permissions to be granted to contract addresses xref:api/accessControl.adoc#AccessControl-_unsafeGrantRole[_unsafeGrantRole]. +Note that the circuit name is very explicit ("unsafe") with this experimental circuit. +Until contract-to-contract calls are supported, there is no direct way for a contract to call permissioned circuits of other contracts or grant/revoke role permissions. + +NOTE: The unsafe circuits are planned to become deprecated once contract-to-contract calls become available. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/api/accessControl.adoc b/docs/modules/ROOT/pages/api/accessControl.adoc new file mode 100644 index 00000000..f2e21d3d --- /dev/null +++ b/docs/modules/ROOT/pages/api/accessControl.adoc @@ -0,0 +1,181 @@ +:github-icon: pass:[] +:accessControl-guide: xref:accessControl.adoc[AccessControl guide] +:grantRole: <> +:revokeRole: <> + += AccessControl + +This page provides the full AccessControl module API. + +Roles are referred to by their `Bytes<32>` identifier. These should be exposed in the top-level contract and be unique. The best way to achieve this is by using `export sealed ledger` hash digests that are initialized in the top-level contract: + +```typescript +import CompactStandardLibrary; +import "AccessControl" prefix AccessControl_; + +export sealed ledger MY_ROLE: Bytes<32>; + +constructor() { + MY_ROLE = persistentHash>(pad(32, "MY_ROLE")); +} +``` + +To restrict access to a circuit, use <>: +```typescript +circuit foo(): [] { + assertOnlyRole(MY_ROLE); + ... +} +``` + +Roles can be granted and revoked dynamically via the {grantRole} and {revokeRole} functions. Each role has an associated admin role, and only accounts that have a role's admin role can call {grantRole} and {revokeRole}. + +By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means that only accounts with this role will be able to grant or revoke other roles. More complex role relationships can be created by using <>. To set a custom `DEFAULT_ADMIN_ROLE`, implement the `Initializable` module and set `DEFAULT_ADMIN_ROLE` in the `initialize()` function. + +WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to grant and revoke this role. Extra precautions should be taken to secure accounts that have been granted it. + +TIP: For an overview of the module, read the {accessControl-guide}. + +== Core + +[.hljs-theme-dark] +```ts +import "./node_modules/@openzeppelin-compact/access-control/src/AccessControl" prefix AccessControl_; +``` + +[.contract] +[[AccessControl]] +=== `++AccessControl++` link:https://github.com/OpenZeppelin/compact-contracts/tree/main/contracts/accessControl/src/AccessControl.compact[{github-icon},role=heading-link] + +[.contract-index] +.Circuits +-- + +[.sub-index#AccessControlModule] +* xref:#AccessControl-hasRole[`++hasRole(roleId, account)++`] +* xref:#AccessControl-assertOnlyRole[`++assertOnlyRole(roleId)++`] +* xref:#AccessControl-_checkRole[`++_checkRole(roleId, account)++`] +* xref:#AccessControl-getRoleAdmin[`++getRoleAdmin(roleId)++`] +* xref:#AccessControl-grantRole[`++grantRole(roleId, account)++`] +* xref:#AccessControl-revokeRole[`++revokeRole(roleId, account)++`] +* xref:#AccessControl-renounceRole[`++renounceRole(roleId, callerConfirmation)++`] +* xref:#AccessControl-_setRoleAdmin[`++_setRoleAdmin(roleId, adminRole)++`] +* xref:#AccessControl-_grantRole[`++_grantRole(roleId, account)++`] +* xref:#AccessControl-_unsafeGrantRole[`++_unsafeGrantRole(roleId, account)++`] +* xref:#AccessControl-_revokeRole[`++_revokeRole(roleId, account)++`] +-- + +[.contract-item] +[[AccessControl-hasRole]] +==== `[.contract-item-name]#++hasRole++#++(roleId: Bytes<32>, account: Either) → Boolean++` [.item-kind]#circuit# + +Returns `true` if `account` has been granted `roleId`. + +[.contract-item] +[[AccessControl-assertOnlyRole]] +==== `[.contract-item-name]#++assertOnlyRole++#++(roleId: Bytes<32>) → []++` [.item-kind]#circuit# + +Reverts if caller is missing `roleId`. + +Requirements: + +- The caller must have `roleId`. +- The caller must not be a `ContractAddress`. + +[.contract-item] +[[AccessControl-_checkRole]] +==== `[.contract-item-name]#++_checkRole++#++(roleId: Bytes<32>, account: Either) → []++` [.item-kind]#circuit# + +Reverts if `account` is missing `roleId`. + +Requirements: + +- `account` must have `roleId`. + +[.contract-item] +[[AccessControl-getRoleAdmin]] +==== `[.contract-item-name]#++getRoleAdmin++#++(roleId: Bytes<32>) → Bytes<32>++` [.item-kind]#circuit# + +Returns the admin role that controls `roleId` or a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}. + +To change a role's admin use <>. + +[.contract-item] +[[AccessControl-grantRole]] +==== `[.contract-item-name]#++grantRole++#++(roleId: Bytes<32>, account: Either) → []++` [.item-kind]#circuit# + +Grants `roleId` to `account`. + +NOTE: Granting roles to contract addresses is currently disallowed until contract-to-contract interactions are supported in Compact. +This restriction prevents permanently disabling access to a circuit. + +Requirements: + +- `account` must not be a ContractAddress. +- The caller must have ``roleId``'s admin role. + +[.contract-item] +[[AccessControl-revokeRole]] +==== `[.contract-item-name]#++revokeRole++#++(roleId: Bytes<32>, account: Either) → []++` [.item-kind]#circuit# + +Revokes `roleId` from `account`. + +Requirements: + +- The caller must have ``roleId``'s admin role. + +[.contract-item] +[[AccessControl-renounceRole]] +==== `[.contract-item-name]#++renounceRole++#++(roleId: Bytes<32>, callerConfirmation: Either) → []++` [.item-kind]#circuit# + +Revokes `roleId` from the calling account. + +Roles are often managed via {grantRole} and {revokeRole}: this circuit's +purpose is to provide a mechanism for accounts to lose their privileges +if they are compromised (such as when a trusted device is misplaced). + +NOTE: We do not provide functionality for smart contracts to renounce roles because self-executing transactions are not supported on Midnight at this time. We may revisit this in future if this feature is made available in Compact. + +Requirements: + +- The caller must be `callerConfirmation`. +- The caller must not be a `ContractAddress`. + +[.contract-item] +[[AccessControl-_setRoleAdmin]] +==== `[.contract-item-name]#++_setRoleAdmin++#++(roleId: Bytes<32>, adminRole: Bytes<32>) → []++` [.item-kind]#circuit# + +Sets `adminRole` as ``roleId``'s admin role. + +[.contract-item] +[[AccessControl-_grantRole]] +==== `[.contract-item-name]#++_grantRole++#++(roleId: Bytes<32>, adminRole: Bytes<32>) → Boolean++` [.item-kind]#circuit# + +Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. + +Internal circuit without access restriction. + +NOTE: Granting roles to contract addresses is currently disallowed in this circuit until contract-to-contract interactions are supported in Compact. +This restriction prevents permanently disabling access to a circuit. + +Requirements: + +- `account` must not be a ContractAddress. + +[.contract-item] +[[AccessControl-_unsafeGrantRole]] +==== `[.contract-item-name]#++_unsafeGrantRole++#++(roleId: Bytes<32>, account: Either) → Boolean++` [.item-kind]#circuit# + +Unsafe variant of <>. + +WARNING: Granting roles to contract addresses is considered unsafe because contract-to-contract calls are not currently supported. +Granting a role to a smart contract may render a circuit permanently inaccessible. +Once contract-to-contract calls are supported, this circuit may be deprecated. + +[.contract-item] +[[AccessControl-_revokeRole]] +==== `[.contract-item-name]#++_revokeRole++#++(roleId: Bytes<32>, account: Either) → Boolean++` [.item-kind]#circuit# + +Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked. + +Internal circuit without access restriction. \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 68bfe065..bfe8e4de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -395,6 +395,18 @@ __metadata: languageName: node linkType: hard +"@openzeppelin-compact/access-control@workspace:contracts/accessControl": + version: 0.0.0-use.local + resolution: "@openzeppelin-compact/access-control@workspace:contracts/accessControl" + dependencies: + "@openzeppelin-compact/compact": "workspace:^" + "@types/node": "npm:22.14.0" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.2.2" + vitest: "npm:^3.1.3" + languageName: unknown + linkType: soft + "@openzeppelin-compact/archive@workspace:contracts/archive": version: 0.0.0-use.local resolution: "@openzeppelin-compact/archive@workspace:contracts/archive"