diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 8969163..ee3823c 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -5,6 +5,10 @@ pragma solidity >=0.8.20 <0.9.0; /// /// @notice Singleton registry of simple and composite policies. Policies are referenced by /// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. +/// +/// @dev Invert (`invertedPolicyId`): all view functions see an inverted policy ID as an +/// extension of the base policy — same existence, admin, pending admin, and child +/// set as the base; `isAuthorized` returns the negated base result. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES @@ -127,6 +131,8 @@ interface IPolicyRegistry { /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite /// and never a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK). The child-policy set is /// capped at 4. + /// @dev A child policy ID may be inverted (its invert flag, bit 63, set via + /// `invertedPolicyId`), in which case the child is evaluated as the inverse of the base. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in @@ -227,6 +233,8 @@ interface IPolicyRegistry { /// BLOCKLIST -> true). /// /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. + /// @dev Invert: `isAuthorized(invertedPolicyId(id), account)` returns the negated + /// result of the base. Applies to every policy type. /// /// @param policyId Policy to query. /// @param account Account to check. @@ -280,9 +288,21 @@ interface IPolicyRegistry { /// @dev An empty return unambiguously means "not a composite". /// @dev The registry preserves the caller's ordering verbatim and neither sorts nor /// de-duplicates. + /// @dev Child IDs are returned as stored, including any per-child invert. /// /// @param policyId Policy to query. /// /// @return Child policy IDs, or an empty array. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); + + /// @notice Returns `policyId` with its invert flag (bit 63) flipped. Never reverts + /// and reads no state. + /// + /// @dev This call does not check that `policyId` exists; a missing + /// or malformed base is denied later, at `isAuthorized`. + /// + /// @param policyId Policy to invert. + /// + /// @return The policy ID with its invert flag toggled. + function invertedPolicyId(uint64 policyId) external view returns (uint64); } diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index d1d770a..e3e3072 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -22,6 +22,10 @@ library PolicyRegistryConstants { /// @dev Encodes as an ALLOWLIST at counter 1 (empty allowlist → block all). uint64 internal constant ALWAYS_BLOCK_ID = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | 1; + /// @notice High bit of a `uint64` policy ID that inverts the base policy. + /// @dev All view functions see an inverted ID as an extension of the base — policy ID + uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63; + /// @notice Number of built-in policies the registry initializes on /// first use. The global counter is advanced to this value /// once both sentinels are populated, so custom policies @@ -70,6 +74,11 @@ contract MockPolicyRegistry is IPolicyRegistry { // Policy ID encoding: top byte = uint8(PolicyType), low 56 bits = counter. uint64 internal constant POLICY_ID_TYPE_SHIFT = 56; + /// @notice Invert flag on a policy ID: bit 63. + /// @dev Sourced from `PolicyRegistryConstants` so the mock and tests share one + /// definition of the bit. + uint64 internal constant INVERTED_POLICY_BIT = PolicyRegistryConstants.INVERTED_POLICY_BIT; + /// @notice Per-call membership-batch limit. `createPolicyWithAccounts`, /// `updateAllowlist`, and `updateBlocklist` revert with /// `BatchSizeTooLarge(MAX_BATCH_SIZE)` when `accounts.length` @@ -238,20 +247,18 @@ contract MockPolicyRegistry is IPolicyRegistry { // ============================================================ /// @inheritdoc IPolicyRegistry + /// @dev An inverted ID resolves to the existence of its base: `policyExists(~id)` + /// equals `policyExists(id)`, so a token may store and later re-validate an + /// inverted policy exactly as it would a plain one. function policyExists(uint64 policyId) external view returns (bool) { - if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; - if (!_isWellFormed(policyId)) return false; - // Use the typed `policyExistsFromPacked` helper rather than a raw - // `packed != 0` test. Functionally identical given the encoding - // invariant (exists bit is always set when `_encode` writes the - // slot), but matches the Rust precompile's `packed.exists()` - // call and survives any future encoding change that adds bits - // above the admin lane without setting the exists bit. - return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); + return _policyExists(policyId); } /// @inheritdoc IPolicyRegistry + /// @dev An inverted ID has no record of its own; it resolves to its base's admin, + /// matching `policyExists` (`policyAdmin(~id) == policyAdmin(id)`). function policyAdmin(uint64 policyId) external view returns (address) { + policyId = _basePolicyId(policyId); if (!_isWellFormed(policyId)) return address(0); // No fast path for built-in IDs needed: lazy init writes them with // a zero admin, so the normal storage read returns address(0) for @@ -276,18 +283,27 @@ contract MockPolicyRegistry is IPolicyRegistry { // below would also return `address(0)` for built-ins in normal // operation (they never have a pending admin staged), but the // explicit branch removes that assumption from the trust boundary. + policyId = _basePolicyId(policyId); if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return address(0); if (!_isWellFormed(policyId)) return address(0); return MockPolicyRegistryStorage.layout().pendingAdmins[policyId]; } /// @inheritdoc IPolicyRegistry + function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { + policyId = _basePolicyId(policyId); if (!_isWellFormed(policyId)) return new uint64[](0); if (!_isComposite(policyId)) return new uint64[](0); return MockPolicyRegistryStorage.layout().children[policyId]; } + /// @inheritdoc IPolicyRegistry + /// @dev Pure toggle of the invert flag on a policy ID; never reverts and reads no state. + function invertedPolicyId(uint64 policyId) external pure returns (uint64) { + return policyId ^ INVERTED_POLICY_BIT; + } + // ============================================================ // INTERNAL HELPERS // ============================================================ @@ -349,6 +365,16 @@ contract MockPolicyRegistry is IPolicyRegistry { if (packed == 0) revert PolicyNotFound(); } + /// @dev Existence predicate shared by the external `policyExists` view and the + /// fail-closed guard in `_isAuthorized`. + function _policyExists(uint64 policyId) internal view returns (bool) { + policyId = _basePolicyId(policyId); + if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; + if (!_isWellFormed(policyId)) return false; + + return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); + } + /// @dev Core authorization logic shared by the external view and composite /// child evaluation. Never reverts. /// @@ -358,6 +384,12 @@ contract MockPolicyRegistry is IPolicyRegistry { /// `_isAuthorized` per child, each of which resolves via the simple path /// (or a built-in short-circuit). function _isAuthorized(uint64 policyId, address account) internal view returns (bool) { + bool isInverted = policyId & INVERTED_POLICY_BIT != 0; + if (isInverted) { + uint64 base = _basePolicyId(policyId); + if (!_policyExists(base)) return false; + return !_isAuthorized(base, account); + } // Built-in short-circuits precede any SLOAD; sentinels have no // storage entry. if (policyId == ALWAYS_ALLOW_ID) return true; @@ -404,16 +436,17 @@ contract MockPolicyRegistry is IPolicyRegistry { /// and must not itself be a composite. Two passes so `PolicyNotFound` takes /// precedence over `InvalidChildPolicy` across the whole set (matches the /// canonical revert order the Rust precompile mirrors). + /// @dev An inverted valid policy ID counts as a valid composite child. function _requireCreatedSimplePolicies(uint64[] calldata childPolicyIds) internal view { MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout(); - // Pass 1: existence. + // Pass 1: existence of the base (an inverted child references its base's members). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - if ($.policies[childPolicyIds[i]] == 0) revert PolicyNotFound(); + if ($.policies[_basePolicyId(childPolicyIds[i])] == 0) revert PolicyNotFound(); } - // Pass 2: must be a simple policy + // Pass 2: the base must be a simple policy (never a sentinel or a composite). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - uint64 child = childPolicyIds[i]; - if (_isBuiltin(child) || _isComposite(child)) revert InvalidChildPolicy(child); + uint64 base = _basePolicyId(childPolicyIds[i]); + if (_isBuiltin(base) || _isComposite(base)) revert InvalidChildPolicy(childPolicyIds[i]); } } @@ -434,6 +467,11 @@ contract MockPolicyRegistry is IPolicyRegistry { return policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID; } + /// @dev Strips the invert flag from the policy ID. + function _basePolicyId(uint64 policyId) internal pure returns (uint64) { + return policyId & ~INVERTED_POLICY_BIT; + } + function _makeId(PolicyType policyType, uint56 counter) internal pure returns (uint64) { return (uint64(uint8(policyType)) << POLICY_ID_TYPE_SHIFT) | uint64(counter); } diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol new file mode 100644 index 0000000..b1e251b --- /dev/null +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; + +/// @notice Covers the invert (NOT) flag on the policy ID. IsAuthrized evaluates the base policy and inverts the result. +contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { + uint64 internal constant INVERTED_POLICY_BIT = PolicyRegistryConstants.INVERTED_POLICY_BIT; + + function _addAllowlistMember(uint64 policyId, address account) internal { + address[] memory accounts = new address[](1); + accounts[0] = account; + vm.prank(admin); + policyRegistry.updateAllowlist(policyId, true, accounts); + } + + function _addBlocklistMember(uint64 policyId, address account) internal { + address[] memory accounts = new address[](1); + accounts[0] = account; + vm.prank(admin); + policyRegistry.updateBlocklist(policyId, true, accounts); + } + + // ============================================================ + // FAIL-CLOSED INVARIANT (the point of 2a) + // ============================================================ + + /// @notice Inverting an uncreated (unknown) base denies rather than allowing everyone. + function test_isAuthorized_success_invertUnknownAllowlistBaseDenies(uint56 counter, address account) public view { + vm.assume(counter > 1); + uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + /// @notice Inverting an uncreated BLOCKLIST base also denies (fail-closed) + function test_isAuthorized_success_invertUnknownBlocklistBaseDenies(uint56 counter, address account) public view { + vm.assume(counter > 1); + uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.BLOCKLIST)) << 56) | uint64(counter); + // Sanity: the plain unknown blocklist authorizes (empty-member-set semantics)... + assertTrue(policyRegistry.isAuthorized(base, account)); + // ...but its inverse must NOT become allow-everyone; the base does not exist. + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + /// @notice Inverting a malformed base denies. + function test_isAuthorized_success_invertMalformedBaseDenies(uint64 seed, address account) public view { + uint64 base = _malformedPolicyId(seed) & ~INVERTED_POLICY_BIT; + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + // ============================================================ + // SIMPLE-POLICY INVERSION + // ============================================================ + + /// @notice NOT(allowlist): a member of the base is denied by the inverse. + function test_isAuthorized_success_invertAllowlistMemberDenied(address account) public { + uint64 base = _createAllowlist(); + _addAllowlistMember(base, account); + assertTrue(policyRegistry.isAuthorized(base, account)); + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + /// @notice NOT(allowlist): a non-member of the base is authorized by the inverse. + function test_isAuthorized_success_invertAllowlistNonMemberAuthorized(address account) public { + uint64 base = _createAllowlist(); + assertFalse(policyRegistry.isAuthorized(base, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + /// @notice NOT(blocklist): a blocked account (base denies) is authorized by the inverse. + function test_isAuthorized_success_invertBlocklistMemberAuthorized(address account) public { + uint64 base = _createBlocklist(); + _addBlocklistMember(base, account); + assertFalse(policyRegistry.isAuthorized(base, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); + } + + // ============================================================ + // BUILT-IN INVERSION + // ============================================================ + + /// @notice NOT(ALWAYS_ALLOW) denies every account. + function test_isAuthorized_success_invertAlwaysAllowDenies(address account) public { + // Touch the registry so the built-ins are initialized before the query. + _createAllowlist(); + assertFalse(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_ALLOW_ID | INVERTED_POLICY_BIT, account)); + } + + /// @notice NOT(ALWAYS_BLOCK) authorizes every account. + function test_isAuthorized_success_invertAlwaysBlockAuthorizes(address account) public { + _createAllowlist(); + assertTrue(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_BLOCK_ID | INVERTED_POLICY_BIT, account)); + } + + // ============================================================ + // COMPOSITE WITH PER-CHILD INVERT: "A AND NOT X" + // ============================================================ + + /// @notice INTERSECT[A, ~X] reads as "on A and not on X". A member of both A and X is + /// denied (fails the NOT-X leg); a member of A only is authorized. + function test_isAuthorized_success_intersectAllowAndNotX(address account) public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + _addAllowlistMember(a, account); + uint64 invertedX = x | INVERTED_POLICY_BIT; + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX)); + + // account is on A and NOT on X -> authorized. + assertTrue(policyRegistry.isAuthorized(composite, account)); + + // Add account to X: now it is on A but IS on X -> the ~X leg denies. + _addAllowlistMember(x, account); + assertFalse(policyRegistry.isAuthorized(composite, account)); + } + + // ============================================================ + // COMPOSITE-CHILD VALIDATION WITH INVERT FLAG + // ============================================================ + + /// @notice An inverted simple child is accepted and stored verbatim (flag intact). + function test_createCompositePolicy_success_invertedSimpleChild() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 invertedX = x | INVERTED_POLICY_BIT; + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX)); + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[1], invertedX); + } + + /// @notice An inverted child whose base does not exist reverts with PolicyNotFound — + /// the invert flag cannot smuggle a non-existent child past validation. + function test_createCompositePolicy_revert_invertedChildBaseNotFound() public { + uint64 a = _createAllowlist(); + uint64 missing = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(9999); + uint64 invertedMissing = missing | INVERTED_POLICY_BIT; + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedMissing)); + } + + /// @notice An inverted COMPOSITE child is rejected: the invert flag must not let a + /// nested gate slip past the flat-tree invariant. + function test_createCompositePolicy_revert_invertedCompositeChild() public { + uint64 a = _createAllowlist(); + uint64 b = _createAllowlist(); + uint64 inner = policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(a, b)); + uint64 invertedInner = inner | INVERTED_POLICY_BIT; + uint64 c = _createAllowlist(); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, invertedInner)); + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, invertedInner)); + } + + // ============================================================ + // compositePolicyChildIds RETURNS CHILDREN VERBATIM + // ============================================================ + + /// @notice The read returns child IDs exactly as stored: a plain child is returned plain, + /// an inverted child is returned with its invert flag intact. + function test_compositePolicyChildIds_success_returnsChildrenVerbatim() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) + ); + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[0], a, "plain child returned unchanged"); + assertEq(children[1], x | INVERTED_POLICY_BIT, "inverted child returned with flag set"); + } + + /// @notice Querying the composite's own inverse returns the identical child set + function test_compositePolicyChildIds_success_invertedCompositeIdReturnsSameSet() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) + ); + uint64[] memory viaBase = policyRegistry.compositePolicyChildIds(composite); + uint64[] memory viaInverse = policyRegistry.compositePolicyChildIds(composite | INVERTED_POLICY_BIT); + assertEq(viaInverse.length, viaBase.length); + for (uint256 i = 0; i < viaBase.length; ++i) { + assertEq(viaInverse[i], viaBase[i]); + } + } + + /// @notice updateComposite preserves the verbatim-return contract: after replacing the + /// child set, an inverted child still reads back with its flag set. + function test_compositePolicyChildIds_success_returnsInvertedChildVerbatimAfterUpdate() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 y = _createAllowlist(); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x)); + + vm.prank(admin); + policyRegistry.updateComposite(composite, _childIds(a, y | INVERTED_POLICY_BIT)); + + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[0], a); + assertEq(children[1], y | INVERTED_POLICY_BIT, "inverted child persists verbatim after update"); + } + + // ============================================================ + // GETTER STRIP SEMANTICS + // ============================================================ + + /// @notice policyExists(~id) mirrors policyExists(id): the inverse of a created policy + /// reports existing + function test_policyExists_success_invertMirrorsBase(uint56 counter) public { + vm.assume(counter > 1); + uint64 created = _createAllowlist(); + assertTrue(policyRegistry.policyExists(created | INVERTED_POLICY_BIT)); + + uint64 unknown = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); + assertEq(policyRegistry.policyExists(unknown | INVERTED_POLICY_BIT), policyRegistry.policyExists(unknown)); + } + + /// @notice policyAdmin(~id) resolves to the base's admin. + function test_policyAdmin_success_invertResolvesBaseAdmin(address policyAdmin) public { + vm.assume(policyAdmin != address(0)); + uint64 base = _createAllowlist(admin, policyAdmin); + assertEq(policyRegistry.policyAdmin(base | INVERTED_POLICY_BIT), policyAdmin); + } + + // ============================================================ + // invertedPolicyId() VIEW + // ============================================================ + + /// @notice The registry view toggles the invert flag, is involutive, and never reverts — + /// including for unknown/malformed IDs (it reads no state). + function test_invertedPolicyId_success_togglesAndRoundTrips(uint64 base) public view { + uint64 inverted = policyRegistry.invertedPolicyId(base); + assertEq(inverted, base ^ INVERTED_POLICY_BIT); + assertEq(policyRegistry.invertedPolicyId(inverted), base); + } + + /// @notice End-to-end: authorizing against the view's result negates the base decision. + function test_invertedPolicyId_success_negatesAuthorization(address account) public { + uint64 base = _createAllowlist(); + _addAllowlistMember(base, account); + uint64 inverted = policyRegistry.invertedPolicyId(base); + assertTrue(policyRegistry.isAuthorized(base, account)); + assertFalse(policyRegistry.isAuthorized(inverted, account)); + } +}