@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

Implement query and policy rules for subprojects

Summary:
Ref T10010. This implements technical groundwork for subprojects. Specifically, it implements policy rules like Phriction:

- to see a project, you must be able to see all of its parents (and the project itself).
- you can edit a project if you can edit any of its parents (or the project itself).

To facilitiate this, we load all project ancestors when querying projects so we can do the view/edit checks.

This does NOT yet implement:

- proper membership rules for these projects (up next);
- any kind of UI to let users create subprojects.

Test Plan:
- Added unit tests.
- Executed unit tests.
- Browsed Projects (no change in behavior is expected).

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T10010

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

+405 -15
+2
resources/sql/autopatches/20151223.proj.01.paths.sql
··· 1 + ALTER TABLE {$NAMESPACE}_project.project 2 + ADD projectPath VARBINARY(64) NOT NULL;
+2
resources/sql/autopatches/20151223.proj.02.depths.sql
··· 1 + ALTER TABLE {$NAMESPACE}_project.project 2 + ADD projectDepth INT UNSIGNED NOT NULL;
+2
resources/sql/autopatches/20151223.proj.03.pathkey.sql
··· 1 + ALTER TABLE {$NAMESPACE}_project.project 2 + ADD KEY `key_path` (projectPath, projectDepth);
+1
src/__phutil_library_map__.php
··· 7131 7131 'PhabricatorApplicationTransactionInterface', 7132 7132 'PhabricatorFlaggableInterface', 7133 7133 'PhabricatorPolicyInterface', 7134 + 'PhabricatorExtendedPolicyInterface', 7134 7135 'PhabricatorSubscribableInterface', 7135 7136 'PhabricatorCustomFieldInterface', 7136 7137 'PhabricatorDestructibleInterface',
+1
src/applications/config/schema/PhabricatorConfigSchemaSpec.php
··· 321 321 break; 322 322 case 'phid': 323 323 case 'policy'; 324 + case 'hashpath64': 324 325 $column_type = 'varbinary(64)'; 325 326 break; 326 327 case 'bytes64':
+100 -9
src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
··· 113 113 $this->assertTrue($caught instanceof Exception); 114 114 } 115 115 116 + public function testParentProject() { 117 + $user = $this->createUser(); 118 + $user->save(); 119 + 120 + $parent = $this->createProject($user); 121 + $child = $this->createProject($user, $parent); 122 + 123 + $this->assertTrue(true); 124 + 125 + $child = $this->refreshProject($child, $user); 126 + 127 + $this->assertEqual( 128 + $parent->getPHID(), 129 + $child->getParentProject()->getPHID()); 130 + 131 + $this->assertEqual(1, (int)$child->getProjectDepth()); 132 + 133 + $this->assertFalse( 134 + $child->isUserMember($user->getPHID())); 135 + 136 + $this->assertFalse( 137 + $child->getParentProject()->isUserMember($user->getPHID())); 138 + 139 + $this->joinProject($child, $user); 140 + 141 + $child = $this->refreshProject($child, $user); 142 + 143 + $this->assertTrue( 144 + $child->isUserMember($user->getPHID())); 145 + 146 + $this->assertTrue( 147 + $child->getParentProject()->isUserMember($user->getPHID())); 148 + 149 + 150 + // Test that hiding a parent hides the child. 151 + 152 + $user2 = $this->createUser(); 153 + $user2->save(); 154 + 155 + // Second user can see the project for now. 156 + $this->assertTrue((bool)$this->refreshProject($child, $user2)); 157 + 158 + // Hide the parent. 159 + $this->setViewPolicy($parent, $user, $user->getPHID()); 160 + 161 + // First user (who can see the parent because they are a member of 162 + // the child) can see the project. 163 + $this->assertTrue((bool)$this->refreshProject($child, $user)); 164 + 165 + // Second user can not, because they can't see the parent. 166 + $this->assertFalse((bool)$this->refreshProject($child, $user2)); 167 + } 168 + 116 169 private function attemptProjectEdit( 117 170 PhabricatorProject $proj, 118 171 PhabricatorUser $user, ··· 126 179 $xaction->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME); 127 180 $xaction->setNewValue($new_name); 128 181 129 - $editor = new PhabricatorProjectTransactionEditor(); 130 - $editor->setActor($user); 131 - $editor->setContentSource(PhabricatorContentSource::newConsoleSource()); 132 - $editor->applyTransactions($proj, array($xaction)); 182 + $this->applyTransactions($proj, $user, array($xaction)); 133 183 134 184 return true; 135 185 } ··· 270 320 } 271 321 } 272 322 273 - private function createProject(PhabricatorUser $user) { 323 + private function createProject( 324 + PhabricatorUser $user, 325 + PhabricatorProject $parent = null) { 326 + 274 327 $project = PhabricatorProject::initializeNewProject($user); 275 - $project->setName(pht('Test Project %d', mt_rand())); 276 - $project->save(); 328 + 329 + $name = pht('Test Project %d', mt_rand()); 330 + 331 + $xactions = array(); 332 + 333 + $xactions[] = id(new PhabricatorProjectTransaction()) 334 + ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) 335 + ->setNewValue($name); 336 + 337 + if ($parent) { 338 + $xactions[] = id(new PhabricatorProjectTransaction()) 339 + ->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT) 340 + ->setNewValue($parent->getPHID()); 341 + } 342 + 343 + $this->applyTransactions($project, $user, $xactions); 344 + 345 + return $project; 346 + } 347 + 348 + private function setViewPolicy( 349 + PhabricatorProject $project, 350 + PhabricatorUser $user, 351 + $policy) { 352 + 353 + $xactions = array(); 354 + 355 + $xactions[] = id(new PhabricatorProjectTransaction()) 356 + ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) 357 + ->setNewValue($policy); 358 + 359 + $this->applyTransactions($project, $user, $xactions); 277 360 278 361 return $project; 279 362 } ··· 359 442 ->setMetadataValue('edge:type', $edge_type) 360 443 ->setNewValue($spec); 361 444 445 + $this->applyTransactions($project, $user, $xactions); 446 + 447 + return $project; 448 + } 449 + 450 + private function applyTransactions( 451 + PhabricatorProject $project, 452 + PhabricatorUser $user, 453 + array $xactions) { 454 + 362 455 $editor = id(new PhabricatorProjectTransactionEditor()) 363 456 ->setActor($user) 364 457 ->setContentSource(PhabricatorContentSource::newConsoleSource()) 365 458 ->setContinueOnNoEffect(true) 366 459 ->applyTransactions($project, $xactions); 367 - 368 - return $project; 369 460 } 370 461 371 462
+78
src/applications/project/editor/PhabricatorProjectTransactionEditor.php
··· 26 26 $types[] = PhabricatorProjectTransaction::TYPE_ICON; 27 27 $types[] = PhabricatorProjectTransaction::TYPE_COLOR; 28 28 $types[] = PhabricatorProjectTransaction::TYPE_LOCKED; 29 + $types[] = PhabricatorProjectTransaction::TYPE_PARENT; 29 30 30 31 return $types; 31 32 } ··· 52 53 return $object->getColor(); 53 54 case PhabricatorProjectTransaction::TYPE_LOCKED: 54 55 return (int)$object->getIsMembershipLocked(); 56 + case PhabricatorProjectTransaction::TYPE_PARENT: 57 + return null; 55 58 } 56 59 57 60 return parent::getCustomTransactionOldValue($object, $xaction); ··· 69 72 case PhabricatorProjectTransaction::TYPE_ICON: 70 73 case PhabricatorProjectTransaction::TYPE_COLOR: 71 74 case PhabricatorProjectTransaction::TYPE_LOCKED: 75 + case PhabricatorProjectTransaction::TYPE_PARENT: 72 76 return $xaction->getNewValue(); 73 77 } 74 78 ··· 102 106 case PhabricatorProjectTransaction::TYPE_LOCKED: 103 107 $object->setIsMembershipLocked($xaction->getNewValue()); 104 108 return; 109 + case PhabricatorProjectTransaction::TYPE_PARENT: 110 + $object->setParentProjectPHID($xaction->getNewValue()); 111 + return; 105 112 } 106 113 107 114 return parent::applyCustomInternalTransaction($object, $xaction); ··· 153 160 case PhabricatorProjectTransaction::TYPE_ICON: 154 161 case PhabricatorProjectTransaction::TYPE_COLOR: 155 162 case PhabricatorProjectTransaction::TYPE_LOCKED: 163 + case PhabricatorProjectTransaction::TYPE_PARENT: 156 164 return; 157 165 } 158 166 ··· 324 332 } 325 333 326 334 break; 335 + case PhabricatorProjectTransaction::TYPE_PARENT: 336 + if (!$xactions) { 337 + break; 338 + } 327 339 340 + $xaction = last($xactions); 341 + 342 + if (!$this->getIsNewObject()) { 343 + $errors[] = new PhabricatorApplicationTransactionValidationError( 344 + $type, 345 + pht('Invalid'), 346 + pht( 347 + 'You can only set a parent project when creating a project '. 348 + 'for the first time.'), 349 + $xaction); 350 + break; 351 + } 352 + 353 + $parent_phid = $xaction->getNewValue(); 354 + 355 + $projects = id(new PhabricatorProjectQuery()) 356 + ->setViewer($this->requireActor()) 357 + ->withPHIDs(array($parent_phid)) 358 + ->requireCapabilities( 359 + array( 360 + PhabricatorPolicyCapability::CAN_VIEW, 361 + PhabricatorPolicyCapability::CAN_EDIT, 362 + )) 363 + ->execute(); 364 + if (!$projects) { 365 + $errors[] = new PhabricatorApplicationTransactionValidationError( 366 + $type, 367 + pht('Invalid'), 368 + pht( 369 + 'Parent project PHID ("%s") must be the PHID of a valid, '. 370 + 'visible project which you have permission to edit.', 371 + $parent_phid), 372 + $xaction); 373 + break; 374 + } 375 + 376 + $project = head($projects); 377 + 378 + if ($project->isMilestone()) { 379 + $errors[] = new PhabricatorApplicationTransactionValidationError( 380 + $type, 381 + pht('Invalid'), 382 + pht( 383 + 'Parent project PHID ("%s") must not be a milestone. '. 384 + 'Milestones may not have subprojects.', 385 + $parent_phid), 386 + $xaction); 387 + break; 388 + } 389 + 390 + $limit = PhabricatorProject::getProjectDepthLimit(); 391 + if ($project->getProjectDepth() >= ($limit - 1)) { 392 + $errors[] = new PhabricatorApplicationTransactionValidationError( 393 + $type, 394 + pht('Invalid'), 395 + pht( 396 + 'You can not create a subproject under this parent because '. 397 + 'it would nest projects too deeply. The maximum nesting '. 398 + 'depth of projects is %s.', 399 + new PhutilNumber($limit)), 400 + $xaction); 401 + break; 402 + } 403 + 404 + $object->attachParentProject($project); 405 + break; 328 406 } 329 407 330 408 return $errors;
+117 -3
src/applications/project/query/PhabricatorProjectQuery.php
··· 130 130 } 131 131 132 132 protected function willFilterPage(array $projects) { 133 + $project_phids = array(); 134 + $ancestor_paths = array(); 135 + 136 + foreach ($projects as $project) { 137 + $project_phids[] = $project->getPHID(); 138 + 139 + foreach ($project->getAncestorProjectPaths() as $path) { 140 + $ancestor_paths[$path] = $path; 141 + } 142 + } 143 + 144 + if ($ancestor_paths) { 145 + $ancestors = id(new PhabricatorProject())->loadAllWhere( 146 + 'projectPath IN (%Ls)', 147 + $ancestor_paths); 148 + } else { 149 + $ancestors = array(); 150 + } 151 + 152 + $projects = $this->linkProjectGraph($projects, $ancestors); 153 + 133 154 $viewer_phid = $this->getViewer()->getPHID(); 134 155 $project_phids = mpull($projects, 'getPHID'); 135 156 ··· 154 175 155 176 $edge_query->execute(); 156 177 178 + $membership_projects = array(); 157 179 foreach ($projects as $project) { 158 180 $project_phid = $project->getPHID(); 159 181 ··· 161 183 array($project_phid), 162 184 array($member_type)); 163 185 164 - $project->setIsUserMember( 165 - $viewer_phid, 166 - in_array($viewer_phid, $member_phids)); 186 + if (in_array($viewer_phid, $member_phids)) { 187 + $membership_projects[$project_phid] = $project; 188 + } 167 189 168 190 if ($this->needMembers) { 169 191 $project->attachMemberPHIDs($member_phids); ··· 178 200 $viewer_phid, 179 201 in_array($viewer_phid, $watcher_phids)); 180 202 } 203 + } 204 + 205 + $all_graph = $this->getAllReachableAncestors($projects); 206 + $member_graph = $this->getAllReachableAncestors($membership_projects); 207 + 208 + foreach ($all_graph as $phid => $project) { 209 + $is_member = isset($member_graph[$phid]); 210 + $project->setIsUserMember($viewer_phid, $is_member); 181 211 } 182 212 183 213 return $projects; ··· 358 388 359 389 protected function getPrimaryTableAlias() { 360 390 return 'p'; 391 + } 392 + 393 + private function linkProjectGraph(array $projects, array $ancestors) { 394 + $ancestor_map = mpull($ancestors, null, 'getPHID'); 395 + $projects_map = mpull($projects, null, 'getPHID'); 396 + 397 + $all_map = $projects_map + $ancestor_map; 398 + 399 + $done = array(); 400 + foreach ($projects as $key => $project) { 401 + $seen = array($project->getPHID() => true); 402 + 403 + if (!$this->linkProject($project, $all_map, $done, $seen)) { 404 + $this->didRejectResult($project); 405 + unset($projects[$key]); 406 + continue; 407 + } 408 + 409 + foreach ($project->getAncestorProjects() as $ancestor) { 410 + $seen[$ancestor->getPHID()] = true; 411 + } 412 + } 413 + 414 + return $projects; 415 + } 416 + 417 + private function linkProject($project, array $all, array $done, array $seen) { 418 + $parent_phid = $project->getParentProjectPHID(); 419 + 420 + // This project has no parent, so just attach `null` and return. 421 + if (!$parent_phid) { 422 + $project->attachParentProject(null); 423 + return true; 424 + } 425 + 426 + // This project has a parent, but it failed to load. 427 + if (empty($all[$parent_phid])) { 428 + return false; 429 + } 430 + 431 + // Test for graph cycles. If we encounter one, we're going to hide the 432 + // entire cycle since we can't meaningfully resolve it. 433 + if (isset($seen[$parent_phid])) { 434 + return false; 435 + } 436 + 437 + $seen[$parent_phid] = true; 438 + 439 + $parent = $all[$parent_phid]; 440 + $project->attachParentProject($parent); 441 + 442 + if (!empty($done[$parent_phid])) { 443 + return true; 444 + } 445 + 446 + return $this->linkProject($parent, $all, $done, $seen); 447 + } 448 + 449 + private function getAllReachableAncestors(array $projects) { 450 + $ancestors = array(); 451 + 452 + $seen = mpull($projects, null, 'getPHID'); 453 + 454 + $stack = $projects; 455 + while ($stack) { 456 + $project = array_pop($stack); 457 + 458 + $phid = $project->getPHID(); 459 + $ancestors[$phid] = $project; 460 + 461 + $parent_phid = $project->getParentProjectPHID(); 462 + if (!$parent_phid) { 463 + continue; 464 + } 465 + 466 + if (isset($seen[$parent_phid])) { 467 + continue; 468 + } 469 + 470 + $seen[$parent_phid] = true; 471 + $stack[] = $project->getParentProject(); 472 + } 473 + 474 + return $ancestors; 361 475 } 362 476 363 477 }
+101 -3
src/applications/project/storage/PhabricatorProject.php
··· 5 5 PhabricatorApplicationTransactionInterface, 6 6 PhabricatorFlaggableInterface, 7 7 PhabricatorPolicyInterface, 8 + PhabricatorExtendedPolicyInterface, 8 9 PhabricatorSubscribableInterface, 9 10 PhabricatorCustomFieldInterface, 10 11 PhabricatorDestructibleInterface, ··· 29 30 protected $hasMilestones; 30 31 protected $hasSubprojects; 31 32 protected $milestoneNumber; 33 + 34 + protected $projectPath; 35 + protected $projectDepth; 32 36 33 37 private $memberPHIDs = self::ATTACHABLE; 34 38 private $watcherPHIDs = self::ATTACHABLE; ··· 69 73 ->attachSlugs(array()) 70 74 ->setHasWorkboard(0) 71 75 ->setHasMilestones(0) 72 - ->setHasSubprojects(0); 76 + ->setHasSubprojects(0) 77 + ->attachParentProject(null); 73 78 } 74 79 75 80 public function getCapabilities() { ··· 92 97 } 93 98 94 99 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { 100 + $can_edit = PhabricatorPolicyCapability::CAN_EDIT; 95 101 96 102 switch ($capability) { 97 103 case PhabricatorPolicyCapability::CAN_VIEW: ··· 101 107 } 102 108 break; 103 109 case PhabricatorPolicyCapability::CAN_EDIT: 110 + $parent = $this->getParentProject(); 111 + if ($parent) { 112 + $can_edit_parent = PhabricatorPolicyFilter::hasCapability( 113 + $viewer, 114 + $parent, 115 + $can_edit); 116 + if ($can_edit_parent) { 117 + return true; 118 + } 119 + } 104 120 break; 105 121 case PhabricatorPolicyCapability::CAN_JOIN: 106 - $can_edit = PhabricatorPolicyCapability::CAN_EDIT; 107 122 if (PhabricatorPolicyFilter::hasCapability($viewer, $this, $can_edit)) { 108 123 // Project editors can always join a project. 109 124 return true; ··· 115 130 } 116 131 117 132 public function describeAutomaticCapability($capability) { 133 + 134 + // TODO: Clarify the additional rules that parent and subprojects imply. 135 + 118 136 switch ($capability) { 119 137 case PhabricatorPolicyCapability::CAN_VIEW: 120 138 return pht('Members of a project can always view it.'); ··· 124 142 return null; 125 143 } 126 144 145 + public function getExtendedPolicy($capability, PhabricatorUser $viewer) { 146 + $extended = array(); 147 + 148 + switch ($capability) { 149 + case PhabricatorPolicyCapability::CAN_VIEW: 150 + $parent = $this->getParentProject(); 151 + if ($parent) { 152 + $extended[] = array( 153 + $parent, 154 + PhabricatorPolicyCapability::CAN_VIEW, 155 + ); 156 + } 157 + break; 158 + } 159 + 160 + return $extended; 161 + } 162 + 163 + 127 164 public function isUserMember($user_phid) { 128 165 if ($this->memberPHIDs !== self::ATTACHABLE) { 129 166 return in_array($user_phid, $this->memberPHIDs); ··· 157 194 'hasMilestones' => 'bool', 158 195 'hasSubprojects' => 'bool', 159 196 'milestoneNumber' => 'uint32?', 197 + 'projectPath' => 'hashpath64', 198 + 'projectDepth' => 'uint32', 160 199 ), 161 200 self::CONFIG_KEY_SCHEMA => array( 162 201 'key_phid' => null, ··· 181 220 'key_primaryslug' => array( 182 221 'columns' => array('primarySlug'), 183 222 'unique' => true, 223 + ), 224 + 'key_path' => array( 225 + 'columns' => array('projectPath', 'projectDepth'), 184 226 ), 185 227 ), 186 228 ) + parent::getConfiguration(); ··· 264 306 $this->setMailKey(Filesystem::readRandomCharacters(20)); 265 307 } 266 308 309 + if (!strlen($this->getPHID())) { 310 + $this->setPHID($this->generatePHID()); 311 + } 312 + 313 + $path = array(); 314 + $depth = 0; 315 + if ($this->parentProjectPHID) { 316 + $parent = $this->getParentProject(); 317 + $path[] = $parent->getProjectPath(); 318 + $depth = $parent->getProjectDepth() + 1; 319 + } 320 + $hash = PhabricatorHash::digestForIndex($this->getPHID()); 321 + $path[] = substr($hash, 0, 4); 322 + 323 + $path = implode('', $path); 324 + 325 + $limit = self::getProjectDepthLimit(); 326 + if (strlen($path) > ($limit * 4)) { 327 + throw new Exception( 328 + pht('Unable to save project: path length is too long.')); 329 + } 330 + 331 + $this->setProjectPath($path); 332 + $this->setProjectDepth($depth); 333 + 267 334 $this->openTransaction(); 268 335 $result = parent::save(); 269 336 $this->updateDatasourceTokens(); 270 337 $this->saveTransaction(); 271 338 272 339 return $result; 340 + } 341 + 342 + public static function getProjectDepthLimit() { 343 + // This is limited by how many path hashes we can fit in the path 344 + // column. 345 + return 16; 273 346 } 274 347 275 348 public function updateDatasourceTokens() { ··· 319 392 return $this->assertAttached($this->parentProject); 320 393 } 321 394 322 - public function attachParentProject(PhabricatorProject $project) { 395 + public function attachParentProject(PhabricatorProject $project = null) { 323 396 $this->parentProject = $project; 324 397 return $this; 398 + } 399 + 400 + public function getAncestorProjectPaths() { 401 + $parts = array(); 402 + 403 + $path = $this->getProjectPath(); 404 + $parent_length = (strlen($path) - 4); 405 + 406 + for ($ii = $parent_length; $ii >= 0; $ii -= 4) { 407 + $parts[] = substr($path, 0, $ii); 408 + } 409 + 410 + return $parts; 411 + } 412 + 413 + public function getAncestorProjects() { 414 + $ancestors = array(); 415 + 416 + $cursor = $this->getParentProject(); 417 + while ($cursor) { 418 + $ancestors[] = $cursor; 419 + $cursor = $cursor->getParentProject(); 420 + } 421 + 422 + return $ancestors; 325 423 } 326 424 327 425
+1
src/applications/project/storage/PhabricatorProjectTransaction.php
··· 10 10 const TYPE_ICON = 'project:icon'; 11 11 const TYPE_COLOR = 'project:color'; 12 12 const TYPE_LOCKED = 'project:locked'; 13 + const TYPE_PARENT = 'project:parent'; 13 14 14 15 // NOTE: This is deprecated, members are just a normal edge now. 15 16 const TYPE_MEMBERS = 'project:members';