@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
at recaptime-dev/main 786 lines 24 kB view raw
1<?php 2 3/** 4 * A @{class:PhabricatorQuery} which filters results according to visibility 5 * policies for the querying user. Broadly, this class allows you to implement 6 * a query that returns only objects the user is allowed to see. 7 * 8 * $results = id(new ExampleQuery()) 9 * ->setViewer($user) 10 * ->withConstraint($example) 11 * ->execute(); 12 * 13 * Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery}, 14 * not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a 15 * more practical interface for building usable queries against most object 16 * types. 17 * 18 * NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery}, 19 * offset paging with policy filtering is not efficient. All results must be 20 * loaded into the application and filtered here: skipping `N` rows via offset 21 * is an `O(N)` operation with a large constant. Prefer cursor-based paging 22 * with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far 23 * more efficiently in MySQL. 24 * 25 * @task config Query Configuration 26 * @task exec Executing Queries 27 * @task policyimpl Policy Query Implementation 28 * 29 * @template R of PhabricatorPolicyInterface 30 */ 31abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { 32 33 private $viewer; 34 private $parentQuery; 35 private $rawResultLimit; 36 private $capabilities; 37 private $workspace = array(); 38 private $inFlightPHIDs = array(); 39 private $policyFilteredPHIDs = array(); 40 41 /** 42 * Should we continue or throw an exception when a query result is filtered 43 * by policy rules? 44 * 45 * Values are `true` (raise exceptions), `false` (do not raise exceptions) 46 * and `null` (inherit from parent query, with no exceptions by default). 47 */ 48 private $raisePolicyExceptions; 49 private $isOverheated; 50 private $returnPartialResultsOnOverheat; 51 private $disableOverheating; 52 53 54/* -( Query Configuration )------------------------------------------------ */ 55 56 57 /** 58 * Set the viewer who is executing the query. Results will be filtered 59 * according to the viewer's capabilities. You must set a viewer to execute 60 * a policy query. 61 * 62 * @param PhabricatorUser $viewer The viewing user. 63 * @return $this 64 * @task config 65 */ 66 final public function setViewer(PhabricatorUser $viewer) { 67 $this->viewer = $viewer; 68 return $this; 69 } 70 71 72 /** 73 * Get the query's viewer. 74 * 75 * @return PhabricatorUser The viewing user. 76 * @task config 77 */ 78 final public function getViewer() { 79 return $this->viewer; 80 } 81 82 83 /** 84 * Set the parent query of this query. This is useful for nested queries so 85 * that configuration like whether or not to raise policy exceptions is 86 * seamlessly passed along to child queries. 87 * 88 * @return $this 89 * @task config 90 */ 91 final public function setParentQuery(PhabricatorPolicyAwareQuery $query) { 92 $this->parentQuery = $query; 93 return $this; 94 } 95 96 97 /** 98 * Get the parent query. See @{method:setParentQuery} for discussion. 99 * 100 * @return PhabricatorPolicyAwareQuery The parent query. 101 * @task config 102 */ 103 final public function getParentQuery() { 104 return $this->parentQuery; 105 } 106 107 108 /** 109 * Hook to configure whether this query should raise policy exceptions. 110 * 111 * @return $this 112 * @task config 113 */ 114 final public function setRaisePolicyExceptions($bool) { 115 $this->raisePolicyExceptions = $bool; 116 return $this; 117 } 118 119 120 /** 121 * @return bool 122 * @task config 123 */ 124 final public function shouldRaisePolicyExceptions() { 125 return (bool)$this->raisePolicyExceptions; 126 } 127 128 129 /** 130 * @task config 131 */ 132 final public function requireCapabilities(array $capabilities) { 133 $this->capabilities = $capabilities; 134 return $this; 135 } 136 137 final public function setReturnPartialResultsOnOverheat($bool) { 138 $this->returnPartialResultsOnOverheat = $bool; 139 return $this; 140 } 141 142 final public function setDisableOverheating($disable_overheating) { 143 $this->disableOverheating = $disable_overheating; 144 return $this; 145 } 146 147 148/* -( Query Execution )---------------------------------------------------- */ 149 150 151 /** 152 * Execute the query, expecting a single result. This method simplifies 153 * loading objects for detail pages or edit views. 154 * 155 * // Load one result by ID. 156 * $obj = id(new ExampleQuery()) 157 * ->setViewer($user) 158 * ->withIDs(array($id)) 159 * ->executeOne(); 160 * if (!$obj) { 161 * return new Aphront404Response(); 162 * } 163 * 164 * If zero results match the query, this method returns `null`. 165 * If one result matches the query, this method returns that result. 166 * 167 * If two or more results match the query, this method throws an exception. 168 * You should use this method only when the query constraints guarantee at 169 * most one match (e.g., selecting a specific ID or PHID). 170 * 171 * If one result matches the query but it is caught by the policy filter (for 172 * example, the user is trying to view or edit an object which exists but 173 * which they do not have permission to see) a policy exception is thrown. 174 * 175 * @return R|null Single result, or null. 176 * @task exec 177 */ 178 final public function executeOne() { 179 180 $this->setRaisePolicyExceptions(true); 181 try { 182 $results = $this->execute(); 183 } catch (Exception $ex) { 184 $this->setRaisePolicyExceptions(false); 185 throw $ex; 186 } 187 188 if (count($results) > 1) { 189 throw new Exception(pht('Expected a single result!')); 190 } 191 192 if (!$results) { 193 return null; 194 } 195 196 return head($results); 197 } 198 199 200 /** 201 * Execute the query, loading all visible results. 202 * 203 * @return R[] Result objects. 204 * @task exec 205 */ 206 final public function execute() { 207 if (!$this->viewer) { 208 throw new PhutilInvalidStateException('setViewer'); 209 } 210 211 $parent_query = $this->getParentQuery(); 212 if ($parent_query && ($this->raisePolicyExceptions === null)) { 213 $this->setRaisePolicyExceptions( 214 $parent_query->shouldRaisePolicyExceptions()); 215 } 216 217 $results = array(); 218 219 $filter = $this->getPolicyFilter(); 220 221 $offset = (int)$this->getOffset(); 222 $limit = (int)$this->getLimit(); 223 $count = 0; 224 225 if ($limit) { 226 $need = $offset + $limit; 227 } else { 228 $need = 0; 229 } 230 231 $this->willExecute(); 232 233 // If we examine and filter significantly more objects than the query 234 // limit, we stop early. This prevents us from looping through a huge 235 // number of records when the viewer can see few or none of them. See 236 // T11773 for some discussion. 237 $this->isOverheated = false; 238 239 // See T13386. If we are on an old offset-based paging workflow, we need 240 // to base the overheating limit on both the offset and limit. 241 $overheat_limit = $need * 10; 242 $total_seen = 0; 243 244 do { 245 if ($need) { 246 $this->rawResultLimit = min($need - $count, 1024); 247 } else { 248 $this->rawResultLimit = 0; 249 } 250 251 if ($this->canViewerUseQueryApplication()) { 252 try { 253 $page = $this->loadPage(); 254 } catch (PhabricatorEmptyQueryException $ex) { 255 $page = array(); 256 } 257 } else { 258 $page = array(); 259 } 260 261 $total_seen += count($page); 262 263 if ($page) { 264 $maybe_visible = $this->willFilterPage($page); 265 if ($maybe_visible) { 266 $maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible); 267 } 268 } else { 269 $maybe_visible = array(); 270 } 271 272 if ($this->shouldDisablePolicyFiltering()) { 273 $visible = $maybe_visible; 274 } else { 275 $visible = $filter->apply($maybe_visible); 276 277 $policy_filtered = array(); 278 foreach ($maybe_visible as $key => $object) { 279 if (empty($visible[$key])) { 280 $phid = $object->getPHID(); 281 if ($phid) { 282 $policy_filtered[$phid] = $phid; 283 } 284 } 285 } 286 $this->addPolicyFilteredPHIDs($policy_filtered); 287 } 288 289 if ($visible) { 290 $visible = $this->didFilterPage($visible); 291 } 292 293 $removed = array(); 294 foreach ($maybe_visible as $key => $object) { 295 if (empty($visible[$key])) { 296 $removed[$key] = $object; 297 } 298 } 299 300 $this->didFilterResults($removed); 301 302 // NOTE: We call "nextPage()" before checking if we've found enough 303 // results because we want to build the internal cursor object even 304 // if we don't need to execute another query: the internal cursor may 305 // be used by a parent query that is using this query to translate an 306 // external cursor into an internal cursor. 307 $this->nextPage($page); 308 309 foreach ($visible as $key => $result) { 310 ++$count; 311 312 // If we have an offset, we just ignore that many results and start 313 // storing them only once we've hit the offset. This reduces memory 314 // requirements for large offsets, compared to storing them all and 315 // slicing them away later. 316 if ($count > $offset) { 317 $results[$key] = $result; 318 } 319 320 if ($need && ($count >= $need)) { 321 // If we have all the rows we need, break out of the paging query. 322 break 2; 323 } 324 } 325 326 if (!$this->rawResultLimit) { 327 // If we don't have a load count, we loaded all the results. We do 328 // not need to load another page. 329 break; 330 } 331 332 if (count($page) < $this->rawResultLimit) { 333 // If we have a load count but the unfiltered results contained fewer 334 // objects, we know this was the last page of objects; we do not need 335 // to load another page because we can deduce it would be empty. 336 break; 337 } 338 339 if (!$this->disableOverheating) { 340 if ($overheat_limit && ($total_seen >= $overheat_limit)) { 341 $this->isOverheated = true; 342 343 if (!$this->returnPartialResultsOnOverheat) { 344 throw new Exception( 345 pht( 346 'Query (of class "%s") overheated: examined more than %s '. 347 'raw rows without finding %s visible objects.', 348 get_class($this), 349 new PhutilNumber($overheat_limit), 350 new PhutilNumber($need))); 351 } 352 353 break; 354 } 355 } 356 } while (true); 357 358 $results = $this->didLoadResults($results); 359 360 return $results; 361 } 362 363 private function getPolicyFilter() { 364 $filter = new PhabricatorPolicyFilter(); 365 $filter->setViewer($this->viewer); 366 $capabilities = $this->getRequiredCapabilities(); 367 $filter->requireCapabilities($capabilities); 368 $filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions()); 369 370 return $filter; 371 } 372 373 protected function getRequiredCapabilities() { 374 if ($this->capabilities) { 375 return $this->capabilities; 376 } 377 378 return array( 379 PhabricatorPolicyCapability::CAN_VIEW, 380 ); 381 } 382 383 protected function applyPolicyFilter(array $objects, array $capabilities) { 384 if ($this->shouldDisablePolicyFiltering()) { 385 return $objects; 386 } 387 $filter = $this->getPolicyFilter(); 388 $filter->requireCapabilities($capabilities); 389 return $filter->apply($objects); 390 } 391 392 protected function didRejectResult(PhabricatorPolicyInterface $object) { 393 // Some objects (like commits) may be rejected because related objects 394 // (like repositories) can not be loaded. In some cases, we may need these 395 // related objects to determine the object policy, so it's expected that 396 // we may occasionally be unable to determine the policy. 397 398 try { 399 $policy = $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW); 400 } catch (Exception $ex) { 401 $policy = null; 402 } 403 404 // Mark this object as filtered so handles can render "Restricted" instead 405 // of "Unknown". 406 $phid = $object->getPHID(); 407 $this->addPolicyFilteredPHIDs(array($phid => $phid)); 408 409 $this->getPolicyFilter()->rejectObject( 410 $object, 411 $policy, 412 PhabricatorPolicyCapability::CAN_VIEW); 413 } 414 415 public function addPolicyFilteredPHIDs(array $phids) { 416 $this->policyFilteredPHIDs += $phids; 417 if ($this->getParentQuery()) { 418 $this->getParentQuery()->addPolicyFilteredPHIDs($phids); 419 } 420 return $this; 421 } 422 423 424 public function getIsOverheated() { 425 if ($this->isOverheated === null) { 426 throw new PhutilInvalidStateException('execute'); 427 } 428 return $this->isOverheated; 429 } 430 431 432 /** 433 * Return a map of all object PHIDs which were loaded in the query but 434 * filtered out by policy constraints. This allows a caller to distinguish 435 * between objects which do not exist (or, at least, were filtered at the 436 * content level) and objects which exist but aren't visible. 437 * 438 * @return map<string, string> Map of object PHIDs which were filtered 439 * by policies. 440 * @task exec 441 */ 442 public function getPolicyFilteredPHIDs() { 443 return $this->policyFilteredPHIDs; 444 } 445 446 447/* -( Query Workspace )---------------------------------------------------- */ 448 449 450 /** 451 * Put a map of objects into the query workspace. Many queries perform 452 * subqueries, which can eventually end up loading the same objects more than 453 * once (often to perform policy checks). 454 * 455 * For example, loading a user may load the user's profile image, which might 456 * load the user object again in order to verify that the viewer has 457 * permission to see the file. 458 * 459 * The "query workspace" allows queries to load objects from elsewhere in a 460 * query block instead of refetching them. 461 * 462 * When using the query workspace, it's important to obey two rules: 463 * 464 * **Never put objects into the workspace which the viewer may not be able 465 * to see**. You need to apply all policy filtering //before// putting 466 * objects in the workspace. Otherwise, subqueries may read the objects and 467 * use them to permit access to content the user shouldn't be able to view. 468 * 469 * **Fully enrich objects pulled from the workspace.** After pulling objects 470 * from the workspace, you still need to load and attach any additional 471 * content the query requests. Otherwise, a query might return objects 472 * without requested content. 473 * 474 * Generally, you do not need to update the workspace yourself: it is 475 * automatically populated as a side effect of objects surviving policy 476 * filtering. 477 * 478 * @param array<PhabricatorPolicyInterface> $objects Objects to add to 479 * the query workspace. 480 * @return $this 481 * @task workspace 482 */ 483 public function putObjectsInWorkspace(array $objects) { 484 $parent = $this->getParentQuery(); 485 if ($parent) { 486 $parent->putObjectsInWorkspace($objects); 487 return $this; 488 } 489 490 assert_instances_of($objects, PhabricatorPolicyInterface::class); 491 492 $viewer_fragment = $this->getViewer()->getCacheFragment(); 493 494 // The workspace is scoped per viewer to prevent accidental contamination. 495 if (empty($this->workspace[$viewer_fragment])) { 496 $this->workspace[$viewer_fragment] = array(); 497 } 498 499 $this->workspace[$viewer_fragment] += $objects; 500 501 return $this; 502 } 503 504 505 /** 506 * Retrieve objects from the query workspace. For more discussion about the 507 * workspace mechanism, see @{method:putObjectsInWorkspace}. This method 508 * searches both the current query's workspace and the workspaces of parent 509 * queries. 510 * 511 * @param list<string> $phids List of PHIDs to retrieve. 512 * @return $this 513 * @task workspace 514 */ 515 public function getObjectsFromWorkspace(array $phids) { 516 $parent = $this->getParentQuery(); 517 if ($parent) { 518 return $parent->getObjectsFromWorkspace($phids); 519 } 520 521 $viewer_fragment = $this->getViewer()->getCacheFragment(); 522 523 $results = array(); 524 foreach ($phids as $key => $phid) { 525 if (isset($this->workspace[$viewer_fragment][$phid])) { 526 $results[$phid] = $this->workspace[$viewer_fragment][$phid]; 527 unset($phids[$key]); 528 } 529 } 530 531 return $results; 532 } 533 534 535 /** 536 * Mark PHIDs as in flight. 537 * 538 * PHIDs which are "in flight" are actively being queried for. Using this 539 * list can prevent infinite query loops by aborting queries which cycle. 540 * 541 * @param list<string> $phids List of PHIDs which are now in flight. 542 * @return $this 543 */ 544 public function putPHIDsInFlight(array $phids) { 545 foreach ($phids as $phid) { 546 $this->inFlightPHIDs[$phid] = $phid; 547 } 548 return $this; 549 } 550 551 552 /** 553 * Get PHIDs which are currently in flight. 554 * 555 * PHIDs which are "in flight" are actively being queried for. 556 * 557 * @return array<string, string> PHIDs currently in flight. 558 */ 559 public function getPHIDsInFlight() { 560 $results = $this->inFlightPHIDs; 561 if ($this->getParentQuery()) { 562 $results += $this->getParentQuery()->getPHIDsInFlight(); 563 } 564 return $results; 565 } 566 567 568/* -( Policy Query Implementation )---------------------------------------- */ 569 570 571 /** 572 * Get the number of results @{method:loadPage} should load. If the value is 573 * 0, @{method:loadPage} should load all available results. 574 * 575 * @return int The number of results to load, or 0 for all results. 576 * @task policyimpl 577 */ 578 final protected function getRawResultLimit() { 579 return $this->rawResultLimit; 580 } 581 582 583 /** 584 * Hook invoked before query execution. Generally, implementations should 585 * reset any internal cursors. 586 * 587 * @return void 588 * @task policyimpl 589 */ 590 protected function willExecute() { 591 return; 592 } 593 594 595 /** 596 * Load a raw page of results. Generally, implementations should load objects 597 * from the database. They should attempt to return the number of results 598 * hinted by @{method:getRawResultLimit}. 599 * 600 * @return R[] List of filterable policy objects. 601 * @task policyimpl 602 */ 603 abstract protected function loadPage(); 604 605 606 /** 607 * Update internal state so that the next call to @{method:loadPage} will 608 * return new results. Generally, you should adjust a cursor position based 609 * on the provided result page. 610 * 611 * @param R[] $page The current page of results. 612 * @return void 613 * @task policyimpl 614 */ 615 abstract protected function nextPage(array $page); 616 617 618 /** 619 * Hook for applying a page filter prior to the privacy filter. This allows 620 * you to drop some items from the result set without creating problems with 621 * pagination or cursor updates. You can also load and attach data which is 622 * required to perform policy filtering. 623 * 624 * Generally, you should load non-policy data and perform non-policy filtering 625 * later, in @{method:didFilterPage}. Strictly fewer objects will make it that 626 * far (so the program will load less data) and subqueries from that context 627 * can use the query workspace to further reduce query load. 628 * 629 * This method will only be called if data is available. Implementations 630 * do not need to handle the case of no results specially. 631 * 632 * @param R[] $page Results from `loadPage()`. 633 * @return R[] Objects for policy filtering. 634 * @task policyimpl 635 */ 636 protected function willFilterPage(array $page) { 637 return $page; 638 } 639 640 /** 641 * Hook for performing additional non-policy loading or filtering after an 642 * object has satisfied all policy checks. Generally, this means loading and 643 * attaching related data. 644 * 645 * Subqueries executed during this phase can use the query workspace, which 646 * may improve performance or make circular policies resolvable. Data which 647 * is not necessary for policy filtering should generally be loaded here. 648 * 649 * This callback can still filter objects (for example, if attachable data 650 * is discovered to not exist), but should not do so for policy reasons. 651 * 652 * This method will only be called if data is available. Implementations do 653 * not need to handle the case of no results specially. 654 * 655 * @param R[] $page Results from @{method:willFilterPage()}. 656 * @return R[] Objects after additional 657 * non-policy processing. 658 */ 659 protected function didFilterPage(array $page) { 660 return $page; 661 } 662 663 664 /** 665 * Hook for removing filtered results from alternate result sets. This 666 * hook will be called with any objects which were returned by the query but 667 * filtered for policy reasons. The query should remove them from any cached 668 * or partial result sets. 669 * 670 * @param R[] $results List of objects that should not be returned by 671 * alternate result mechanisms. 672 * @return void 673 * @task policyimpl 674 */ 675 protected function didFilterResults(array $results) { 676 return; 677 } 678 679 680 /** 681 * Hook for applying final adjustments before results are returned. This is 682 * used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results 683 * that are queried during reverse paging. 684 * 685 * @param R[] $results Query results. 686 * @return R[] Final results. 687 * @task policyimpl 688 */ 689 protected function didLoadResults(array $results) { 690 return $results; 691 } 692 693 694 /** 695 * Allows a subclass to disable policy filtering. This method is dangerous. 696 * It should be used only if the query loads data which has already been 697 * filtered (for example, because it wraps some other query which uses 698 * normal policy filtering). 699 * 700 * @return bool True to disable all policy filtering. 701 * @task policyimpl 702 */ 703 protected function shouldDisablePolicyFiltering() { 704 return false; 705 } 706 707 708 /** 709 * If this query belongs to an application, return the application class name 710 * here. This will prevent the query from returning results if the viewer can 711 * not access the application. 712 * 713 * If this query does not belong to an application, return `null`. 714 * 715 * @return string|null Application class name. 716 */ 717 abstract public function getQueryApplicationClass(); 718 719 720 /** 721 * Determine if the viewer has permission to use this query's application. 722 * For queries which aren't part of an application, this method always returns 723 * true. 724 * 725 * @return bool True if the viewer has application-level permission to 726 * execute the query. 727 */ 728 public function canViewerUseQueryApplication() { 729 $class = $this->getQueryApplicationClass(); 730 if (!$class) { 731 return true; 732 } 733 734 $viewer = $this->getViewer(); 735 return PhabricatorApplication::isClassInstalledForViewer($class, $viewer); 736 } 737 738 private function applyWillFilterPageExtensions(array $page) { 739 $bridges = array(); 740 foreach ($page as $key => $object) { 741 if ($object instanceof DoorkeeperBridgedObjectInterface) { 742 $bridges[$key] = $object; 743 } 744 } 745 746 if ($bridges) { 747 $external_phids = array(); 748 foreach ($bridges as $bridge) { 749 $external_phid = $bridge->getBridgedObjectPHID(); 750 if ($external_phid) { 751 $external_phids[$key] = $external_phid; 752 } 753 } 754 755 if ($external_phids) { 756 $external_objects = id(new DoorkeeperExternalObjectQuery()) 757 ->setViewer($this->getViewer()) 758 ->withPHIDs($external_phids) 759 ->execute(); 760 $external_objects = mpull($external_objects, null, 'getPHID'); 761 } else { 762 $external_objects = array(); 763 } 764 765 foreach ($bridges as $key => $bridge) { 766 $external_phid = idx($external_phids, $key); 767 if (!$external_phid) { 768 $bridge->attachBridgedObject(null); 769 continue; 770 } 771 772 $external_object = idx($external_objects, $external_phid); 773 if (!$external_object) { 774 $this->didRejectResult($bridge); 775 unset($page[$key]); 776 continue; 777 } 778 779 $bridge->attachBridgedObject($external_object); 780 } 781 } 782 783 return $page; 784 } 785 786}