Skip to content
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

feat: Protected SetRoleHolders Guard #507

Closed
wants to merge 21 commits into from
61 changes: 61 additions & 0 deletions src/guards/ProtectedSetRoleHoldersGuard.sol
AustinGreen marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why but this link doesn't seem to take me to anything useful. can you paste what you'd like me to see here or send a screenshot

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import {ActionInfo, RoleHolderData} from "src/lib/Structs.sol";
import {ILlamaActionGuard} from "src/interfaces/ILlamaActionGuard.sol";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order here should be flipped.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// @title Protected Set Role Holder Guard
/// @author Llama ([email protected])
/// @notice A guard that protects against unauthorized calls to setRoleHolders on the LlamaGovernanceScript.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be more descriptive here. Unauthorized callers can never call setRoleHolders. It's more about defining which roles can add policyholders to other roles

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contract ProtectedSetRoleHoldersGuard is ILlamaActionGuard {
/// @dev Throws if called by any account other than the EXECUTOR.
error OnlyLlamaExecutor();
/// @dev Throws if the setterRole is not authorized to set the targetRole.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thrown not Throws

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And setterRole and targetRole should be in backticks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error UnauthorizedSetRoleHolder(uint8 setterRole, uint8 targetRole);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could call it actionCreatorRole rather than setterRole which seems to be a bit more accurate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


AustinGreen marked this conversation as resolved.
Show resolved Hide resolved
/// @dev Emitted when the authorizedSetRoleHolder mapping is updated.
event AuthorizedSetRoleHolder(uint8 indexed setterRole, uint8 indexed targetRole, bool isAuthorized);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could call it actionCreatorRole rather than setterRole which seems to be a bit more accurate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// @notice BYPASS_PROTECTION_ROLE can be set to 0 to disable this feature.
/// This also means the all holders role cannot be set as the BYPASS_PROTECTION_ROLE.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some about this screams icky to me. If the ALL HOLDERS ROLE ever held permission to call setRoleHolders on the Gov Script, it would always bypass the check? (Far fetched I know, but we should account for all possibilities.).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, that is not true at all. if you read the code, or the comment, it's clear that it would never bypass the check and setting BYPASS_PROTECTION_ROLE to 0 (default value) disables the bypass role entirely.

uint8 public immutable BYPASS_PROTECTION_ROLE;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the idea behind a Bypass Protection Role? To allow some role to bypass the action creation check? Why just 1 role in that case (and why not multiple achievable through another mapping)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd assume there would be cases where multiple roles will want to bypass the check.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't really see the point of this either. If the concern setRoleHolders gets bricked then the guard can be disabled. setRoleHolder can also be used if needed. I think it would simplify things not to have it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea is to provide a better UX. if there is a core team role that can set every role, they shouldn't need to iteratively update the permissions mapping and update it every time a new role is created.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes me think the whole design of this is wrong then. We should either make one of the following two assumptions:

  1. If this guard is enabled, all roles should default to not having any setRoleHolders access and any access to this function must be updated here.
  2. If this guard is enabled, all roles should default to having normal setRoleHolders access and any restrictions to this function must be updated here.

This conversations makes me think we should go with 2. Having a dedicated bypass role is an extension to the Llama role system that just increases complexity.

Think about explaining this process to a smart crypto-native friend:

LlamaCore::executeAction runs the guard -> LlamaExecutor::execute -> LlamaGovernanceScript::setRoleHolders -> LlamaPolicy::setRoleHolder

It's already complicated enough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of 2.

/// @notice The `LlamaExecutor` contract address that controls this guard contract.
address public immutable EXECUTOR;

/// @notice A mapping to keep track of which roles the setterRole is authorized to set.
mapping(uint8 => mapping(uint8 => bool)) public authorizedSetRoleHolder;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do named mappings here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


constructor(uint8 _BYPASS_PROTECTION_ROLE, address _executor) {
BYPASS_PROTECTION_ROLE = _BYPASS_PROTECTION_ROLE;
EXECUTOR = _executor;
}

/// @inheritdoc ILlamaActionGuard
/// @dev Performs a validation check at action creation time that the action creator is authorized to set the role.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't inheritdoc here right? We should just write proper natspec for these params and what the function does

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why not to inherit doc, or why this isn't "proper". The inherit doc already describes the params. I changed the additional comment to @notice instead of dev which defines what the function is doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function validateActionCreation(ActionInfo calldata actionInfo) external view {
if (BYPASS_PROTECTION_ROLE == 0 || actionInfo.creatorRole != BYPASS_PROTECTION_ROLE) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If BYPASS_PROTECTION_ROLE is 0 it'll always enter this if scope right? How is that bypassing it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's clear that this is not true and when it's equal to 0 the bypass role is disabled

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait why is that? If BYPASS_PROTECTION_ROLE == 0, your if condition is satisfied, so you always enter the if scope. But the idea is to bypass it right?

so shouldn't the condition be if (BYPASS_PROTECTION_ROLE != 0 && actionInfo.creatorRole != BYPASS_PROTECTION_ROLE) ?

RoleHolderData[] memory roleHolderData = abi.decode(actionInfo.data[4:], (RoleHolderData[]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took me a second to figure out what's going on here. Might be worth it to leave a comment saying that you're slicing the bytes array to get the function calldata from the data field.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agreed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for (uint256 i = 0; i < roleHolderData.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things to save gas (since we're on v0.8.19):

  1. Define length outside the loop
  2. Use LlamaUtils.uncheckedIncrement(i) for the index increment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!authorizedSetRoleHolder[actionInfo.creatorRole][roleHolderData[i].role]) {
revert UnauthorizedSetRoleHolder(actionInfo.creatorRole, roleHolderData[i].role);
}
}
}
}

/// @notice Allows the EXECUTOR to set the authorizedSetRoleHolder mapping.
/// @param setterRole The role that is is being authorized or unauthorized to set the targetRole.
/// @param targetRole The role that the setterRole is being authorized or unauthorized to set.
/// @param isAuthorized Whether the setterRole is authorized to set the targetRole.
function setAuthorizedSetRoleHolder(uint8 setterRole, uint8 targetRole, bool isAuthorized) external {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could call it actionCreatorRole rather than setterRole which seems to be a bit more accurate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (msg.sender != EXECUTOR) revert OnlyLlamaExecutor();
authorizedSetRoleHolder[setterRole][targetRole] = isAuthorized;
emit AuthorizedSetRoleHolder(setterRole, targetRole, isAuthorized);
}

/// @inheritdoc ILlamaActionGuard
function validatePreActionExecution(ActionInfo calldata actionInfo) external pure {}

/// @inheritdoc ILlamaActionGuard
function validatePostActionExecution(ActionInfo calldata actionInfo) external pure {}
}
113 changes: 113 additions & 0 deletions test/guards/ProtectedSetRoleHoldersGuard.t.sol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think SetRoleHolderGuard is a suitable name. It's a lot shorter and the Protected adjective doesn't do much. The purpose of every guard is to protect something.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be SetRoleHoldersGuard IMO. Since this is protecting setRoleHolders and not setRoleHolder.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test, console2} from "forge-std/Test.sol";

