diff --git a/src/applications/files/query/PhabricatorFileQuery.php b/src/applications/files/query/PhabricatorFileQuery.php index bce7becda4..01ad2a9c72 100644 --- a/src/applications/files/query/PhabricatorFileQuery.php +++ b/src/applications/files/query/PhabricatorFileQuery.php @@ -1,252 +1,258 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withAuthorPHIDs(array $phids) { $this->authorPHIDs = $phids; return $this; } public function withDateCreatedBefore($date_created_before) { $this->dateCreatedBefore = $date_created_before; return $this; } public function withDateCreatedAfter($date_created_after) { $this->dateCreatedAfter = $date_created_after; return $this; } public function withContentHashes(array $content_hashes) { $this->contentHashes = $content_hashes; return $this; } /** * Select files which are transformations of some other file. For example, * you can use this query to find previously generated thumbnails of an image * file. * * As a parameter, provide a list of transformation specifications. Each * specification is a dictionary with the keys `originalPHID` and `transform`. * The `originalPHID` is the PHID of the original file (the file which was * transformed) and the `transform` is the name of the transform to query * for. If you pass `true` as the `transform`, all transformations of the * file will be selected. * * For example: * * array( * array( * 'originalPHID' => 'PHID-FILE-aaaa', * 'transform' => 'sepia', * ), * array( * 'originalPHID' => 'PHID-FILE-bbbb', * 'transform' => true, * ), * ) * * This selects the `"sepia"` transformation of the file with PHID * `PHID-FILE-aaaa` and all transformations of the file with PHID * `PHID-FILE-bbbb`. * * @param list List of transform specifications, described above. * @return this */ public function withTransforms(array $specs) { foreach ($specs as $spec) { if (!is_array($spec) || empty($spec['originalPHID']) || empty($spec['transform'])) { throw new Exception( "Transform specification must be a dictionary with keys ". "'originalPHID' and 'transform'!"); } } $this->transforms = $specs; return $this; } public function showOnlyExplicitUploads($explicit_uploads) { $this->explicitUploads = $explicit_uploads; return $this; } protected function loadPage() { $table = new PhabricatorFile(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT f.* FROM %T f %Q %Q %Q %Q', $table->getTableName(), $this->buildJoinClause($conn_r), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); $files = $table->loadAllFromArray($data); if (!$files) { return $files; } // We need to load attached objects to perform policy checks for files. // First, load the edges. $edge_type = PhabricatorEdgeConfig::TYPE_FILE_HAS_OBJECT; $phids = mpull($files, 'getPHID'); $edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs($phids) ->withEdgeTypes(array($edge_type)) ->execute(); $object_phids = array(); foreach ($files as $file) { $phids = array_keys($edges[$file->getPHID()][$edge_type]); $file->attachObjectPHIDs($phids); foreach ($phids as $phid) { $object_phids[$phid] = true; } } $object_phids = array_keys($object_phids); // Now, load the objects. $objects = array(); if ($object_phids) { + // NOTE: We're explicitly turning policy exceptions off, since the rule + // here is "you can see the file if you can see ANY associated object". + // Without this explicit flag, we'll incorrectly throw unless you can + // see ALL associated objects. + $objects = id(new PhabricatorObjectQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($object_phids) + ->setRaisePolicyExceptions(false) ->execute(); $objects = mpull($objects, null, 'getPHID'); } foreach ($files as $file) { $file_objects = array_select_keys($objects, $file->getObjectPHIDs()); $file->attachObjects($file_objects); } return $files; } private function buildJoinClause(AphrontDatabaseConnection $conn_r) { $joins = array(); if ($this->transforms) { $joins[] = qsprintf( $conn_r, 'JOIN %T t ON t.transformedPHID = f.phid', id(new PhabricatorTransformedFile())->getTableName()); } return implode(' ', $joins); } private function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); $where[] = $this->buildPagingClause($conn_r); if ($this->ids) { $where[] = qsprintf( $conn_r, 'f.id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn_r, 'f.phid IN (%Ls)', $this->phids); } if ($this->authorPHIDs) { $where[] = qsprintf( $conn_r, 'f.authorPHID IN (%Ls)', $this->authorPHIDs); } if ($this->explicitUploads) { $where[] = qsprintf( $conn_r, 'f.isExplicitUpload = true'); } if ($this->transforms) { $clauses = array(); foreach ($this->transforms as $transform) { if ($transform['transform'] === true) { $clauses[] = qsprintf( $conn_r, '(t.originalPHID = %s)', $transform['originalPHID']); } else { $clauses[] = qsprintf( $conn_r, '(t.originalPHID = %s AND t.transform = %s)', $transform['originalPHID'], $transform['transform']); } } $where[] = qsprintf($conn_r, '(%Q)', implode(') OR (', $clauses)); } if ($this->dateCreatedAfter) { $where[] = qsprintf( $conn_r, 'f.dateCreated >= %d', $this->dateCreatedAfter); } if ($this->dateCreatedBefore) { $where[] = qsprintf( $conn_r, 'f.dateCreated <= %d', $this->dateCreatedBefore); } if ($this->contentHashes) { $where[] = qsprintf( $conn_r, 'f.contentHash IN (%Ls)', $this->contentHashes); } return $this->formatWhereClause($where); } protected function getPagingColumn() { return 'f.id'; } public function getQueryApplicationClass() { return 'PhabricatorFilesApplication'; } } diff --git a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php index 6046bff847..1f353d7dfb 100644 --- a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php @@ -1,641 +1,649 @@ setViewer($user) * ->withConstraint($example) * ->execute(); * * Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery}, * not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a * more practical interface for building usable queries against most object * types. * * NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery}, * offset paging with policy filtering is not efficient. All results must be * loaded into the application and filtered here: skipping `N` rows via offset * is an `O(N)` operation with a large constant. Prefer cursor-based paging * with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far * more efficiently in MySQL. * * @task config Query Configuration * @task exec Executing Queries * @task policyimpl Policy Query Implementation */ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { private $viewer; - private $raisePolicyExceptions; private $parentQuery; private $rawResultLimit; private $capabilities; private $workspace = array(); private $policyFilteredPHIDs = array(); private $canUseApplication; + /** + * Should we continue or throw an exception when a query result is filtered + * by policy rules? + * + * Values are `true` (raise exceptions), `false` (do not raise exceptions) + * and `null` (inherit from parent query, with no exceptions by default). + */ + private $raisePolicyExceptions; + /* -( Query Configuration )------------------------------------------------ */ /** * Set the viewer who is executing the query. Results will be filtered * according to the viewer's capabilities. You must set a viewer to execute * a policy query. * * @param PhabricatorUser The viewing user. * @return this * @task config */ final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } /** * Get the query's viewer. * * @return PhabricatorUser The viewing user. * @task config */ final public function getViewer() { return $this->viewer; } /** * Set the parent query of this query. This is useful for nested queries so * that configuration like whether or not to raise policy exceptions is * seamlessly passed along to child queries. * * @return this * @task config */ final public function setParentQuery(PhabricatorPolicyAwareQuery $query) { $this->parentQuery = $query; return $this; } /** * Get the parent query. See @{method:setParentQuery} for discussion. * * @return PhabricatorPolicyAwareQuery The parent query. * @task config */ final public function getParentQuery() { return $this->parentQuery; } /** * Hook to configure whether this query should raise policy exceptions. * * @return this * @task config */ final public function setRaisePolicyExceptions($bool) { $this->raisePolicyExceptions = $bool; return $this; } /** * @return bool * @task config */ final public function shouldRaisePolicyExceptions() { return (bool)$this->raisePolicyExceptions; } /** * @task config */ final public function requireCapabilities(array $capabilities) { $this->capabilities = $capabilities; return $this; } /* -( Query Execution )---------------------------------------------------- */ /** * Execute the query, expecting a single result. This method simplifies * loading objects for detail pages or edit views. * * // Load one result by ID. * $obj = id(new ExampleQuery()) * ->setViewer($user) * ->withIDs(array($id)) * ->executeOne(); * if (!$obj) { * return new Aphront404Response(); * } * * If zero results match the query, this method returns `null`. * If one result matches the query, this method returns that result. * * If two or more results match the query, this method throws an exception. * You should use this method only when the query constraints guarantee at * most one match (e.g., selecting a specific ID or PHID). * * If one result matches the query but it is caught by the policy filter (for * example, the user is trying to view or edit an object which exists but * which they do not have permission to see) a policy exception is thrown. * * @return mixed Single result, or null. * @task exec */ final public function executeOne() { $this->setRaisePolicyExceptions(true); try { $results = $this->execute(); } catch (Exception $ex) { $this->setRaisePolicyExceptions(false); throw $ex; } if (count($results) > 1) { throw new Exception('Expected a single result!'); } if (!$results) { return null; } return head($results); } /** * Execute the query, loading all visible results. * * @return list Result objects. * @task exec */ final public function execute() { if (!$this->viewer) { throw new Exception('Call setViewer() before execute()!'); } $parent_query = $this->getParentQuery(); - if ($parent_query) { + if ($parent_query && ($this->raisePolicyExceptions === null)) { $this->setRaisePolicyExceptions( $parent_query->shouldRaisePolicyExceptions()); } $results = array(); $filter = $this->getPolicyFilter(); $offset = (int)$this->getOffset(); $limit = (int)$this->getLimit(); $count = 0; if ($limit) { $need = $offset + $limit; } else { $need = 0; } $this->willExecute(); do { if ($need) { $this->rawResultLimit = min($need - $count, 1024); } else { $this->rawResultLimit = 0; } if ($this->canViewerUseQueryApplication()) { try { $page = $this->loadPage(); } catch (PhabricatorEmptyQueryException $ex) { $page = array(); } } else { $page = array(); } if ($page) { $maybe_visible = $this->willFilterPage($page); } else { $maybe_visible = array(); } if ($this->shouldDisablePolicyFiltering()) { $visible = $maybe_visible; } else { $visible = $filter->apply($maybe_visible); $policy_filtered = array(); foreach ($maybe_visible as $key => $object) { if (empty($visible[$key])) { $phid = $object->getPHID(); if ($phid) { $policy_filtered[$phid] = $phid; } } } $this->addPolicyFilteredPHIDs($policy_filtered); } if ($visible) { $this->putObjectsInWorkspace($this->getWorkspaceMapForPage($visible)); $visible = $this->didFilterPage($visible); } $removed = array(); foreach ($maybe_visible as $key => $object) { if (empty($visible[$key])) { $removed[$key] = $object; } } $this->didFilterResults($removed); foreach ($visible as $key => $result) { ++$count; // If we have an offset, we just ignore that many results and start // storing them only once we've hit the offset. This reduces memory // requirements for large offsets, compared to storing them all and // slicing them away later. if ($count > $offset) { $results[$key] = $result; } if ($need && ($count >= $need)) { // If we have all the rows we need, break out of the paging query. break 2; } } if (!$this->rawResultLimit) { // If we don't have a load count, we loaded all the results. We do // not need to load another page. break; } if (count($page) < $this->rawResultLimit) { // If we have a load count but the unfiltered results contained fewer // objects, we know this was the last page of objects; we do not need // to load another page because we can deduce it would be empty. break; } $this->nextPage($page); } while (true); $results = $this->didLoadResults($results); return $results; } private function getPolicyFilter() { $filter = new PhabricatorPolicyFilter(); $filter->setViewer($this->viewer); $capabilities = $this->getRequiredCapabilities(); $filter->requireCapabilities($capabilities); $filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions()); return $filter; } protected function getRequiredCapabilities() { if ($this->capabilities) { return $this->capabilities; } return array( PhabricatorPolicyCapability::CAN_VIEW, ); } protected function applyPolicyFilter(array $objects, array $capabilities) { if ($this->shouldDisablePolicyFiltering()) { return $objects; } $filter = $this->getPolicyFilter(); $filter->requireCapabilities($capabilities); return $filter->apply($objects); } protected function didRejectResult(PhabricatorPolicyInterface $object) { $this->getPolicyFilter()->rejectObject( $object, $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW), PhabricatorPolicyCapability::CAN_VIEW); } public function addPolicyFilteredPHIDs(array $phids) { $this->policyFilteredPHIDs += $phids; if ($this->getParentQuery()) { $this->getParentQuery()->addPolicyFilteredPHIDs($phids); } return $this; } /** * Return a map of all object PHIDs which were loaded in the query but * filtered out by policy constraints. This allows a caller to distinguish * between objects which do not exist (or, at least, were filtered at the * content level) and objects which exist but aren't visible. * * @return map Map of object PHIDs which were filtered * by policies. * @task exec */ public function getPolicyFilteredPHIDs() { return $this->policyFilteredPHIDs; } /* -( Query Workspace )---------------------------------------------------- */ /** * Put a map of objects into the query workspace. Many queries perform * subqueries, which can eventually end up loading the same objects more than * once (often to perform policy checks). * * For example, loading a user may load the user's profile image, which might * load the user object again in order to verify that the viewer has * permission to see the file. * * The "query workspace" allows queries to load objects from elsewhere in a * query block instead of refetching them. * * When using the query workspace, it's important to obey two rules: * * **Never put objects into the workspace which the viewer may not be able * to see**. You need to apply all policy filtering //before// putting * objects in the workspace. Otherwise, subqueries may read the objects and * use them to permit access to content the user shouldn't be able to view. * * **Fully enrich objects pulled from the workspace.** After pulling objects * from the workspace, you still need to load and attach any additional * content the query requests. Otherwise, a query might return objects without * requested content. * * Generally, you do not need to update the workspace yourself: it is * automatically populated as a side effect of objects surviving policy * filtering. * * @param map Objects to add to the query * workspace. * @return this * @task workspace */ public function putObjectsInWorkspace(array $objects) { assert_instances_of($objects, 'PhabricatorPolicyInterface'); $viewer_phid = $this->getViewer()->getPHID(); // The workspace is scoped per viewer to prevent accidental contamination. if (empty($this->workspace[$viewer_phid])) { $this->workspace[$viewer_phid] = array(); } $this->workspace[$viewer_phid] += $objects; return $this; } /** * Retrieve objects from the query workspace. For more discussion about the * workspace mechanism, see @{method:putObjectsInWorkspace}. This method * searches both the current query's workspace and the workspaces of parent * queries. * * @param list List of PHIDs to retrieve. * @return this * @task workspace */ public function getObjectsFromWorkspace(array $phids) { $viewer_phid = $this->getViewer()->getPHID(); $results = array(); foreach ($phids as $key => $phid) { if (isset($this->workspace[$viewer_phid][$phid])) { $results[$phid] = $this->workspace[$viewer_phid][$phid]; unset($phids[$key]); } } if ($phids && $this->getParentQuery()) { $results += $this->getParentQuery()->getObjectsFromWorkspace($phids); } return $results; } /** * Convert a result page to a `` map. * * @param list Objects. * @return map Map of objects which can * be put into the workspace. * @task workspace */ protected function getWorkspaceMapForPage(array $results) { $map = array(); foreach ($results as $result) { $phid = $result->getPHID(); if ($phid !== null) { $map[$phid] = $result; } } return $map; } /* -( Policy Query Implementation )---------------------------------------- */ /** * Get the number of results @{method:loadPage} should load. If the value is * 0, @{method:loadPage} should load all available results. * * @return int The number of results to load, or 0 for all results. * @task policyimpl */ final protected function getRawResultLimit() { return $this->rawResultLimit; } /** * Hook invoked before query execution. Generally, implementations should * reset any internal cursors. * * @return void * @task policyimpl */ protected function willExecute() { return; } /** * Load a raw page of results. Generally, implementations should load objects * from the database. They should attempt to return the number of results * hinted by @{method:getRawResultLimit}. * * @return list List of filterable policy objects. * @task policyimpl */ abstract protected function loadPage(); /** * Update internal state so that the next call to @{method:loadPage} will * return new results. Generally, you should adjust a cursor position based * on the provided result page. * * @param list The current page of results. * @return void * @task policyimpl */ abstract protected function nextPage(array $page); /** * Hook for applying a page filter prior to the privacy filter. This allows * you to drop some items from the result set without creating problems with * pagination or cursor updates. You can also load and attach data which is * required to perform policy filtering. * * Generally, you should load non-policy data and perform non-policy filtering * later, in @{method:didFilterPage}. Strictly fewer objects will make it that * far (so the program will load less data) and subqueries from that context * can use the query workspace to further reduce query load. * * This method will only be called if data is available. Implementations * do not need to handle the case of no results specially. * * @param list Results from `loadPage()`. * @return list Objects for policy filtering. * @task policyimpl */ protected function willFilterPage(array $page) { return $page; } /** * Hook for performing additional non-policy loading or filtering after an * object has satisfied all policy checks. Generally, this means loading and * attaching related data. * * Subqueries executed during this phase can use the query workspace, which * may improve performance or make circular policies resolvable. Data which * is not necessary for policy filtering should generally be loaded here. * * This callback can still filter objects (for example, if attachable data * is discovered to not exist), but should not do so for policy reasons. * * This method will only be called if data is available. Implementations do * not need to handle the case of no results specially. * * @param list Results from @{method:willFilterPage()}. * @return list Objects after additional * non-policy processing. */ protected function didFilterPage(array $page) { return $page; } /** * Hook for removing filtered results from alternate result sets. This * hook will be called with any objects which were returned by the query but * filtered for policy reasons. The query should remove them from any cached * or partial result sets. * * @param list List of objects that should not be returned by alternate * result mechanisms. * @return void * @task policyimpl */ protected function didFilterResults(array $results) { return; } /** * Hook for applying final adjustments before results are returned. This is * used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results * that are queried during reverse paging. * * @param list Query results. * @return list Final results. * @task policyimpl */ protected function didLoadResults(array $results) { return $results; } /** * Allows a subclass to disable policy filtering. This method is dangerous. * It should be used only if the query loads data which has already been * filtered (for example, because it wraps some other query which uses * normal policy filtering). * * @return bool True to disable all policy filtering. * @task policyimpl */ protected function shouldDisablePolicyFiltering() { return false; } /** * If this query belongs to an application, return the application class name * here. This will prevent the query from returning results if the viewer can * not access the application. * * If this query does not belong to an application, return `null`. * * @return string|null Application class name. */ abstract public function getQueryApplicationClass(); /** * Determine if the viewer has permission to use this query's application. * For queries which aren't part of an application, this method always returns * true. * * @return bool True if the viewer has application-level permission to * execute the query. */ public function canViewerUseQueryApplication() { if ($this->canUseApplication === null) { $class = $this->getQueryApplicationClass(); if (!$class) { $this->canUseApplication = true; } else { $result = id(new PhabricatorApplicationQuery()) ->setViewer($this->getViewer()) ->withClasses(array($class)) ->execute(); $this->canUseApplication = (bool)$result; } } return $this->canUseApplication; } }