@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Provide core policy support for Spaces

Summary:
Ref T8424. No UI or interesting behavior yet, but integrates Spaces checks:

- `PolicyFilter` now checks Spaces.
- `PolicyAwareQuery` now automatically adds Spaces constraints.

There's one interesting design decision here: **spaces are stronger than automatic capabilities**. That means that you can't see a task in a space you don't have permission to access, //even if you are the owner//.

I //think// this is desirable. Particularly, we need to do this in order to exclude objects at the query level, which potentially makes policy filtering for spaces hugely more efficient. I also like Spaces being very strong, conceptually.

It's possible that we might want to change this; this would reduce our access to optimizations but might be a little friendlier or make more sense to users later on.

For now, at least, I'm pursuing the more aggressive line. If we stick with this, we probably need to make some additional UI affordances (e.g., show when an owner can't see a task).

This also means that you get a hard 404 instead of a policy exception when you try to access something in a space you can't see. I'd slightly prefer to show you a policy exception instead, but think this is generally a reasonable tradeoff to get the high-performance filtering at the Query layer.

Test Plan:
- Added and executed unit tests.
- Put objects in spaces and viewed them with multiple users.
- Made the default space visible/invisible, viewed objects.
- Checked the services panel and saw `spacePHID` constraints.
- Verified that this adds only one query to each page.

Reviewers: btrahan, chad

Reviewed By: btrahan

Subscribers: chad, epriestley

Maniphest Tasks: T8424

Differential Revision: https://secure.phabricator.com/D13156