import {ProtectedSetRoleHoldersGuard} from "src/guards/ProtectedSetRoleHoldersGuard.sol";
import {ActionInfo, PermissionData, RoleHolderData} from "src/lib/Structs.sol";
import {LlamaGovernanceScript} from "src/llama-scripts/LlamaGovernanceScript.sol";
import {Roles, LlamaTestSetup} from "test/utils/LlamaTestSetup.sol";
import {LlamaGovernanceScriptTest} from "test/llama-scripts/LlamaGovernanceScript.t.sol";

contract ProtectedSetRoleHolderTest is LlamaGovernanceScriptTest {
event AuthorizedSetRoleHolder(uint8 indexed setterRole, uint8 indexed targetRole, bool isAuthorized);

ProtectedSetRoleHoldersGuard public guard;

function setUp() public override {
LlamaGovernanceScriptTest.setUp();
guard = new ProtectedSetRoleHoldersGuard(uint8(0), address(mpExecutor));
vm.prank(address(mpExecutor));
mpCore.setGuard(address(govScript), SET_ROLE_HOLDERS_SELECTOR, guard);
}
}

contract ValidateActionCreation is ProtectedSetRoleHolderTest {
function test_RevertIf_UnauthorizedSetRoleHolder(uint8 targetRole) public {
targetRole = uint8(bound(targetRole, 1, 8)); // number of existing roles excluding all holders role

RoleHolderData[] memory roleHolderData = new RoleHolderData[](1);
roleHolderData[0] = RoleHolderData({role: targetRole, policyholder: approverAdam, quantity: 1, expiration: 0});

bytes memory data = abi.encodeWithSelector(SET_ROLE_HOLDERS_SELECTOR, roleHolderData);

// There is no bypass role, and we have not set any authorizations, so this should always revert.
vm.expectRevert(
abi.encodeWithSelector(
ProtectedSetRoleHoldersGuard.UnauthorizedSetRoleHolder.selector, uint8(Roles.ActionCreator), targetRole
)
);
vm.prank(actionCreatorAaron);
mpCore.createAction(uint8(Roles.ActionCreator), mpStrategy2, address(govScript), 0, data, "");
}

function test_BypassProtectionRoleWorksWithAllExisingRoles(uint8 targetRole) public {
targetRole = uint8(bound(targetRole, 1, 8)); // number of existing roles excluding all holders role

// create a new guard with a bypass role
guard = new ProtectedSetRoleHoldersGuard(uint8(Roles.ActionCreator), address(mpExecutor));
vm.prank(address(mpExecutor));
mpCore.setGuard(address(govScript), SET_ROLE_HOLDERS_SELECTOR, guard);

RoleHolderData[] memory roleHolderData = new RoleHolderData[](1);
roleHolderData[0] =
RoleHolderData({role: targetRole, policyholder: approverAdam, quantity: 1, expiration: type(uint64).max});

bytes memory data = abi.encodeWithSelector(SET_ROLE_HOLDERS_SELECTOR, roleHolderData);

ActionInfo memory actionInfo = _createAndApproveAndQueueAction(data);
mpCore.executeAction(actionInfo);

assertEq(mpPolicy.hasRole(approverAdam, targetRole), true);
}

function test_AuthorizedSetRoleHolder(uint8 targetRole) public {
targetRole = uint8(bound(targetRole, 1, 8)); // number of existing roles excluding all holders role

// set role authorization
vm.prank(address(mpExecutor));
vm.expectEmit();
emit AuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, true);
guard.setAuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, true);

