Skip to content

Unshielded Access Control #182

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ffb9fe8
commit initial design
emnul Jul 17, 2025
35397ac
Undo package.json changes
emnul Jul 17, 2025
9010249
fmt files
emnul Jul 17, 2025
3cddc66
fmt package.json
emnul Jul 17, 2025
5fd5213
fix yarn.lock
emnul Jul 17, 2025
b6d03b5
Apply documentation suggestions from code review
emnul Jul 18, 2025
770e321
Rename _checkRole -> assertOnlyRole
emnul Jul 18, 2025
a847331
Update module documentation
emnul Jul 18, 2025
d78686a
Remove Initializable module
emnul Jul 18, 2025
359ece1
Remove Initializable requirements from method docs
emnul Jul 18, 2025
685d062
error message consistency
emnul Jul 19, 2025
b2079a3
Update method docs
emnul Jul 19, 2025
fbd6004
Fix simulator bug
emnul Jul 19, 2025
dcab470
Update method docs
emnul Jul 19, 2025
c792cc3
Add tests
emnul Jul 19, 2025
20316be
Remove unused files
emnul Jul 20, 2025
82e3f12
Fmt files
emnul Jul 20, 2025
c48e7c0
Update method docs
emnul Jul 20, 2025
b1c1e58
Update asciidoc attributes
emnul Jul 20, 2025
6ea39a7
Update nav and add API reference
emnul Jul 20, 2025
55fa0b3
Update module docs
emnul Jul 21, 2025
38abf97
Add AccessControl Overview, update AccessControl API
emnul Jul 21, 2025
9feb5fb
Update imports
emnul Jul 21, 2025
2a47555
Apply fmt suggestions to AccessControl.compact
emnul Jul 22, 2025
5452fa4
Add @return to method docs
emnul Jul 22, 2025
b9dc2a5
Reword method docs
emnul Jul 22, 2025
32ac7df
Update _checkRole tests
emnul Jul 22, 2025
348d6f8
refactor callerTypes
emnul Jul 22, 2025
9980a9c
use _lowercase instead of SCREAMING_CASE
emnul Jul 22, 2025
31513c4
Refactor code code block
emnul Jul 22, 2025
7ebeb83
Remove unnecessary expect
emnul Jul 22, 2025
ded8bdf
refactor describe each
emnul Jul 22, 2025
45ba4a8
Add CUSTOM_ADMIN
emnul Jul 22, 2025
4beddb0
Fmt file
emnul Jul 22, 2025
247e043
Add beforeEach block
emnul Jul 22, 2025
ef5a62b
remove extra setter
emnul Jul 22, 2025
930dccd
Check new admin and previous admin permissions
emnul Jul 22, 2025
b582214
fmt file
emnul Jul 22, 2025
c18a3a2
Add more tests, refactor code
emnul Jul 22, 2025
08a2465
fmt file
emnul Jul 22, 2025
232847c
Apply documentation suggestions from code review
emnul Jul 22, 2025
7437b73
Update requirements and test
emnul Jul 22, 2025
affb1e3
Update AccessControl docs
emnul Jul 22, 2025
4440a68
Merge branch 'main' into unshielded-access-control
emnul Jul 22, 2025
dbd9f0c
Annihilate typos
emnul Jul 23, 2025
5eb3e51
Update module docs
emnul Jul 23, 2025
1008842
Merge branch 'main' into unshielded-access-control
emnul Jul 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions contracts/accessControl/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
321 changes: 321 additions & 0 deletions contracts/accessControl/src/AccessControl.compact
Original file line number Diff line number Diff line change
@@ -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<Bytes<32>>(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<Either<ZswapCoinPublicKey, ContractAddress>, Boolean>} hasRole - A mapping from an account to a
* Boolean determining if the account is approved for a role.
* @type {Map<roleId, hasRole>}
* @type {Map<Bytes<32>, Map<Either<ZswapCoinPublicKey, ContractAddress>, Boolean>} _operatorRoles
 */
export ledger _operatorRoles: Map<Bytes<32>, Map<Either<ZswapCoinPublicKey, ContractAddress>, 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<roleId, adminId>}
* @type {Map<Bytes<32>, Bytes<32>>} _adminRoles
 */
export ledger _adminRoles: Map<Bytes<32>, 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<ZswapCoinPublicKey, ContractAddress>} account - The account to query.
* @return {Boolean} - Whether the account has the specified role.
  */
export circuit hasRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): 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<ZswapCoinPublicKey,ContractAddress>(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<ZswapCoinPublicKey, ContractAddress>} account - The account to query.
* @return {[]} - Empty tuple.
*/
export circuit _checkRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): [] {
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<Bytes<32>>;
}

/**
* @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<ZswapCoinPublicKey, ContractAddress>} account - A ZswapCoinPublicKey or ContractAddress.
* @return {[]} - Empty tuple.
*/
export circuit grantRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): [] {
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<ZswapCoinPublicKey, ContractAddress>} account - A ZswapCoinPublicKey or ContractAddress.
* @return {[]} - Empty tuple.
*/
export circuit revokeRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): [] {
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<ZswapCoinPublicKey, ContractAddress>} callerConfirmation - A ZswapCoinPublicKey or ContractAddress.
* @return {[]} - Empty tuple.
*/
export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either<ZswapCoinPublicKey, ContractAddress>): [] {
assert callerConfirmation == left<ZswapCoinPublicKey,ContractAddress>(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<ZswapCoinPublicKey, ContractAddress>} account - A ZswapCoinPublicKey or ContractAddress.
* @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted.
*/
export circuit _grantRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): 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<ZswapCoinPublicKey, ContractAddress>} account - A ZswapCoinPublicKey or ContractAddress.
* @return {Boolean} roleGranted - A boolean indicating if `role` was granted.
*/
export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either<ZswapCoinPublicKey, ContractAddress>): Boolean {
if (hasRole(roleId, account)) {
return false;
}

if (!_operatorRoles.member(roleId)) {
_operatorRoles.insert(
roleId,
default<Map<
Either<ZswapCoinPublicKey, ContractAddress>,
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<ZswapCoinPublicKey, ContractAddress>): Boolean {
if (!hasRole(roleId, account)) {
return false;
}

_operatorRoles
.lookup(roleId)
.insert(account, false);
return true;
}
}
Loading
Loading