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.
8 * $results = id(new ExampleQuery())
10 * ->withConstraint($example)
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
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.
25 * @task config Query Configuration
26 * @task exec Executing Queries
27 * @task policyimpl Policy Query Implementation
29 abstract class PhabricatorPolicyAwareQuery
extends PhabricatorOffsetPagedQuery
{
33 private $rawResultLimit;
34 private $capabilities;
35 private $workspace = array();
36 private $inFlightPHIDs = array();
37 private $policyFilteredPHIDs = array();
40 * Should we continue or throw an exception when a query result is filtered
43 * Values are `true` (raise exceptions), `false` (do not raise exceptions)
44 * and `null` (inherit from parent query, with no exceptions by default).
46 private $raisePolicyExceptions;
47 private $isOverheated;
48 private $returnPartialResultsOnOverheat;
49 private $disableOverheating;
52 /* -( Query Configuration )------------------------------------------------ */
56 * Set the viewer who is executing the query. Results will be filtered
57 * according to the viewer's capabilities. You must set a viewer to execute
60 * @param PhabricatorUser The viewing user.
64 final public function setViewer(PhabricatorUser
$viewer) {
65 $this->viewer
= $viewer;
71 * Get the query's viewer.
73 * @return PhabricatorUser The viewing user.
76 final public function getViewer() {
82 * Set the parent query of this query. This is useful for nested queries so
83 * that configuration like whether or not to raise policy exceptions is
84 * seamlessly passed along to child queries.
89 final public function setParentQuery(PhabricatorPolicyAwareQuery
$query) {
90 $this->parentQuery
= $query;
96 * Get the parent query. See @{method:setParentQuery} for discussion.
98 * @return PhabricatorPolicyAwareQuery The parent query.
101 final public function getParentQuery() {
102 return $this->parentQuery
;
107 * Hook to configure whether this query should raise policy exceptions.
112 final public function setRaisePolicyExceptions($bool) {
113 $this->raisePolicyExceptions
= $bool;
122 final public function shouldRaisePolicyExceptions() {
123 return (bool)$this->raisePolicyExceptions
;
130 final public function requireCapabilities(array $capabilities) {
131 $this->capabilities
= $capabilities;
135 final public function setReturnPartialResultsOnOverheat($bool) {
136 $this->returnPartialResultsOnOverheat
= $bool;
140 final public function setDisableOverheating($disable_overheating) {
141 $this->disableOverheating
= $disable_overheating;
146 /* -( Query Execution )---------------------------------------------------- */
150 * Execute the query, expecting a single result. This method simplifies
151 * loading objects for detail pages or edit views.
153 * // Load one result by ID.
154 * $obj = id(new ExampleQuery())
156 * ->withIDs(array($id))
159 * return new Aphront404Response();
162 * If zero results match the query, this method returns `null`.
163 * If one result matches the query, this method returns that result.
165 * If two or more results match the query, this method throws an exception.
166 * You should use this method only when the query constraints guarantee at
167 * most one match (e.g., selecting a specific ID or PHID).
169 * If one result matches the query but it is caught by the policy filter (for
170 * example, the user is trying to view or edit an object which exists but
171 * which they do not have permission to see) a policy exception is thrown.
173 * @return mixed Single result, or null.
176 final public function executeOne() {
178 $this->setRaisePolicyExceptions(true);
180 $results = $this->execute();
181 } catch (Exception
$ex) {
182 $this->setRaisePolicyExceptions(false);
186 if (count($results) > 1) {
187 throw new Exception(pht('Expected a single result!'));
194 return head($results);
199 * Execute the query, loading all visible results.
201 * @return list<PhabricatorPolicyInterface> Result objects.
204 final public function execute() {
205 if (!$this->viewer
) {
206 throw new PhutilInvalidStateException('setViewer');
209 $parent_query = $this->getParentQuery();
210 if ($parent_query && ($this->raisePolicyExceptions
=== null)) {
211 $this->setRaisePolicyExceptions(
212 $parent_query->shouldRaisePolicyExceptions());
217 $filter = $this->getPolicyFilter();
219 $offset = (int)$this->getOffset();
220 $limit = (int)$this->getLimit();
224 $need = $offset +
$limit;
229 $this->willExecute();
231 // If we examine and filter significantly more objects than the query
232 // limit, we stop early. This prevents us from looping through a huge
233 // number of records when the viewer can see few or none of them. See
234 // T11773 for some discussion.
235 $this->isOverheated
= false;
237 // See T13386. If we are on an old offset-based paging workflow, we need
238 // to base the overheating limit on both the offset and limit.
239 $overheat_limit = $need * 10;
244 $this->rawResultLimit
= min($need - $count, 1024);
246 $this->rawResultLimit
= 0;
249 if ($this->canViewerUseQueryApplication()) {
251 $page = $this->loadPage();
252 } catch (PhabricatorEmptyQueryException
$ex) {
259 $total_seen +
= count($page);
262 $maybe_visible = $this->willFilterPage($page);
263 if ($maybe_visible) {
264 $maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible);
267 $maybe_visible = array();
270 if ($this->shouldDisablePolicyFiltering()) {
271 $visible = $maybe_visible;
273 $visible = $filter->apply($maybe_visible);
275 $policy_filtered = array();
276 foreach ($maybe_visible as $key => $object) {
277 if (empty($visible[$key])) {
278 $phid = $object->getPHID();
280 $policy_filtered[$phid] = $phid;
284 $this->addPolicyFilteredPHIDs($policy_filtered);
288 $visible = $this->didFilterPage($visible);
292 foreach ($maybe_visible as $key => $object) {
293 if (empty($visible[$key])) {
294 $removed[$key] = $object;
298 $this->didFilterResults($removed);
300 // NOTE: We call "nextPage()" before checking if we've found enough
301 // results because we want to build the internal cursor object even
302 // if we don't need to execute another query: the internal cursor may
303 // be used by a parent query that is using this query to translate an
304 // external cursor into an internal cursor.
305 $this->nextPage($page);
307 foreach ($visible as $key => $result) {
310 // If we have an offset, we just ignore that many results and start
311 // storing them only once we've hit the offset. This reduces memory
312 // requirements for large offsets, compared to storing them all and
313 // slicing them away later.
314 if ($count > $offset) {
315 $results[$key] = $result;
318 if ($need && ($count >= $need)) {
319 // If we have all the rows we need, break out of the paging query.
324 if (!$this->rawResultLimit
) {
325 // If we don't have a load count, we loaded all the results. We do
326 // not need to load another page.
330 if (count($page) < $this->rawResultLimit
) {
331 // If we have a load count but the unfiltered results contained fewer
332 // objects, we know this was the last page of objects; we do not need
333 // to load another page because we can deduce it would be empty.
337 if (!$this->disableOverheating
) {
338 if ($overheat_limit && ($total_seen >= $overheat_limit)) {
339 $this->isOverheated
= true;
341 if (!$this->returnPartialResultsOnOverheat
) {
344 'Query (of class "%s") overheated: examined more than %s '.
345 'raw rows without finding %s visible objects.',
347 new PhutilNumber($overheat_limit),
348 new PhutilNumber($need)));
356 $results = $this->didLoadResults($results);
361 private function getPolicyFilter() {
362 $filter = new PhabricatorPolicyFilter();
363 $filter->setViewer($this->viewer
);
364 $capabilities = $this->getRequiredCapabilities();
365 $filter->requireCapabilities($capabilities);
366 $filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions());
371 protected function getRequiredCapabilities() {
372 if ($this->capabilities
) {
373 return $this->capabilities
;
377 PhabricatorPolicyCapability
::CAN_VIEW
,
381 protected function applyPolicyFilter(array $objects, array $capabilities) {
382 if ($this->shouldDisablePolicyFiltering()) {
385 $filter = $this->getPolicyFilter();
386 $filter->requireCapabilities($capabilities);
387 return $filter->apply($objects);
390 protected function didRejectResult(PhabricatorPolicyInterface
$object) {
391 // Some objects (like commits) may be rejected because related objects
392 // (like repositories) can not be loaded. In some cases, we may need these
393 // related objects to determine the object policy, so it's expected that
394 // we may occasionally be unable to determine the policy.
397 $policy = $object->getPolicy(PhabricatorPolicyCapability
::CAN_VIEW
);
398 } catch (Exception
$ex) {
402 // Mark this object as filtered so handles can render "Restricted" instead
404 $phid = $object->getPHID();
405 $this->addPolicyFilteredPHIDs(array($phid => $phid));
407 $this->getPolicyFilter()->rejectObject(
410 PhabricatorPolicyCapability
::CAN_VIEW
);
413 public function addPolicyFilteredPHIDs(array $phids) {
414 $this->policyFilteredPHIDs +
= $phids;
415 if ($this->getParentQuery()) {
416 $this->getParentQuery()->addPolicyFilteredPHIDs($phids);
422 public function getIsOverheated() {
423 if ($this->isOverheated
=== null) {
424 throw new PhutilInvalidStateException('execute');
426 return $this->isOverheated
;
431 * Return a map of all object PHIDs which were loaded in the query but
432 * filtered out by policy constraints. This allows a caller to distinguish
433 * between objects which do not exist (or, at least, were filtered at the
434 * content level) and objects which exist but aren't visible.
436 * @return map<phid, phid> Map of object PHIDs which were filtered
440 public function getPolicyFilteredPHIDs() {
441 return $this->policyFilteredPHIDs
;
445 /* -( Query Workspace )---------------------------------------------------- */
449 * Put a map of objects into the query workspace. Many queries perform
450 * subqueries, which can eventually end up loading the same objects more than
451 * once (often to perform policy checks).
453 * For example, loading a user may load the user's profile image, which might
454 * load the user object again in order to verify that the viewer has
455 * permission to see the file.
457 * The "query workspace" allows queries to load objects from elsewhere in a
458 * query block instead of refetching them.
460 * When using the query workspace, it's important to obey two rules:
462 * **Never put objects into the workspace which the viewer may not be able
463 * to see**. You need to apply all policy filtering //before// putting
464 * objects in the workspace. Otherwise, subqueries may read the objects and
465 * use them to permit access to content the user shouldn't be able to view.
467 * **Fully enrich objects pulled from the workspace.** After pulling objects
468 * from the workspace, you still need to load and attach any additional
469 * content the query requests. Otherwise, a query might return objects
470 * without requested content.
472 * Generally, you do not need to update the workspace yourself: it is
473 * automatically populated as a side effect of objects surviving policy
476 * @param map<phid, PhabricatorPolicyInterface> Objects to add to the query
481 public function putObjectsInWorkspace(array $objects) {
482 $parent = $this->getParentQuery();
484 $parent->putObjectsInWorkspace($objects);
488 assert_instances_of($objects, 'PhabricatorPolicyInterface');
490 $viewer_fragment = $this->getViewer()->getCacheFragment();
492 // The workspace is scoped per viewer to prevent accidental contamination.
493 if (empty($this->workspace
[$viewer_fragment])) {
494 $this->workspace
[$viewer_fragment] = array();
497 $this->workspace
[$viewer_fragment] +
= $objects;
504 * Retrieve objects from the query workspace. For more discussion about the
505 * workspace mechanism, see @{method:putObjectsInWorkspace}. This method
506 * searches both the current query's workspace and the workspaces of parent
509 * @param list<phid> List of PHIDs to retrieve.
513 public function getObjectsFromWorkspace(array $phids) {
514 $parent = $this->getParentQuery();
516 return $parent->getObjectsFromWorkspace($phids);
519 $viewer_fragment = $this->getViewer()->getCacheFragment();
522 foreach ($phids as $key => $phid) {
523 if (isset($this->workspace
[$viewer_fragment][$phid])) {
524 $results[$phid] = $this->workspace
[$viewer_fragment][$phid];
534 * Mark PHIDs as in flight.
536 * PHIDs which are "in flight" are actively being queried for. Using this
537 * list can prevent infinite query loops by aborting queries which cycle.
539 * @param list<phid> List of PHIDs which are now in flight.
542 public function putPHIDsInFlight(array $phids) {
543 foreach ($phids as $phid) {
544 $this->inFlightPHIDs
[$phid] = $phid;
551 * Get PHIDs which are currently in flight.
553 * PHIDs which are "in flight" are actively being queried for.
555 * @return map<phid, phid> PHIDs currently in flight.
557 public function getPHIDsInFlight() {
558 $results = $this->inFlightPHIDs
;
559 if ($this->getParentQuery()) {
560 $results +
= $this->getParentQuery()->getPHIDsInFlight();
566 /* -( Policy Query Implementation )---------------------------------------- */
570 * Get the number of results @{method:loadPage} should load. If the value is
571 * 0, @{method:loadPage} should load all available results.
573 * @return int The number of results to load, or 0 for all results.
576 final protected function getRawResultLimit() {
577 return $this->rawResultLimit
;
582 * Hook invoked before query execution. Generally, implementations should
583 * reset any internal cursors.
588 protected function willExecute() {
594 * Load a raw page of results. Generally, implementations should load objects
595 * from the database. They should attempt to return the number of results
596 * hinted by @{method:getRawResultLimit}.
598 * @return list<PhabricatorPolicyInterface> List of filterable policy objects.
601 abstract protected function loadPage();
605 * Update internal state so that the next call to @{method:loadPage} will
606 * return new results. Generally, you should adjust a cursor position based
607 * on the provided result page.
609 * @param list<PhabricatorPolicyInterface> The current page of results.
613 abstract protected function nextPage(array $page);
617 * Hook for applying a page filter prior to the privacy filter. This allows
618 * you to drop some items from the result set without creating problems with
619 * pagination or cursor updates. You can also load and attach data which is
620 * required to perform policy filtering.
622 * Generally, you should load non-policy data and perform non-policy filtering
623 * later, in @{method:didFilterPage}. Strictly fewer objects will make it that
624 * far (so the program will load less data) and subqueries from that context
625 * can use the query workspace to further reduce query load.
627 * This method will only be called if data is available. Implementations
628 * do not need to handle the case of no results specially.
630 * @param list<wild> Results from `loadPage()`.
631 * @return list<PhabricatorPolicyInterface> Objects for policy filtering.
634 protected function willFilterPage(array $page) {
639 * Hook for performing additional non-policy loading or filtering after an
640 * object has satisfied all policy checks. Generally, this means loading and
641 * attaching related data.
643 * Subqueries executed during this phase can use the query workspace, which
644 * may improve performance or make circular policies resolvable. Data which
645 * is not necessary for policy filtering should generally be loaded here.
647 * This callback can still filter objects (for example, if attachable data
648 * is discovered to not exist), but should not do so for policy reasons.
650 * This method will only be called if data is available. Implementations do
651 * not need to handle the case of no results specially.
653 * @param list<wild> Results from @{method:willFilterPage()}.
654 * @return list<PhabricatorPolicyInterface> Objects after additional
655 * non-policy processing.
657 protected function didFilterPage(array $page) {
663 * Hook for removing filtered results from alternate result sets. This
664 * hook will be called with any objects which were returned by the query but
665 * filtered for policy reasons. The query should remove them from any cached
666 * or partial result sets.
668 * @param list<wild> List of objects that should not be returned by alternate
673 protected function didFilterResults(array $results) {
679 * Hook for applying final adjustments before results are returned. This is
680 * used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results
681 * that are queried during reverse paging.
683 * @param list<PhabricatorPolicyInterface> Query results.
684 * @return list<PhabricatorPolicyInterface> Final results.
687 protected function didLoadResults(array $results) {
693 * Allows a subclass to disable policy filtering. This method is dangerous.
694 * It should be used only if the query loads data which has already been
695 * filtered (for example, because it wraps some other query which uses
696 * normal policy filtering).
698 * @return bool True to disable all policy filtering.
701 protected function shouldDisablePolicyFiltering() {
707 * If this query belongs to an application, return the application class name
708 * here. This will prevent the query from returning results if the viewer can
709 * not access the application.
711 * If this query does not belong to an application, return `null`.
713 * @return string|null Application class name.
715 abstract public function getQueryApplicationClass();
719 * Determine if the viewer has permission to use this query's application.
720 * For queries which aren't part of an application, this method always returns
723 * @return bool True if the viewer has application-level permission to
726 public function canViewerUseQueryApplication() {
727 $class = $this->getQueryApplicationClass();
732 $viewer = $this->getViewer();
733 return PhabricatorApplication
::isClassInstalledForViewer($class, $viewer);
736 private function applyWillFilterPageExtensions(array $page) {
738 foreach ($page as $key => $object) {
739 if ($object instanceof DoorkeeperBridgedObjectInterface
) {
740 $bridges[$key] = $object;
745 $external_phids = array();
746 foreach ($bridges as $bridge) {
747 $external_phid = $bridge->getBridgedObjectPHID();
748 if ($external_phid) {
749 $external_phids[$key] = $external_phid;
753 if ($external_phids) {
754 $external_objects = id(new DoorkeeperExternalObjectQuery())
755 ->setViewer($this->getViewer())
756 ->withPHIDs($external_phids)
758 $external_objects = mpull($external_objects, null, 'getPHID');
760 $external_objects = array();
763 foreach ($bridges as $key => $bridge) {
764 $external_phid = idx($external_phids, $key);
765 if (!$external_phid) {
766 $bridge->attachBridgedObject(null);
770 $external_object = idx($external_objects, $external_phid);
771 if (!$external_object) {
772 $this->didRejectResult($bridge);
777 $bridge->attachBridgedObject($external_object);