RoleHolderData[] memory roleHolderData = new RoleHolderData[](1);
roleHolderData[0] =
RoleHolderData({role: targetRole, policyholder: approverAdam, quantity: 1, expiration: type(uint64).max});

bytes memory data = abi.encodeWithSelector(SET_ROLE_HOLDERS_SELECTOR, roleHolderData);

ActionInfo memory actionInfo = _createAndApproveAndQueueAction(data);

mpCore.executeAction(actionInfo);

assertEq(mpPolicy.hasRole(approverAdam, targetRole), true);
}

function test_IfAuthorizationChangesBeforeExecution(uint8 targetRole) public {
targetRole = uint8(bound(targetRole, 1, 8)); // number of existing roles excluding all holders role

// set role authorization
vm.prank(address(mpExecutor));
vm.expectEmit();
emit AuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, true);
guard.setAuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, true);

RoleHolderData[] memory roleHolderData = new RoleHolderData[](1);
roleHolderData[0] =
RoleHolderData({role: targetRole, policyholder: approverAdam, quantity: 1, expiration: type(uint64).max});

bytes memory data = abi.encodeWithSelector(SET_ROLE_HOLDERS_SELECTOR, roleHolderData);

ActionInfo memory actionInfo = _createAndApproveAndQueueAction(data);

// setting role authorization to false mid action
vm.prank(address(mpExecutor));
vm.expectEmit();
emit AuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, false);
guard.setAuthorizedSetRoleHolder(uint8(Roles.ActionCreator), targetRole, false);

mpCore.executeAction(actionInfo);

assertEq(mpPolicy.hasRole(approverAdam, targetRole), true);
}
}
Loading