@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<?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}