+401 -2
+4
src/applications/paste/query/PhabricatorPasteQuery.php
··· 67 67 return $this; 68 68 } 69 69 70 + protected function newResultObject() { 71 + return new PhabricatorPaste(); 72 + } 73 + 70 74 protected function loadPage() { 71 75 $table = new PhabricatorPaste(); 72 76 $conn_r = $table->establishConnection('r');
+68
src/applications/policy/filter/PhabricatorPolicyFilter.php
··· 453 453 return true; 454 454 } 455 455 456 + if ($object instanceof PhabricatorSpacesInterface) { 457 + $space_phid = $object->getSpacePHID(); 458 + if (!$this->canViewerSeeObjectsInSpace($viewer, $space_phid)) { 459 + $this->rejectObjectFromSpace($object, $space_phid); 460 + return false; 461 + } 462 + } 463 + 456 464 if ($object->hasAutomaticCapability($capability, $viewer)) { 457 465 return true; 458 466 } ··· 674 682 } 675 683 676 684 return $access_denied; 685 + } 686 + 687 + 688 + private function canViewerSeeObjectsInSpace( 689 + PhabricatorUser $viewer, 690 + $space_phid) { 691 + 692 + $spaces = PhabricatorSpacesNamespaceQuery::getAllSpaces(); 693 + 694 + // If there are no spaces, everything exists in an implicit default space 695 + // with no policy controls. This is the default state. 696 + if (!$spaces) { 697 + if ($space_phid !== null) { 698 + return false; 699 + } else { 700 + return true; 701 + } 702 + } 703 + 704 + if ($space_phid === null) { 705 + $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); 706 + } else { 707 + $space = idx($spaces, $space_phid); 708 + } 709 + 710 + if (!$space) { 711 + return false; 712 + } 713 + 714 + // This may be more involved later, but for now being able to see the 715 + // space is equivalent to being able to see everything in it. 716 + return self::hasCapability( 717 + $viewer, 718 + $space, 719 + PhabricatorPolicyCapability::CAN_VIEW); 720 + } 721 + 722 + private function rejectObjectFromSpace( 723 + PhabricatorPolicyInterface $object, 724 + $space_phid) { 725 + 726 + if (!$this->raisePolicyExceptions) { 727 + return; 728 + } 729 + 730 + if ($this->viewer->isOmnipotent()) { 731 + return; 732 + } 733 + 734 + $access_denied = $this->renderAccessDenied($object); 735 + 736 + $rejection = pht( 737 + 'This object is in a space you do not have permission to access.'); 738 + $full_message = pht('[%s] %s', $access_denied, $rejection); 739 + 740 + $exception = id(new PhabricatorPolicyException($full_message)) 741 + ->setTitle($access_denied) 742 + ->setRejection($rejection); 743 + 744 + throw $exception; 677 745 } 678 746 679 747 }
+100 -2
src/applications/spaces/__tests__/PhabricatorSpacesTestCase.php
··· 14 14 // Test that our helper methods work correctly. 15 15 16 16 $actor = $this->generateNewTestUser(); 17 - $this->newSpace($actor, pht('Test Space'), true); 17 + 18 + $default = $this->newSpace($actor, pht('Test Space'), true); 18 19 $this->assertEqual(1, count($this->loadAllSpaces())); 20 + $this->assertEqual( 21 + 1, 22 + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); 23 + $cache_default = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); 24 + $this->assertEqual($default->getPHID(), $cache_default->getPHID()); 25 + 19 26 $this->destroyAllSpaces(); 20 27 $this->assertEqual(0, count($this->loadAllSpaces())); 28 + $this->assertEqual( 29 + 0, 30 + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); 31 + $this->assertEqual( 32 + null, 33 + PhabricatorSpacesNamespaceQuery::getDefaultSpace()); 21 34 } 22 35 23 36 public function testSpacesSeveralSpaces() { ··· 27 40 // work fine. 28 41 29 42 $actor = $this->generateNewTestUser(); 30 - $this->newSpace($actor, pht('Default Space'), true); 43 + $default = $this->newSpace($actor, pht('Default Space'), true); 31 44 $this->newSpace($actor, pht('Alternate Space'), false); 32 45 $this->assertEqual(2, count($this->loadAllSpaces())); 46 + $this->assertEqual( 47 + 2, 48 + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); 49 + 50 + $cache_default = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); 51 + $this->assertEqual($default->getPHID(), $cache_default->getPHID()); 33 52 } 34 53 35 54 public function testSpacesRequireNames() { ··· 70 89 $this->assertTrue(($caught instanceof Exception)); 71 90 } 72 91 92 + public function testSpacesPolicyFiltering() { 93 + $this->destroyAllSpaces(); 94 + 95 + $creator = $this->generateNewTestUser(); 96 + $viewer = $this->generateNewTestUser(); 97 + 98 + // Create a new paste. 99 + $paste = PhabricatorPaste::initializeNewPaste($creator) 100 + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) 101 + ->setFilePHID('') 102 + ->setLanguage('') 103 + ->save(); 104 + 105 + // It should be visible. 106 + $this->assertTrue( 107 + PhabricatorPolicyFilter::hasCapability( 108 + $viewer, 109 + $paste, 110 + PhabricatorPolicyCapability::CAN_VIEW)); 111 + 112 + // Create a default space with an open view policy. 113 + $default = $this->newSpace($creator, pht('Default Space'), true) 114 + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) 115 + ->save(); 116 + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); 117 + 118 + // The paste should now be in the space implicitly, but still visible 119 + // because the space view policy is open. 120 + $this->assertTrue( 121 + PhabricatorPolicyFilter::hasCapability( 122 + $viewer, 123 + $paste, 124 + PhabricatorPolicyCapability::CAN_VIEW)); 125 + 126 + // Make the space view policy restrictive. 127 + $default 128 + ->setViewPolicy(PhabricatorPolicies::POLICY_NOONE) 129 + ->save(); 130 + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); 131 + 132 + // The paste should be in the space implicitly, and no longer visible. 133 + $this->assertFalse( 134 + PhabricatorPolicyFilter::hasCapability( 135 + $viewer, 136 + $paste, 137 + PhabricatorPolicyCapability::CAN_VIEW)); 138 + 139 + // Put the paste in the space explicitly. 140 + $paste 141 + ->setSpacePHID($default->getPHID()) 142 + ->save(); 143 + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); 144 + 145 + // This should still fail, we're just in the space explicitly now. 146 + $this->assertFalse( 147 + PhabricatorPolicyFilter::hasCapability( 148 + $viewer, 149 + $paste, 150 + PhabricatorPolicyCapability::CAN_VIEW)); 151 + 152 + // Create an alternate space with more permissive policies, then move the 153 + // paste to that space. 154 + $alternate = $this->newSpace($creator, pht('Alternate Space'), false) 155 + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) 156 + ->save(); 157 + $paste 158 + ->setSpacePHID($alternate->getPHID()) 159 + ->save(); 160 + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); 161 + 162 + // Now the paste should be visible again. 163 + $this->assertTrue( 164 + PhabricatorPolicyFilter::hasCapability( 165 + $viewer, 166 + $paste, 167 + PhabricatorPolicyCapability::CAN_VIEW)); 168 + } 169 + 73 170 private function loadAllSpaces() { 74 171 return id(new PhabricatorSpacesNamespaceQuery()) 75 172 ->setViewer(PhabricatorUser::getOmnipotentUser()) ··· 77 174 } 78 175 79 176 private function destroyAllSpaces() { 177 + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); 80 178 $spaces = $this->loadAllSpaces(); 81 179 foreach ($spaces as $space) { 82 180 $engine = new PhabricatorDestructionEngine();
+67
src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php
··· 3 3 final class PhabricatorSpacesNamespaceQuery 4 4 extends PhabricatorCursorPagedPolicyAwareQuery { 5 5 6 + const KEY_ALL = 'spaces.all'; 7 + const KEY_DEFAULT = 'spaces.default'; 8 + 6 9 private $ids; 7 10 private $phids; 8 11 private $isDefaultNamespace; ··· 72 75 73 76 $where[] = $this->buildPagingClause($conn_r); 74 77 return $this->formatWhereClause($where); 78 + } 79 + 80 + public static function destroySpacesCache() { 81 + $cache = PhabricatorCaches::getRequestCache(); 82 + $cache->deleteKeys( 83 + array( 84 + self::KEY_ALL, 85 + self::KEY_DEFAULT, 86 + )); 87 + } 88 + 89 + public static function getAllSpaces() { 90 + $cache = PhabricatorCaches::getRequestCache(); 91 + $cache_key = self::KEY_ALL; 92 + 93 + $spaces = $cache->getKey($cache_key); 94 + if ($spaces === null) { 95 + $spaces = id(new PhabricatorSpacesNamespaceQuery()) 96 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 97 + ->execute(); 98 + $spaces = mpull($spaces, null, 'getPHID'); 99 + $cache->setKey($cache_key, $spaces); 100 + } 101 + 102 + return $spaces; 103 + } 104 + 105 + public static function getDefaultSpace() { 106 + $cache = PhabricatorCaches::getRequestCache(); 107 + $cache_key = self::KEY_DEFAULT; 108 + 109 + $default_space = $cache->getKey($cache_key, false); 110 + if ($default_space === false) { 111 + $default_space = null; 112 + 113 + $spaces = self::getAllSpaces(); 114 + foreach ($spaces as $space) { 115 + if ($space->getIsDefaultNamespace()) { 116 + $default_space = $space; 117 + break; 118 + } 119 + } 120 + 121 + $cache->setKey($cache_key, $default_space); 122 + } 123 + 124 + return $default_space; 125 + } 126 + 127 + public static function getViewerSpaces(PhabricatorUser $viewer) { 128 + $spaces = self::getAllSpaces(); 129 + 130 + $result = array(); 131 + foreach ($spaces as $key => $space) { 132 + $can_see = PhabricatorPolicyFilter::hasCapability( 133 + $viewer, 134 + $space, 135 + PhabricatorPolicyCapability::CAN_VIEW); 136 + if ($can_see) { 137 + $result[$key] = $space; 138 + } 139 + } 140 + 141 + return $result; 75 142 } 76 143 77 144 }
+162
src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
··· 10 10 * @task paging Paging 11 11 * @task order Result Ordering 12 12 * @task edgelogic Working with Edge Logic 13 + * @task spaces Working with Spaces 13 14 */ 14 15 abstract class PhabricatorCursorPagedPolicyAwareQuery 15 16 extends PhabricatorPolicyAwareQuery { ··· 23 24 private $builtinOrder; 24 25 private $edgeLogicConstraints = array(); 25 26 private $edgeLogicConstraintsAreValid = false; 27 + private $spacePHIDs; 26 28 27 29 protected function getPageCursors(array $page) { 28 30 return array( ··· 247 249 $where = array(); 248 250 $where[] = $this->buildPagingClause($conn); 249 251 $where[] = $this->buildEdgeLogicWhereClause($conn); 252 + $where[] = $this->buildSpacesWhereClause($conn); 250 253 return $where; 251 254 } 252 255 ··· 1610 1613 return $this; 1611 1614 } 1612 1615 1616 + 1617 + /* -( Spaces )------------------------------------------------------------- */ 1618 + 1619 + 1620 + /** 1621 + * Constrain the query to return results from only specific Spaces. 1622 + * 1623 + * Pass a list of Space PHIDs, or `null` to represent the default space. Only 1624 + * results in those Spaces will be returned. 1625 + * 1626 + * Queries are always constrained to include only results from spaces the 1627 + * viewer has access to. 1628 + * 1629 + * @param list<phid|null> 1630 + * @task spaces 1631 + */ 1632 + public function withSpacePHIDs(array $space_phids) { 1633 + $object = $this->newResultObject(); 1634 + 1635 + if (!$object) { 1636 + throw new Exception( 1637 + pht( 1638 + 'This query (of class "%s") does not implement newResultObject(), '. 1639 + 'but must implement this method to enable support for Spaces.', 1640 + get_class($this))); 1641 + } 1642 + 1643 + if (!($object instanceof PhabricatorSpacesInterface)) { 1644 + throw new Exception( 1645 + pht( 1646 + 'This query (of class "%s") returned an object of class "%s" from '. 1647 + 'getNewResultObject(), but it does not implement the required '. 1648 + 'interface ("%s"). Objects must implement this interface to enable '. 1649 + 'Spaces support.', 1650 + get_class($this), 1651 + get_class($object), 1652 + 'PhabricatorSpacesInterface')); 1653 + } 1654 + 1655 + $this->spacePHIDs = $space_phids; 1656 + 1657 + return $this; 1658 + } 1659 + 1660 + 1661 + /** 1662 + * Constrain the query to include only results in valid Spaces. 1663 + * 1664 + * This method builds part of a WHERE clause which considers the spaces the 1665 + * viewer has access to see with any explicit constraint on spaces added by 1666 + * @{method:withSpacePHIDs}. 1667 + * 1668 + * @param AphrontDatabaseConnection Database connection. 1669 + * @return string Part of a WHERE clause. 1670 + * @task spaces 1671 + */ 1672 + private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) { 1673 + $object = $this->newResultObject(); 1674 + if (!$object) { 1675 + return null; 1676 + } 1677 + 1678 + if (!($object instanceof PhabricatorSpacesInterface)) { 1679 + return null; 1680 + } 1681 + 1682 + $viewer = $this->getViewer(); 1683 + 1684 + $space_phids = array(); 1685 + $include_null = false; 1686 + 1687 + $all = PhabricatorSpacesNamespaceQuery::getAllSpaces(); 1688 + if (!$all) { 1689 + // If there are no spaces at all, implicitly give the viewer access to 1690 + // the default space. 1691 + $include_null = true; 1692 + } else { 1693 + // Otherwise, give them access to the spaces they have permission to 1694 + // see. 1695 + $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces( 1696 + $viewer); 1697 + foreach ($viewer_spaces as $viewer_space) { 1698 + $phid = $viewer_space->getPHID(); 1699 + $space_phids[$phid] = $phid; 1700 + if ($viewer_space->getIsDefaultNamespace()) { 1701 + $include_null = true; 1702 + } 1703 + } 1704 + } 1705 + 1706 + // If we have additional explicit constraints, evaluate them now. 1707 + if ($this->spacePHIDs !== null) { 1708 + $explicit = array(); 1709 + $explicit_null = false; 1710 + foreach ($this->spacePHIDs as $phid) { 1711 + if ($phid === null) { 1712 + $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); 1713 + } else { 1714 + $space = idx($all, $phid); 1715 + } 1716 + 1717 + if ($space) { 1718 + $phid = $space->getPHID(); 1719 + $explicit[$phid] = $phid; 1720 + if ($space->getIsDefaultNamespace()) { 1721 + $explicit_null = true; 1722 + } 1723 + } 1724 + } 1725 + 1726 + // If the viewer can see the default space but it isn't on the explicit 1727 + // list of spaces to query, don't match it. 1728 + if ($include_null && !$explicit_null) { 1729 + $include_null = false; 1730 + } 1731 + 1732 + // Include only the spaces common to the viewer and the constraints. 1733 + $space_phids = array_intersect_key($space_phids, $explicit); 1734 + } 1735 + 1736 + if (!$space_phids && !$include_null) { 1737 + if ($this->spacePHIDs === null) { 1738 + throw new PhabricatorEmptyQueryException( 1739 + pht('You do not have access to any spaces.')); 1740 + } else { 1741 + throw new PhabricatorEmptyQueryException( 1742 + pht( 1743 + 'You do not have access to any of the spaces this query '. 1744 + 'is constrained to.')); 1745 + } 1746 + } 1747 + 1748 + $alias = $this->getPrimaryTableAlias(); 1749 + if ($alias) { 1750 + $col = qsprintf($conn, '%T.spacePHID', $alias); 1751 + } else { 1752 + $col = 'spacePHID'; 1753 + } 1754 + 1755 + if ($space_phids && $include_null) { 1756 + return qsprintf( 1757 + $conn, 1758 + '(%Q IN (%Ls) OR %Q IS NULL)', 1759 + $col, 1760 + $space_phids, 1761 + $col); 1762 + } else if ($space_phids) { 1763 + return qsprintf( 1764 + $conn, 1765 + '%Q IN (%Ls)', 1766 + $col, 1767 + $space_phids); 1768 + } else { 1769 + return qsprintf( 1770 + $conn, 1771 + '%Q IS NULL', 1772 + $col); 1773 + } 1774 + } 1613 1775 1614 1776 }