Path: blob/master/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
12242 views
<?php12/**3* A @{class:PhabricatorQuery} which filters results according to visibility4* policies for the querying user. Broadly, this class allows you to implement5* a query that returns only objects the user is allowed to see.6*7* $results = id(new ExampleQuery())8* ->setViewer($user)9* ->withConstraint($example)10* ->execute();11*12* Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery},13* not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a14* more practical interface for building usable queries against most object15* types.16*17* NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery},18* offset paging with policy filtering is not efficient. All results must be19* loaded into the application and filtered here: skipping `N` rows via offset20* is an `O(N)` operation with a large constant. Prefer cursor-based paging21* with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far22* more efficiently in MySQL.23*24* @task config Query Configuration25* @task exec Executing Queries26* @task policyimpl Policy Query Implementation27*/28abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery {2930private $viewer;31private $parentQuery;32private $rawResultLimit;33private $capabilities;34private $workspace = array();35private $inFlightPHIDs = array();36private $policyFilteredPHIDs = array();3738/**39* Should we continue or throw an exception when a query result is filtered40* by policy rules?41*42* Values are `true` (raise exceptions), `false` (do not raise exceptions)43* and `null` (inherit from parent query, with no exceptions by default).44*/45private $raisePolicyExceptions;46private $isOverheated;47private $returnPartialResultsOnOverheat;48private $disableOverheating;495051/* -( Query Configuration )------------------------------------------------ */525354/**55* Set the viewer who is executing the query. Results will be filtered56* according to the viewer's capabilities. You must set a viewer to execute57* a policy query.58*59* @param PhabricatorUser The viewing user.60* @return this61* @task config62*/63final public function setViewer(PhabricatorUser $viewer) {64$this->viewer = $viewer;65return $this;66}676869/**70* Get the query's viewer.71*72* @return PhabricatorUser The viewing user.73* @task config74*/75final public function getViewer() {76return $this->viewer;77}787980/**81* Set the parent query of this query. This is useful for nested queries so82* that configuration like whether or not to raise policy exceptions is83* seamlessly passed along to child queries.84*85* @return this86* @task config87*/88final public function setParentQuery(PhabricatorPolicyAwareQuery $query) {89$this->parentQuery = $query;90return $this;91}929394/**95* Get the parent query. See @{method:setParentQuery} for discussion.96*97* @return PhabricatorPolicyAwareQuery The parent query.98* @task config99*/100final public function getParentQuery() {101return $this->parentQuery;102}103104105/**106* Hook to configure whether this query should raise policy exceptions.107*108* @return this109* @task config110*/111final public function setRaisePolicyExceptions($bool) {112$this->raisePolicyExceptions = $bool;113return $this;114}115116117/**118* @return bool119* @task config120*/121final public function shouldRaisePolicyExceptions() {122return (bool)$this->raisePolicyExceptions;123}124125126/**127* @task config128*/129final public function requireCapabilities(array $capabilities) {130$this->capabilities = $capabilities;131return $this;132}133134final public function setReturnPartialResultsOnOverheat($bool) {135$this->returnPartialResultsOnOverheat = $bool;136return $this;137}138139final public function setDisableOverheating($disable_overheating) {140$this->disableOverheating = $disable_overheating;141return $this;142}143144145/* -( Query Execution )---------------------------------------------------- */146147148/**149* Execute the query, expecting a single result. This method simplifies150* loading objects for detail pages or edit views.151*152* // Load one result by ID.153* $obj = id(new ExampleQuery())154* ->setViewer($user)155* ->withIDs(array($id))156* ->executeOne();157* if (!$obj) {158* return new Aphront404Response();159* }160*161* If zero results match the query, this method returns `null`.162* If one result matches the query, this method returns that result.163*164* If two or more results match the query, this method throws an exception.165* You should use this method only when the query constraints guarantee at166* most one match (e.g., selecting a specific ID or PHID).167*168* If one result matches the query but it is caught by the policy filter (for169* example, the user is trying to view or edit an object which exists but170* which they do not have permission to see) a policy exception is thrown.171*172* @return mixed Single result, or null.173* @task exec174*/175final public function executeOne() {176177$this->setRaisePolicyExceptions(true);178try {179$results = $this->execute();180} catch (Exception $ex) {181$this->setRaisePolicyExceptions(false);182throw $ex;183}184185if (count($results) > 1) {186throw new Exception(pht('Expected a single result!'));187}188189if (!$results) {190return null;191}192193return head($results);194}195196197/**198* Execute the query, loading all visible results.199*200* @return list<PhabricatorPolicyInterface> Result objects.201* @task exec202*/203final public function execute() {204if (!$this->viewer) {205throw new PhutilInvalidStateException('setViewer');206}207208$parent_query = $this->getParentQuery();209if ($parent_query && ($this->raisePolicyExceptions === null)) {210$this->setRaisePolicyExceptions(211$parent_query->shouldRaisePolicyExceptions());212}213214$results = array();215216$filter = $this->getPolicyFilter();217218$offset = (int)$this->getOffset();219$limit = (int)$this->getLimit();220$count = 0;221222if ($limit) {223$need = $offset + $limit;224} else {225$need = 0;226}227228$this->willExecute();229230// If we examine and filter significantly more objects than the query231// limit, we stop early. This prevents us from looping through a huge232// number of records when the viewer can see few or none of them. See233// T11773 for some discussion.234$this->isOverheated = false;235236// See T13386. If we are on an old offset-based paging workflow, we need237// to base the overheating limit on both the offset and limit.238$overheat_limit = $need * 10;239$total_seen = 0;240241do {242if ($need) {243$this->rawResultLimit = min($need - $count, 1024);244} else {245$this->rawResultLimit = 0;246}247248if ($this->canViewerUseQueryApplication()) {249try {250$page = $this->loadPage();251} catch (PhabricatorEmptyQueryException $ex) {252$page = array();253}254} else {255$page = array();256}257258$total_seen += count($page);259260if ($page) {261$maybe_visible = $this->willFilterPage($page);262if ($maybe_visible) {263$maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible);264}265} else {266$maybe_visible = array();267}268269if ($this->shouldDisablePolicyFiltering()) {270$visible = $maybe_visible;271} else {272$visible = $filter->apply($maybe_visible);273274$policy_filtered = array();275foreach ($maybe_visible as $key => $object) {276if (empty($visible[$key])) {277$phid = $object->getPHID();278if ($phid) {279$policy_filtered[$phid] = $phid;280}281}282}283$this->addPolicyFilteredPHIDs($policy_filtered);284}285286if ($visible) {287$visible = $this->didFilterPage($visible);288}289290$removed = array();291foreach ($maybe_visible as $key => $object) {292if (empty($visible[$key])) {293$removed[$key] = $object;294}295}296297$this->didFilterResults($removed);298299// NOTE: We call "nextPage()" before checking if we've found enough300// results because we want to build the internal cursor object even301// if we don't need to execute another query: the internal cursor may302// be used by a parent query that is using this query to translate an303// external cursor into an internal cursor.304$this->nextPage($page);305306foreach ($visible as $key => $result) {307++$count;308309// If we have an offset, we just ignore that many results and start310// storing them only once we've hit the offset. This reduces memory311// requirements for large offsets, compared to storing them all and312// slicing them away later.313if ($count > $offset) {314$results[$key] = $result;315}316317if ($need && ($count >= $need)) {318// If we have all the rows we need, break out of the paging query.319break 2;320}321}322323if (!$this->rawResultLimit) {324// If we don't have a load count, we loaded all the results. We do325// not need to load another page.326break;327}328329if (count($page) < $this->rawResultLimit) {330// If we have a load count but the unfiltered results contained fewer331// objects, we know this was the last page of objects; we do not need332// to load another page because we can deduce it would be empty.333break;334}335336if (!$this->disableOverheating) {337if ($overheat_limit && ($total_seen >= $overheat_limit)) {338$this->isOverheated = true;339340if (!$this->returnPartialResultsOnOverheat) {341throw new Exception(342pht(343'Query (of class "%s") overheated: examined more than %s '.344'raw rows without finding %s visible objects.',345get_class($this),346new PhutilNumber($overheat_limit),347new PhutilNumber($need)));348}349350break;351}352}353} while (true);354355$results = $this->didLoadResults($results);356357return $results;358}359360private function getPolicyFilter() {361$filter = new PhabricatorPolicyFilter();362$filter->setViewer($this->viewer);363$capabilities = $this->getRequiredCapabilities();364$filter->requireCapabilities($capabilities);365$filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions());366367return $filter;368}369370protected function getRequiredCapabilities() {371if ($this->capabilities) {372return $this->capabilities;373}374375return array(376PhabricatorPolicyCapability::CAN_VIEW,377);378}379380protected function applyPolicyFilter(array $objects, array $capabilities) {381if ($this->shouldDisablePolicyFiltering()) {382return $objects;383}384$filter = $this->getPolicyFilter();385$filter->requireCapabilities($capabilities);386return $filter->apply($objects);387}388389protected function didRejectResult(PhabricatorPolicyInterface $object) {390// Some objects (like commits) may be rejected because related objects391// (like repositories) can not be loaded. In some cases, we may need these392// related objects to determine the object policy, so it's expected that393// we may occasionally be unable to determine the policy.394395try {396$policy = $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW);397} catch (Exception $ex) {398$policy = null;399}400401// Mark this object as filtered so handles can render "Restricted" instead402// of "Unknown".403$phid = $object->getPHID();404$this->addPolicyFilteredPHIDs(array($phid => $phid));405406$this->getPolicyFilter()->rejectObject(407$object,408$policy,409PhabricatorPolicyCapability::CAN_VIEW);410}411412public function addPolicyFilteredPHIDs(array $phids) {413$this->policyFilteredPHIDs += $phids;414if ($this->getParentQuery()) {415$this->getParentQuery()->addPolicyFilteredPHIDs($phids);416}417return $this;418}419420421public function getIsOverheated() {422if ($this->isOverheated === null) {423throw new PhutilInvalidStateException('execute');424}425return $this->isOverheated;426}427428429/**430* Return a map of all object PHIDs which were loaded in the query but431* filtered out by policy constraints. This allows a caller to distinguish432* between objects which do not exist (or, at least, were filtered at the433* content level) and objects which exist but aren't visible.434*435* @return map<phid, phid> Map of object PHIDs which were filtered436* by policies.437* @task exec438*/439public function getPolicyFilteredPHIDs() {440return $this->policyFilteredPHIDs;441}442443444/* -( Query Workspace )---------------------------------------------------- */445446447/**448* Put a map of objects into the query workspace. Many queries perform449* subqueries, which can eventually end up loading the same objects more than450* once (often to perform policy checks).451*452* For example, loading a user may load the user's profile image, which might453* load the user object again in order to verify that the viewer has454* permission to see the file.455*456* The "query workspace" allows queries to load objects from elsewhere in a457* query block instead of refetching them.458*459* When using the query workspace, it's important to obey two rules:460*461* **Never put objects into the workspace which the viewer may not be able462* to see**. You need to apply all policy filtering //before// putting463* objects in the workspace. Otherwise, subqueries may read the objects and464* use them to permit access to content the user shouldn't be able to view.465*466* **Fully enrich objects pulled from the workspace.** After pulling objects467* from the workspace, you still need to load and attach any additional468* content the query requests. Otherwise, a query might return objects469* without requested content.470*471* Generally, you do not need to update the workspace yourself: it is472* automatically populated as a side effect of objects surviving policy473* filtering.474*475* @param map<phid, PhabricatorPolicyInterface> Objects to add to the query476* workspace.477* @return this478* @task workspace479*/480public function putObjectsInWorkspace(array $objects) {481$parent = $this->getParentQuery();482if ($parent) {483$parent->putObjectsInWorkspace($objects);484return $this;485}486487assert_instances_of($objects, 'PhabricatorPolicyInterface');488489$viewer_fragment = $this->getViewer()->getCacheFragment();490491// The workspace is scoped per viewer to prevent accidental contamination.492if (empty($this->workspace[$viewer_fragment])) {493$this->workspace[$viewer_fragment] = array();494}495496$this->workspace[$viewer_fragment] += $objects;497498return $this;499}500501502/**503* Retrieve objects from the query workspace. For more discussion about the504* workspace mechanism, see @{method:putObjectsInWorkspace}. This method505* searches both the current query's workspace and the workspaces of parent506* queries.507*508* @param list<phid> List of PHIDs to retrieve.509* @return this510* @task workspace511*/512public function getObjectsFromWorkspace(array $phids) {513$parent = $this->getParentQuery();514if ($parent) {515return $parent->getObjectsFromWorkspace($phids);516}517518$viewer_fragment = $this->getViewer()->getCacheFragment();519520$results = array();521foreach ($phids as $key => $phid) {522if (isset($this->workspace[$viewer_fragment][$phid])) {523$results[$phid] = $this->workspace[$viewer_fragment][$phid];524unset($phids[$key]);525}526}527528return $results;529}530531532/**533* Mark PHIDs as in flight.534*535* PHIDs which are "in flight" are actively being queried for. Using this536* list can prevent infinite query loops by aborting queries which cycle.537*538* @param list<phid> List of PHIDs which are now in flight.539* @return this540*/541public function putPHIDsInFlight(array $phids) {542foreach ($phids as $phid) {543$this->inFlightPHIDs[$phid] = $phid;544}545return $this;546}547548549/**550* Get PHIDs which are currently in flight.551*552* PHIDs which are "in flight" are actively being queried for.553*554* @return map<phid, phid> PHIDs currently in flight.555*/556public function getPHIDsInFlight() {557$results = $this->inFlightPHIDs;558if ($this->getParentQuery()) {559$results += $this->getParentQuery()->getPHIDsInFlight();560}561return $results;562}563564565/* -( Policy Query Implementation )---------------------------------------- */566567568/**569* Get the number of results @{method:loadPage} should load. If the value is570* 0, @{method:loadPage} should load all available results.571*572* @return int The number of results to load, or 0 for all results.573* @task policyimpl574*/575final protected function getRawResultLimit() {576return $this->rawResultLimit;577}578579580/**581* Hook invoked before query execution. Generally, implementations should582* reset any internal cursors.583*584* @return void585* @task policyimpl586*/587protected function willExecute() {588return;589}590591592/**593* Load a raw page of results. Generally, implementations should load objects594* from the database. They should attempt to return the number of results595* hinted by @{method:getRawResultLimit}.596*597* @return list<PhabricatorPolicyInterface> List of filterable policy objects.598* @task policyimpl599*/600abstract protected function loadPage();601602603/**604* Update internal state so that the next call to @{method:loadPage} will605* return new results. Generally, you should adjust a cursor position based606* on the provided result page.607*608* @param list<PhabricatorPolicyInterface> The current page of results.609* @return void610* @task policyimpl611*/612abstract protected function nextPage(array $page);613614615/**616* Hook for applying a page filter prior to the privacy filter. This allows617* you to drop some items from the result set without creating problems with618* pagination or cursor updates. You can also load and attach data which is619* required to perform policy filtering.620*621* Generally, you should load non-policy data and perform non-policy filtering622* later, in @{method:didFilterPage}. Strictly fewer objects will make it that623* far (so the program will load less data) and subqueries from that context624* can use the query workspace to further reduce query load.625*626* This method will only be called if data is available. Implementations627* do not need to handle the case of no results specially.628*629* @param list<wild> Results from `loadPage()`.630* @return list<PhabricatorPolicyInterface> Objects for policy filtering.631* @task policyimpl632*/633protected function willFilterPage(array $page) {634return $page;635}636637/**638* Hook for performing additional non-policy loading or filtering after an639* object has satisfied all policy checks. Generally, this means loading and640* attaching related data.641*642* Subqueries executed during this phase can use the query workspace, which643* may improve performance or make circular policies resolvable. Data which644* is not necessary for policy filtering should generally be loaded here.645*646* This callback can still filter objects (for example, if attachable data647* is discovered to not exist), but should not do so for policy reasons.648*649* This method will only be called if data is available. Implementations do650* not need to handle the case of no results specially.651*652* @param list<wild> Results from @{method:willFilterPage()}.653* @return list<PhabricatorPolicyInterface> Objects after additional654* non-policy processing.655*/656protected function didFilterPage(array $page) {657return $page;658}659660661/**662* Hook for removing filtered results from alternate result sets. This663* hook will be called with any objects which were returned by the query but664* filtered for policy reasons. The query should remove them from any cached665* or partial result sets.666*667* @param list<wild> List of objects that should not be returned by alternate668* result mechanisms.669* @return void670* @task policyimpl671*/672protected function didFilterResults(array $results) {673return;674}675676677/**678* Hook for applying final adjustments before results are returned. This is679* used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results680* that are queried during reverse paging.681*682* @param list<PhabricatorPolicyInterface> Query results.683* @return list<PhabricatorPolicyInterface> Final results.684* @task policyimpl685*/686protected function didLoadResults(array $results) {687return $results;688}689690691/**692* Allows a subclass to disable policy filtering. This method is dangerous.693* It should be used only if the query loads data which has already been694* filtered (for example, because it wraps some other query which uses695* normal policy filtering).696*697* @return bool True to disable all policy filtering.698* @task policyimpl699*/700protected function shouldDisablePolicyFiltering() {701return false;702}703704705/**706* If this query belongs to an application, return the application class name707* here. This will prevent the query from returning results if the viewer can708* not access the application.709*710* If this query does not belong to an application, return `null`.711*712* @return string|null Application class name.713*/714abstract public function getQueryApplicationClass();715716717/**718* Determine if the viewer has permission to use this query's application.719* For queries which aren't part of an application, this method always returns720* true.721*722* @return bool True if the viewer has application-level permission to723* execute the query.724*/725public function canViewerUseQueryApplication() {726$class = $this->getQueryApplicationClass();727if (!$class) {728return true;729}730731$viewer = $this->getViewer();732return PhabricatorApplication::isClassInstalledForViewer($class, $viewer);733}734735private function applyWillFilterPageExtensions(array $page) {736$bridges = array();737foreach ($page as $key => $object) {738if ($object instanceof DoorkeeperBridgedObjectInterface) {739$bridges[$key] = $object;740}741}742743if ($bridges) {744$external_phids = array();745foreach ($bridges as $bridge) {746$external_phid = $bridge->getBridgedObjectPHID();747if ($external_phid) {748$external_phids[$key] = $external_phid;749}750}751752if ($external_phids) {753$external_objects = id(new DoorkeeperExternalObjectQuery())754->setViewer($this->getViewer())755->withPHIDs($external_phids)756->execute();757$external_objects = mpull($external_objects, null, 'getPHID');758} else {759$external_objects = array();760}761762foreach ($bridges as $key => $bridge) {763$external_phid = idx($external_phids, $key);764if (!$external_phid) {765$bridge->attachBridgedObject(null);766continue;767}768769$external_object = idx($external_objects, $external_phid);770if (!$external_object) {771$this->didRejectResult($bridge);772unset($page[$key]);773continue;774}775776$bridge->attachBridgedObject($external_object);777}778}779780return $page;781}782783}784785786