Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
12242 views
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
abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery {
30
31
private $viewer;
32
private $parentQuery;
33
private $rawResultLimit;
34
private $capabilities;
35
private $workspace = array();
36
private $inFlightPHIDs = array();
37
private $policyFilteredPHIDs = array();
38
39
/**
40
* Should we continue or throw an exception when a query result is filtered
41
* by policy rules?
42
*
43
* Values are `true` (raise exceptions), `false` (do not raise exceptions)
44
* and `null` (inherit from parent query, with no exceptions by default).
45
*/
46
private $raisePolicyExceptions;
47
private $isOverheated;
48
private $returnPartialResultsOnOverheat;
49
private $disableOverheating;
50
51
52
/* -( Query Configuration )------------------------------------------------ */
53
54
55
/**
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
58
* a policy query.
59
*
60
* @param PhabricatorUser The viewing user.
61
* @return this
62
* @task config
63
*/
64
final public function setViewer(PhabricatorUser $viewer) {
65
$this->viewer = $viewer;
66
return $this;
67
}
68
69
70
/**
71
* Get the query's viewer.
72
*
73
* @return PhabricatorUser The viewing user.
74
* @task config
75
*/
76
final public function getViewer() {
77
return $this->viewer;
78
}
79
80
81
/**
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.
85
*
86
* @return this
87
* @task config
88
*/
89
final public function setParentQuery(PhabricatorPolicyAwareQuery $query) {
90
$this->parentQuery = $query;
91
return $this;
92
}
93
94
95
/**
96
* Get the parent query. See @{method:setParentQuery} for discussion.
97
*
98
* @return PhabricatorPolicyAwareQuery The parent query.
99
* @task config
100
*/
101
final public function getParentQuery() {
102
return $this->parentQuery;
103
}
104
105
106
/**
107
* Hook to configure whether this query should raise policy exceptions.
108
*
109
* @return this
110
* @task config
111
*/
112
final public function setRaisePolicyExceptions($bool) {
113
$this->raisePolicyExceptions = $bool;
114
return $this;
115
}
116
117
118
/**
119
* @return bool
120
* @task config
121
*/
122
final public function shouldRaisePolicyExceptions() {
123
return (bool)$this->raisePolicyExceptions;
124
}
125
126
127
/**
128
* @task config
129
*/
130
final public function requireCapabilities(array $capabilities) {
131
$this->capabilities = $capabilities;
132
return $this;
133
}
134
135
final public function setReturnPartialResultsOnOverheat($bool) {
136
$this->returnPartialResultsOnOverheat = $bool;
137
return $this;
138
}
139
140
final public function setDisableOverheating($disable_overheating) {
141
$this->disableOverheating = $disable_overheating;
142
return $this;
143
}
144
145
146
/* -( Query Execution )---------------------------------------------------- */
147
148
149
/**
150
* Execute the query, expecting a single result. This method simplifies
151
* loading objects for detail pages or edit views.
152
*
153
* // Load one result by ID.
154
* $obj = id(new ExampleQuery())
155
* ->setViewer($user)
156
* ->withIDs(array($id))
157
* ->executeOne();
158
* if (!$obj) {
159
* return new Aphront404Response();
160
* }
161
*
162
* If zero results match the query, this method returns `null`.
163
* If one result matches the query, this method returns that result.
164
*
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).
168
*
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.
172
*
173
* @return mixed Single result, or null.
174
* @task exec
175
*/
176
final public function executeOne() {
177
178
$this->setRaisePolicyExceptions(true);
179
try {
180
$results = $this->execute();
181
} catch (Exception $ex) {
182
$this->setRaisePolicyExceptions(false);
183
throw $ex;
184
}
185
186
if (count($results) > 1) {
187
throw new Exception(pht('Expected a single result!'));
188
}
189
190
if (!$results) {
191
return null;
192
}
193
194
return head($results);
195
}
196
197
198
/**
199
* Execute the query, loading all visible results.
200
*
201
* @return list<PhabricatorPolicyInterface> Result objects.
202
* @task exec
203
*/
204
final public function execute() {
205
if (!$this->viewer) {
206
throw new PhutilInvalidStateException('setViewer');
207
}
208
209
$parent_query = $this->getParentQuery();
210
if ($parent_query && ($this->raisePolicyExceptions === null)) {
211
$this->setRaisePolicyExceptions(
212
$parent_query->shouldRaisePolicyExceptions());
213
}
214
215
$results = array();
216
217
$filter = $this->getPolicyFilter();
218
219
$offset = (int)$this->getOffset();
220
$limit = (int)$this->getLimit();
221
$count = 0;
222
223
if ($limit) {
224
$need = $offset + $limit;
225
} else {
226
$need = 0;
227
}
228
229
$this->willExecute();
230
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;
236
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;
240
$total_seen = 0;
241
242
do {
243
if ($need) {
244
$this->rawResultLimit = min($need - $count, 1024);
245
} else {
246
$this->rawResultLimit = 0;
247
}
248
249
if ($this->canViewerUseQueryApplication()) {
250
try {
251
$page = $this->loadPage();
252
} catch (PhabricatorEmptyQueryException $ex) {
253
$page = array();
254
}
255
} else {
256
$page = array();
257
}
258
259
$total_seen += count($page);
260
261
if ($page) {
262
$maybe_visible = $this->willFilterPage($page);
263
if ($maybe_visible) {
264
$maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible);
265
}
266
} else {
267
$maybe_visible = array();
268
}
269
270
if ($this->shouldDisablePolicyFiltering()) {
271
$visible = $maybe_visible;
272
} else {
273
$visible = $filter->apply($maybe_visible);
274
275
$policy_filtered = array();
276
foreach ($maybe_visible as $key => $object) {
277
if (empty($visible[$key])) {
278
$phid = $object->getPHID();
279
if ($phid) {
280
$policy_filtered[$phid] = $phid;
281
}
282
}
283
}
284
$this->addPolicyFilteredPHIDs($policy_filtered);
285
}
286
287
if ($visible) {
288
$visible = $this->didFilterPage($visible);
289
}
290
291
$removed = array();
292
foreach ($maybe_visible as $key => $object) {
293
if (empty($visible[$key])) {
294
$removed[$key] = $object;
295
}
296
}
297
298
$this->didFilterResults($removed);
299
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);
306
307
foreach ($visible as $key => $result) {
308
++$count;
309
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;
316
}
317
318
if ($need && ($count >= $need)) {
319
// If we have all the rows we need, break out of the paging query.
320
break 2;
321
}
322
}
323
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.
327
break;
328
}
329
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.
334
break;
335
}
336
337
if (!$this->disableOverheating) {
338
if ($overheat_limit && ($total_seen >= $overheat_limit)) {
339
$this->isOverheated = true;
340
341
if (!$this->returnPartialResultsOnOverheat) {
342
throw new Exception(
343
pht(
344
'Query (of class "%s") overheated: examined more than %s '.
345
'raw rows without finding %s visible objects.',
346
get_class($this),
347
new PhutilNumber($overheat_limit),
348
new PhutilNumber($need)));
349
}
350
351
break;
352
}
353
}
354
} while (true);
355
356
$results = $this->didLoadResults($results);
357
358
return $results;
359
}
360
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());
367
368
return $filter;
369
}
370
371
protected function getRequiredCapabilities() {
372
if ($this->capabilities) {
373
return $this->capabilities;
374
}
375
376
return array(
377
PhabricatorPolicyCapability::CAN_VIEW,
378
);
379
}
380
381
protected function applyPolicyFilter(array $objects, array $capabilities) {
382
if ($this->shouldDisablePolicyFiltering()) {
383
return $objects;
384
}
385
$filter = $this->getPolicyFilter();
386
$filter->requireCapabilities($capabilities);
387
return $filter->apply($objects);
388
}
389
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.
395
396
try {
397
$policy = $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW);
398
} catch (Exception $ex) {
399
$policy = null;
400
}
401
402
// Mark this object as filtered so handles can render "Restricted" instead
403
// of "Unknown".
404
$phid = $object->getPHID();
405
$this->addPolicyFilteredPHIDs(array($phid => $phid));
406
407
$this->getPolicyFilter()->rejectObject(
408
$object,
409
$policy,
410
PhabricatorPolicyCapability::CAN_VIEW);
411
}
412
413
public function addPolicyFilteredPHIDs(array $phids) {
414
$this->policyFilteredPHIDs += $phids;
415
if ($this->getParentQuery()) {
416
$this->getParentQuery()->addPolicyFilteredPHIDs($phids);
417
}
418
return $this;
419
}
420
421
422
public function getIsOverheated() {
423
if ($this->isOverheated === null) {
424
throw new PhutilInvalidStateException('execute');
425
}
426
return $this->isOverheated;
427
}
428
429
430
/**
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.
435
*
436
* @return map<phid, phid> Map of object PHIDs which were filtered
437
* by policies.
438
* @task exec
439
*/
440
public function getPolicyFilteredPHIDs() {
441
return $this->policyFilteredPHIDs;
442
}
443
444
445
/* -( Query Workspace )---------------------------------------------------- */
446
447
448
/**
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).
452
*
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.
456
*
457
* The "query workspace" allows queries to load objects from elsewhere in a
458
* query block instead of refetching them.
459
*
460
* When using the query workspace, it's important to obey two rules:
461
*
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.
466
*
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.
471
*
472
* Generally, you do not need to update the workspace yourself: it is
473
* automatically populated as a side effect of objects surviving policy
474
* filtering.
475
*
476
* @param map<phid, PhabricatorPolicyInterface> Objects to add to the query
477
* workspace.
478
* @return this
479
* @task workspace
480
*/
481
public function putObjectsInWorkspace(array $objects) {
482
$parent = $this->getParentQuery();
483
if ($parent) {
484
$parent->putObjectsInWorkspace($objects);
485
return $this;
486
}
487
488
assert_instances_of($objects, 'PhabricatorPolicyInterface');
489
490
$viewer_fragment = $this->getViewer()->getCacheFragment();
491
492
// The workspace is scoped per viewer to prevent accidental contamination.
493
if (empty($this->workspace[$viewer_fragment])) {
494
$this->workspace[$viewer_fragment] = array();
495
}
496
497
$this->workspace[$viewer_fragment] += $objects;
498
499
return $this;
500
}
501
502
503
/**
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
507
* queries.
508
*
509
* @param list<phid> List of PHIDs to retrieve.
510
* @return this
511
* @task workspace
512
*/
513
public function getObjectsFromWorkspace(array $phids) {
514
$parent = $this->getParentQuery();
515
if ($parent) {
516
return $parent->getObjectsFromWorkspace($phids);
517
}
518
519
$viewer_fragment = $this->getViewer()->getCacheFragment();
520
521
$results = array();
522
foreach ($phids as $key => $phid) {
523
if (isset($this->workspace[$viewer_fragment][$phid])) {
524
$results[$phid] = $this->workspace[$viewer_fragment][$phid];
525
unset($phids[$key]);
526
}
527
}
528
529
return $results;
530
}
531
532
533
/**
534
* Mark PHIDs as in flight.
535
*
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.
538
*
539
* @param list<phid> List of PHIDs which are now in flight.
540
* @return this
541
*/
542
public function putPHIDsInFlight(array $phids) {
543
foreach ($phids as $phid) {
544
$this->inFlightPHIDs[$phid] = $phid;
545
}
546
return $this;
547
}
548
549
550
/**
551
* Get PHIDs which are currently in flight.
552
*
553
* PHIDs which are "in flight" are actively being queried for.
554
*
555
* @return map<phid, phid> PHIDs currently in flight.
556
*/
557
public function getPHIDsInFlight() {
558
$results = $this->inFlightPHIDs;
559
if ($this->getParentQuery()) {
560
$results += $this->getParentQuery()->getPHIDsInFlight();
561
}
562
return $results;
563
}
564
565
566
/* -( Policy Query Implementation )---------------------------------------- */
567
568
569
/**
570
* Get the number of results @{method:loadPage} should load. If the value is
571
* 0, @{method:loadPage} should load all available results.
572
*
573
* @return int The number of results to load, or 0 for all results.
574
* @task policyimpl
575
*/
576
final protected function getRawResultLimit() {
577
return $this->rawResultLimit;
578
}
579
580
581
/**
582
* Hook invoked before query execution. Generally, implementations should
583
* reset any internal cursors.
584
*
585
* @return void
586
* @task policyimpl
587
*/
588
protected function willExecute() {
589
return;
590
}
591
592
593
/**
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}.
597
*
598
* @return list<PhabricatorPolicyInterface> List of filterable policy objects.
599
* @task policyimpl
600
*/
601
abstract protected function loadPage();
602
603
604
/**
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.
608
*
609
* @param list<PhabricatorPolicyInterface> The current page of results.
610
* @return void
611
* @task policyimpl
612
*/
613
abstract protected function nextPage(array $page);
614
615
616
/**
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.
621
*
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.
626
*
627
* This method will only be called if data is available. Implementations
628
* do not need to handle the case of no results specially.
629
*
630
* @param list<wild> Results from `loadPage()`.
631
* @return list<PhabricatorPolicyInterface> Objects for policy filtering.
632
* @task policyimpl
633
*/
634
protected function willFilterPage(array $page) {
635
return $page;
636
}
637
638
/**
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.
642
*
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.
646
*
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.
649
*
650
* This method will only be called if data is available. Implementations do
651
* not need to handle the case of no results specially.
652
*
653
* @param list<wild> Results from @{method:willFilterPage()}.
654
* @return list<PhabricatorPolicyInterface> Objects after additional
655
* non-policy processing.
656
*/
657
protected function didFilterPage(array $page) {
658
return $page;
659
}
660
661
662
/**
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.
667
*
668
* @param list<wild> List of objects that should not be returned by alternate
669
* result mechanisms.
670
* @return void
671
* @task policyimpl
672
*/
673
protected function didFilterResults(array $results) {
674
return;
675
}
676
677
678
/**
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.
682
*
683
* @param list<PhabricatorPolicyInterface> Query results.
684
* @return list<PhabricatorPolicyInterface> Final results.
685
* @task policyimpl
686
*/
687
protected function didLoadResults(array $results) {
688
return $results;
689
}
690
691
692
/**
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).
697
*
698
* @return bool True to disable all policy filtering.
699
* @task policyimpl
700
*/
701
protected function shouldDisablePolicyFiltering() {
702
return false;
703
}
704
705
706
/**
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.
710
*
711
* If this query does not belong to an application, return `null`.
712
*
713
* @return string|null Application class name.
714
*/
715
abstract public function getQueryApplicationClass();
716
717
718
/**
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
721
* true.
722
*
723
* @return bool True if the viewer has application-level permission to
724
* execute the query.
725
*/
726
public function canViewerUseQueryApplication() {
727
$class = $this->getQueryApplicationClass();
728
if (!$class) {
729
return true;
730
}
731
732
$viewer = $this->getViewer();
733
return PhabricatorApplication::isClassInstalledForViewer($class, $viewer);
734
}
735
736
private function applyWillFilterPageExtensions(array $page) {
737
$bridges = array();
738
foreach ($page as $key => $object) {
739
if ($object instanceof DoorkeeperBridgedObjectInterface) {
740
$bridges[$key] = $object;
741
}
742
}
743
744
if ($bridges) {
745
$external_phids = array();
746
foreach ($bridges as $bridge) {
747
$external_phid = $bridge->getBridgedObjectPHID();
748
if ($external_phid) {
749
$external_phids[$key] = $external_phid;
750
}
751
}
752
753
if ($external_phids) {
754
$external_objects = id(new DoorkeeperExternalObjectQuery())
755
->setViewer($this->getViewer())
756
->withPHIDs($external_phids)
757
->execute();
758
$external_objects = mpull($external_objects, null, 'getPHID');
759
} else {
760
$external_objects = array();
761
}
762
763
foreach ($bridges as $key => $bridge) {
764
$external_phid = idx($external_phids, $key);
765
if (!$external_phid) {
766
$bridge->attachBridgedObject(null);
767
continue;
768
}
769
770
$external_object = idx($external_objects, $external_phid);
771
if (!$external_object) {
772
$this->didRejectResult($bridge);
773
unset($page[$key]);
774
continue;
775
}
776
777
$bridge->attachBridgedObject($external_object);
778
}
779
}
780
781
return $page;
782
}
783
784
}
785
786