Skip to content

Commit db06a6e

Browse files
Amxxjames-toussainternestognw
authored
Add a _canCancel function to improve granularity and allow roleAdminId to cancel the grant and revoke functions (#6573)
Co-authored-by: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Co-authored-by: ernestognw <ernestognw@gmail.com>
1 parent df7e641 commit db06a6e

3 files changed

Lines changed: 80 additions & 7 deletions

File tree

.changeset/famous-roses-clean.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`AccessManager`: Allow a role admin to cancel grant and revoke operations.

contracts/access/manager/AccessManager.sol

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,8 @@ contract AccessManager is Context, Multicall, IAccessManager {
541541
bytes32 operationId = hashOperation(caller, target, data);
542542
if (_schedules[operationId].timepoint == 0) {
543543
revert AccessManagerNotScheduled(operationId);
544-
} else if (caller != msgsender) {
545-
// calls can only be canceled by the account that scheduled them, a global admin, or by a guardian of the required role.
546-
(bool isAdmin, ) = hasRole(ADMIN_ROLE, msgsender);
547-
(bool isGuardian, ) = hasRole(getRoleGuardian(getTargetFunctionRole(target, selector)), msgsender);
548-
if (!isAdmin && !isGuardian) {
549-
revert AccessManagerUnauthorizedCancel(msgsender, caller, target, selector);
550-
}
544+
} else if (!_canCancel(caller, target, data)) {
545+
revert AccessManagerUnauthorizedCancel(msgsender, caller, target, selector);
551546
}
552547

553548
delete _schedules[operationId].timepoint; // reset the timepoint, keep the nonce
@@ -721,6 +716,37 @@ contract AccessManager is Context, Multicall, IAccessManager {
721716
return (delay == 0, delay);
722717
}
723718

719+
/**
720+
* @dev Returns true if a scheduled operation can be canceled by the caller.
721+
*/
722+
function _canCancel(address caller, address target, bytes calldata data) internal view virtual returns (bool) {
723+
address msgsender = _msgSender();
724+
725+
// caller can cancel if they are the msg.sender of the scheduled operation
726+
if (caller == msgsender) {
727+
return true;
728+
}
729+
730+
// admins can cancel any operation, and guardians of the target function's role can cancel it
731+
(bool isAdmin, ) = hasRole(ADMIN_ROLE, msgsender);
732+
(bool isGuardian, ) = hasRole(getRoleGuardian(getTargetFunctionRole(target, _checkSelector(data))), msgsender);
733+
if (isAdmin || isGuardian) {
734+
return true;
735+
}
736+
737+
// if the target is this AccessManager and the call matches an admin-restricted function, allow members
738+
// of the admin role returned by _getAdminRestrictions to cancel. ADMIN_ROLE was already checked above.
739+
if (target == address(this)) {
740+
(bool adminRestricted, uint64 roleId, ) = _getAdminRestrictions(data);
741+
if (adminRestricted && roleId != ADMIN_ROLE) {
742+
(bool inRole, ) = hasRole(roleId, msgsender);
743+
return inRole;
744+
}
745+
}
746+
747+
return false;
748+
}
749+
724750
/**
725751
* @dev Returns true if a call with `target` and `selector` is being executed via {executed}.
726752
*/

test/access/manager/AccessManager.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2392,6 +2392,48 @@ describe('AccessManager', function () {
23922392
});
23932393
});
23942394

2395+
[
2396+
{ name: 'grant', method: 'grantRole(uint64,address,uint32)' },
2397+
{ name: 'revoke', method: 'revokeRole(uint64,address)' },
2398+
].forEach(({ name, method }) => {
2399+
describe(`when caller is a role admin (${name})`, function () {
2400+
beforeEach('reschedule as a role admin call targeting the manager', async function () {
2401+
this.method = this.manager.interface.getFunction(method);
2402+
this.caller = this.roles.SOME_ADMIN.members[0];
2403+
await this.manager.$_grantRole(this.roles.SOME_ADMIN.id, this.caller, 0, 1); // nonzero execution delay
2404+
await this.manager.$_grantRole(this.roles.SOME_ADMIN.id, this.other, 0, 1); // nonzero execution delay
2405+
this.calldata = this.manager.interface.encodeFunctionData(
2406+
this.method,
2407+
name == 'grant'
2408+
? [this.roles.SOME.id, ethers.ZeroAddress, 0]
2409+
: [this.roles.SOME.id, ethers.ZeroAddress],
2410+
);
2411+
const { operationId, schedule } = await prepareOperation(this.manager, {
2412+
caller: this.caller,
2413+
target: this.manager,
2414+
calldata: this.calldata,
2415+
delay: this.scheduleIn,
2416+
});
2417+
this.operationId = operationId;
2418+
await schedule();
2419+
});
2420+
2421+
it('another member of the role admin succeeds', async function () {
2422+
await expect(this.manager.connect(this.other).cancel(this.caller, this.manager, this.calldata))
2423+
.to.emit(this.manager, 'OperationCanceled')
2424+
.withArgs(this.operationId, 1n);
2425+
expect(await this.manager.getSchedule(this.operationId)).to.equal('0');
2426+
});
2427+
2428+
it('a member of the granted role but not its admin reverts', async function () {
2429+
const roleMember = this.roles.SOME.members[0];
2430+
await expect(this.manager.connect(roleMember).cancel(this.caller, this.manager, this.calldata))
2431+
.to.be.revertedWithCustomError(this.manager, 'AccessManagerUnauthorizedCancel')
2432+
.withArgs(roleMember, this.caller, this.manager, this.method.selector);
2433+
});
2434+
});
2435+
});
2436+
23952437
describe('when caller is any other account', function () {
23962438
it('reverts as AccessManagerUnauthorizedCancel', async function () {
23972439
await expect(this.manager.connect(this.other).cancel(this.caller, this.target, this.calldata))

0 commit comments

Comments
 (0)