diff --git a/src/applications/differential/query/DifferentialRevisionQuery.php b/src/applications/differential/query/DifferentialRevisionQuery.php index 69e9477e0d..30731b6fed 100644 --- a/src/applications/differential/query/DifferentialRevisionQuery.php +++ b/src/applications/differential/query/DifferentialRevisionQuery.php @@ -1,1152 +1,1156 @@ withStatus(DifferentialRevisionQuery::STATUS_OPEN) * ->execute(); * * @task config Query Configuration * @task exec Query Execution * @task internal Internals */ final class DifferentialRevisionQuery extends PhabricatorCursorPagedPolicyAwareQuery { private $pathIDs = array(); private $status = 'status-any'; const STATUS_ANY = 'status-any'; const STATUS_OPEN = 'status-open'; const STATUS_ACCEPTED = 'status-accepted'; const STATUS_NEEDS_REVIEW = 'status-needs-review'; const STATUS_NEEDS_REVISION = 'status-needs-revision'; const STATUS_CLOSED = 'status-closed'; const STATUS_ABANDONED = 'status-abandoned'; private $authors = array(); private $draftAuthors = array(); private $ccs = array(); private $reviewers = array(); private $revIDs = array(); private $commitHashes = array(); private $commitPHIDs = array(); private $phids = array(); private $responsibles = array(); private $branches = array(); private $repositoryPHIDs; private $updatedEpochMin; private $updatedEpochMax; const ORDER_MODIFIED = 'order-modified'; const ORDER_CREATED = 'order-created'; private $needRelationships = false; private $needActiveDiffs = false; private $needDiffIDs = false; private $needCommitPHIDs = false; private $needHashes = false; private $needReviewerStatus = false; private $needReviewerAuthority; private $needDrafts; private $needFlags; private $buildingGlobalOrder; /* -( Query Configuration )------------------------------------------------ */ /** * Filter results to revisions which affect a Diffusion path ID in a given * repository. You can call this multiple times to select revisions for * several paths. * * @param int Diffusion repository ID. * @param int Diffusion path ID. * @return this * @task config */ public function withPath($repository_id, $path_id) { $this->pathIDs[] = array( 'repositoryID' => $repository_id, 'pathID' => $path_id, ); return $this; } /** * Filter results to revisions authored by one of the given PHIDs. Calling * this function will clear anything set by previous calls to * @{method:withAuthors}. * * @param array List of PHIDs of authors * @return this * @task config */ public function withAuthors(array $author_phids) { $this->authors = $author_phids; return $this; } /** * Filter results to revisions with comments authored by the given PHIDs. * * @param array List of PHIDs of authors * @return this * @task config */ public function withDraftRepliesByAuthors(array $author_phids) { $this->draftAuthors = $author_phids; return $this; } /** * Filter results to revisions which CC one of the listed people. Calling this * function will clear anything set by previous calls to @{method:withCCs}. * * @param array List of PHIDs of subscribers. * @return this * @task config */ public function withCCs(array $cc_phids) { $this->ccs = $cc_phids; return $this; } /** * Filter results to revisions that have one of the provided PHIDs as * reviewers. Calling this function will clear anything set by previous calls * to @{method:withReviewers}. * * @param array List of PHIDs of reviewers * @return this * @task config */ public function withReviewers(array $reviewer_phids) { $this->reviewers = $reviewer_phids; return $this; } /** * Filter results to revisions that have one of the provided commit hashes. * Calling this function will clear anything set by previous calls to * @{method:withCommitHashes}. * * @param array List of pairs * @return this * @task config */ public function withCommitHashes(array $commit_hashes) { $this->commitHashes = $commit_hashes; return $this; } /** * Filter results to revisions that have one of the provided PHIDs as * commits. Calling this function will clear anything set by previous calls * to @{method:withCommitPHIDs}. * * @param array List of PHIDs of commits * @return this * @task config */ public function withCommitPHIDs(array $commit_phids) { $this->commitPHIDs = $commit_phids; return $this; } /** * Filter results to revisions with a given status. Provide a class constant, * such as `DifferentialRevisionQuery::STATUS_OPEN`. * * @param const Class STATUS constant, like STATUS_OPEN. * @return this * @task config */ public function withStatus($status_constant) { $this->status = $status_constant; return $this; } /** * Filter results to revisions on given branches. * * @param list List of branch names. * @return this * @task config */ public function withBranches(array $branches) { $this->branches = $branches; return $this; } /** * Filter results to only return revisions whose ids are in the given set. * * @param array List of revision ids * @return this * @task config */ public function withIDs(array $ids) { $this->revIDs = $ids; return $this; } /** * Filter results to only return revisions whose PHIDs are in the given set. * * @param array List of revision PHIDs * @return this * @task config */ public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } /** * Given a set of users, filter results to return only revisions they are * responsible for (i.e., they are either authors or reviewers). * * @param array List of user PHIDs. * @return this * @task config */ public function withResponsibleUsers(array $responsible_phids) { $this->responsibles = $responsible_phids; return $this; } public function withRepositoryPHIDs(array $repository_phids) { $this->repositoryPHIDs = $repository_phids; return $this; } public function withUpdatedEpochBetween($min, $max) { $this->updatedEpochMin = $min; $this->updatedEpochMax = $max; return $this; } /** * Set result ordering. Provide a class constant, such as * `DifferentialRevisionQuery::ORDER_CREATED`. * * @task config */ public function setOrder($order_constant) { switch ($order_constant) { case self::ORDER_CREATED: $this->setOrderVector(array('id')); break; case self::ORDER_MODIFIED: $this->setOrderVector(array('updated', 'id')); break; default: throw new Exception(pht('Unknown order "%s".', $order_constant)); } return $this; } /** * Set whether or not the query will load and attach relationships. * * @param bool True to load and attach relationships. * @return this * @task config */ public function needRelationships($need_relationships) { $this->needRelationships = $need_relationships; return $this; } /** * Set whether or not the query should load the active diff for each * revision. * * @param bool True to load and attach diffs. * @return this * @task config */ public function needActiveDiffs($need_active_diffs) { $this->needActiveDiffs = $need_active_diffs; return $this; } /** * Set whether or not the query should load the associated commit PHIDs for * each revision. * * @param bool True to load and attach diffs. * @return this * @task config */ public function needCommitPHIDs($need_commit_phids) { $this->needCommitPHIDs = $need_commit_phids; return $this; } /** * Set whether or not the query should load associated diff IDs for each * revision. * * @param bool True to load and attach diff IDs. * @return this * @task config */ public function needDiffIDs($need_diff_ids) { $this->needDiffIDs = $need_diff_ids; return $this; } /** * Set whether or not the query should load associated commit hashes for each * revision. * * @param bool True to load and attach commit hashes. * @return this * @task config */ public function needHashes($need_hashes) { $this->needHashes = $need_hashes; return $this; } /** * Set whether or not the query should load associated reviewer status. * * @param bool True to load and attach reviewers. * @return this * @task config */ public function needReviewerStatus($need_reviewer_status) { $this->needReviewerStatus = $need_reviewer_status; return $this; } /** * Request information about the viewer's authority to act on behalf of each * reviewer. In particular, they have authority to act on behalf of projects * they are a member of. * * @param bool True to load and attach authority. * @return this * @task config */ public function needReviewerAuthority($need_reviewer_authority) { $this->needReviewerAuthority = $need_reviewer_authority; return $this; } public function needFlags($need_flags) { $this->needFlags = $need_flags; return $this; } public function needDrafts($need_drafts) { $this->needDrafts = $need_drafts; return $this; } /* -( Query Execution )---------------------------------------------------- */ /** * Execute the query as configured, returning matching * @{class:DifferentialRevision} objects. * * @return list List of matching DifferentialRevision objects. * @task exec */ protected function loadPage() { $table = new DifferentialRevision(); $conn_r = $table->establishConnection('r'); $data = $this->loadData(); return $table->loadAllFromArray($data); } protected function willFilterPage(array $revisions) { $viewer = $this->getViewer(); $repository_phids = mpull($revisions, 'getRepositoryPHID'); $repository_phids = array_filter($repository_phids); $repositories = array(); if ($repository_phids) { $repositories = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withPHIDs($repository_phids) ->execute(); $repositories = mpull($repositories, null, 'getPHID'); } // If a revision is associated with a repository: // // - the viewer must be able to see the repository; or // - the viewer must have an automatic view capability. // // In the latter case, we'll load the revision but not load the repository. $can_view = PhabricatorPolicyCapability::CAN_VIEW; foreach ($revisions as $key => $revision) { $repo_phid = $revision->getRepositoryPHID(); if (!$repo_phid) { // The revision has no associated repository. Attach `null` and move on. $revision->attachRepository(null); continue; } $repository = idx($repositories, $repo_phid); if ($repository) { // The revision has an associated repository, and the viewer can see // it. Attach it and move on. $revision->attachRepository($repository); continue; } if ($revision->hasAutomaticCapability($can_view, $viewer)) { // The revision has an associated repository which the viewer can not // see, but the viewer has an automatic capability on this revision. // Load the revision without attaching a repository. $revision->attachRepository(null); continue; } if ($this->getViewer()->isOmnipotent()) { // The viewer is omnipotent. Allow the revision to load even without // a repository. $revision->attachRepository(null); continue; } // The revision has an associated repository, and the viewer can't see // it, and the viewer has no special capabilities. Filter out this // revision. $this->didRejectResult($revision); unset($revisions[$key]); } if (!$revisions) { return array(); } $table = new DifferentialRevision(); $conn_r = $table->establishConnection('r'); if ($this->needRelationships) { $this->loadRelationships($conn_r, $revisions); } if ($this->needCommitPHIDs) { $this->loadCommitPHIDs($conn_r, $revisions); } $need_active = $this->needActiveDiffs; $need_ids = $need_active || $this->needDiffIDs; if ($need_ids) { $this->loadDiffIDs($conn_r, $revisions); } if ($need_active) { $this->loadActiveDiffs($conn_r, $revisions); } if ($this->needHashes) { $this->loadHashes($conn_r, $revisions); } if ($this->needReviewerStatus || $this->needReviewerAuthority) { $this->loadReviewers($conn_r, $revisions); } return $revisions; } protected function didFilterPage(array $revisions) { $viewer = $this->getViewer(); if ($this->needFlags) { $flags = id(new PhabricatorFlagQuery()) ->setViewer($viewer) ->withOwnerPHIDs(array($viewer->getPHID())) ->withObjectPHIDs(mpull($revisions, 'getPHID')) ->execute(); $flags = mpull($flags, null, 'getObjectPHID'); foreach ($revisions as $revision) { $revision->attachFlag( $viewer, idx($flags, $revision->getPHID())); } } if ($this->needDrafts) { $drafts = id(new DifferentialDraft())->loadAllWhere( 'authorPHID = %s AND objectPHID IN (%Ls)', $viewer->getPHID(), mpull($revisions, 'getPHID')); $drafts = mgroup($drafts, 'getObjectPHID'); foreach ($revisions as $revision) { $revision->attachDrafts( $viewer, idx($drafts, $revision->getPHID(), array())); } } return $revisions; } private function loadData() { $table = new DifferentialRevision(); $conn_r = $table->establishConnection('r'); $selects = array(); // NOTE: If the query includes "responsiblePHIDs", we execute it as a // UNION of revisions they own and revisions they're reviewing. This has // much better performance than doing it with JOIN/WHERE. if ($this->responsibles) { $basic_authors = $this->authors; $basic_reviewers = $this->reviewers; $authority_projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withMemberPHIDs($this->responsibles) ->execute(); $authority_phids = mpull($authority_projects, 'getPHID'); try { // Build the query where the responsible users are authors. $this->authors = array_merge($basic_authors, $this->responsibles); $this->reviewers = $basic_reviewers; $selects[] = $this->buildSelectStatement($conn_r); // Build the query where the responsible users are reviewers, or // projects they are members of are reviewers. $this->authors = $basic_authors; $this->reviewers = array_merge( $basic_reviewers, $this->responsibles, $authority_phids); $selects[] = $this->buildSelectStatement($conn_r); // Put everything back like it was. $this->authors = $basic_authors; $this->reviewers = $basic_reviewers; } catch (Exception $ex) { $this->authors = $basic_authors; $this->reviewers = $basic_reviewers; throw $ex; } } else { $selects[] = $this->buildSelectStatement($conn_r); } if (count($selects) > 1) { $this->buildingGlobalOrder = true; $query = qsprintf( $conn_r, '%Q %Q %Q', implode(' UNION DISTINCT ', $selects), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); } else { $query = head($selects); } return queryfx_all($conn_r, '%Q', $query); } private function buildSelectStatement(AphrontDatabaseConnection $conn_r) { $table = new DifferentialRevision(); $select = $this->buildSelectClause($conn_r); $from = qsprintf( $conn_r, 'FROM %T r', $table->getTableName()); $joins = $this->buildJoinsClause($conn_r); $where = $this->buildWhereClause($conn_r); $group_by = $this->buildGroupByClause($conn_r); $having = $this->buildHavingClause($conn_r); $this->buildingGlobalOrder = false; $order_by = $this->buildOrderClause($conn_r); $limit = $this->buildLimitClause($conn_r); return qsprintf( $conn_r, '(%Q %Q %Q %Q %Q %Q %Q %Q)', $select, $from, $joins, $where, $group_by, $having, $order_by, $limit); } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function buildJoinsClause($conn_r) { $joins = array(); if ($this->pathIDs) { $path_table = new DifferentialAffectedPath(); $joins[] = qsprintf( $conn_r, 'JOIN %T p ON p.revisionID = r.id', $path_table->getTableName()); } if ($this->commitHashes) { $joins[] = qsprintf( $conn_r, 'JOIN %T hash_rel ON hash_rel.revisionID = r.id', ArcanistDifferentialRevisionHash::TABLE_NAME); } if ($this->ccs) { $joins[] = qsprintf( $conn_r, 'JOIN %T e_ccs ON e_ccs.src = r.phid '. 'AND e_ccs.type = %s '. 'AND e_ccs.dst in (%Ls)', PhabricatorEdgeConfig::TABLE_NAME_EDGE, PhabricatorObjectHasSubscriberEdgeType::EDGECONST, $this->ccs); } if ($this->reviewers) { $joins[] = qsprintf( $conn_r, 'JOIN %T e_reviewers ON e_reviewers.src = r.phid '. 'AND e_reviewers.type = %s '. 'AND e_reviewers.dst in (%Ls)', PhabricatorEdgeConfig::TABLE_NAME_EDGE, DifferentialRevisionHasReviewerEdgeType::EDGECONST, $this->reviewers); } if ($this->draftAuthors) { $differential_draft = new DifferentialDraft(); $joins[] = qsprintf( $conn_r, 'JOIN %T has_draft ON has_draft.objectPHID = r.phid '. 'AND has_draft.authorPHID IN (%Ls)', $differential_draft->getTableName(), $this->draftAuthors); } if ($this->commitPHIDs) { $joins[] = qsprintf( $conn_r, 'JOIN %T commits ON commits.revisionID = r.id', DifferentialRevision::TABLE_COMMIT); } $joins[] = $this->buildJoinClauseParts($conn_r); return $this->formatJoinClause($joins); } /** * @task internal */ protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); if ($this->pathIDs) { $path_clauses = array(); $repo_info = igroup($this->pathIDs, 'repositoryID'); foreach ($repo_info as $repository_id => $paths) { $path_clauses[] = qsprintf( $conn_r, '(p.repositoryID = %d AND p.pathID IN (%Ld))', $repository_id, ipull($paths, 'pathID')); } $path_clauses = '('.implode(' OR ', $path_clauses).')'; $where[] = $path_clauses; } if ($this->authors) { $where[] = qsprintf( $conn_r, 'r.authorPHID IN (%Ls)', $this->authors); } if ($this->revIDs) { $where[] = qsprintf( $conn_r, 'r.id IN (%Ld)', $this->revIDs); } if ($this->repositoryPHIDs) { $where[] = qsprintf( $conn_r, 'r.repositoryPHID IN (%Ls)', $this->repositoryPHIDs); } if ($this->commitHashes) { $hash_clauses = array(); foreach ($this->commitHashes as $info) { list($type, $hash) = $info; $hash_clauses[] = qsprintf( $conn_r, '(hash_rel.type = %s AND hash_rel.hash = %s)', $type, $hash); } $hash_clauses = '('.implode(' OR ', $hash_clauses).')'; $where[] = $hash_clauses; } if ($this->commitPHIDs) { $where[] = qsprintf( $conn_r, 'commits.commitPHID IN (%Ls)', $this->commitPHIDs); } if ($this->phids) { $where[] = qsprintf( $conn_r, 'r.phid IN (%Ls)', $this->phids); } if ($this->branches) { $where[] = qsprintf( $conn_r, 'r.branchName in (%Ls)', $this->branches); } if ($this->updatedEpochMin !== null) { $where[] = qsprintf( $conn_r, 'r.dateModified >= %d', $this->updatedEpochMin); } if ($this->updatedEpochMax !== null) { $where[] = qsprintf( $conn_r, 'r.dateModified <= %d', $this->updatedEpochMax); } + // NOTE: Although the status constants are integers in PHP, the column is a + // string column in MySQL, and MySQL won't use keys on string columns if + // you put integers in the query. + switch ($this->status) { case self::STATUS_ANY: break; case self::STATUS_OPEN: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', DifferentialRevisionStatus::getOpenStatuses()); break; case self::STATUS_NEEDS_REVIEW: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', array( ArcanistDifferentialRevisionStatus::NEEDS_REVIEW, )); break; case self::STATUS_NEEDS_REVISION: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', array( ArcanistDifferentialRevisionStatus::NEEDS_REVISION, )); break; case self::STATUS_ACCEPTED: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', array( ArcanistDifferentialRevisionStatus::ACCEPTED, )); break; case self::STATUS_CLOSED: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', DifferentialRevisionStatus::getClosedStatuses()); break; case self::STATUS_ABANDONED: $where[] = qsprintf( $conn_r, - 'r.status IN (%Ld)', + 'r.status IN (%Ls)', array( ArcanistDifferentialRevisionStatus::ABANDONED, )); break; default: throw new Exception( pht("Unknown revision status filter constant '%s'!", $this->status)); } $where[] = $this->buildWhereClauseParts($conn_r); return $this->formatWhereClause($where); } /** * @task internal */ private function buildGroupByClause($conn_r) { $join_triggers = array_merge( $this->pathIDs, $this->ccs, $this->reviewers); $needs_distinct = (count($join_triggers) > 1); if ($needs_distinct) { return 'GROUP BY r.id'; } else { return ''; } } protected function getDefaultOrderVector() { return array('updated', 'id'); } public function getOrderableColumns() { $primary = ($this->buildingGlobalOrder ? null : 'r'); return array( 'id' => array( 'table' => $primary, 'column' => 'id', 'type' => 'int', 'unique' => true, ), 'updated' => array( 'table' => $primary, 'column' => 'dateModified', 'type' => 'int', ), ); } protected function getPagingValueMap($cursor, array $keys) { $revision = $this->loadCursorObject($cursor); return array( 'id' => $revision->getID(), 'updated' => $revision->getDateModified(), ); } private function loadRelationships($conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $type_reviewer = DifferentialRevisionHasReviewerEdgeType::EDGECONST; $type_subscriber = PhabricatorObjectHasSubscriberEdgeType::EDGECONST; $edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(mpull($revisions, 'getPHID')) ->withEdgeTypes(array($type_reviewer, $type_subscriber)) ->setOrder(PhabricatorEdgeQuery::ORDER_OLDEST_FIRST) ->execute(); $type_map = array( DifferentialRevision::RELATION_REVIEWER => $type_reviewer, DifferentialRevision::RELATION_SUBSCRIBED => $type_subscriber, ); foreach ($revisions as $revision) { $data = array(); foreach ($type_map as $rel_type => $edge_type) { $revision_edges = $edges[$revision->getPHID()][$edge_type]; foreach ($revision_edges as $dst_phid => $edge_data) { $data[] = array( 'relation' => $rel_type, 'objectPHID' => $dst_phid, 'reasonPHID' => null, ); } } $revision->attachRelationships($data); } } private function loadCommitPHIDs($conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $commit_phids = queryfx_all( $conn_r, 'SELECT * FROM %T WHERE revisionID IN (%Ld)', DifferentialRevision::TABLE_COMMIT, mpull($revisions, 'getID')); $commit_phids = igroup($commit_phids, 'revisionID'); foreach ($revisions as $revision) { $phids = idx($commit_phids, $revision->getID(), array()); $phids = ipull($phids, 'commitPHID'); $revision->attachCommitPHIDs($phids); } } private function loadDiffIDs($conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $diff_table = new DifferentialDiff(); $diff_ids = queryfx_all( $conn_r, 'SELECT revisionID, id FROM %T WHERE revisionID IN (%Ld) ORDER BY id DESC', $diff_table->getTableName(), mpull($revisions, 'getID')); $diff_ids = igroup($diff_ids, 'revisionID'); foreach ($revisions as $revision) { $ids = idx($diff_ids, $revision->getID(), array()); $ids = ipull($ids, 'id'); $revision->attachDiffIDs($ids); } } private function loadActiveDiffs($conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $diff_table = new DifferentialDiff(); $load_ids = array(); foreach ($revisions as $revision) { $diffs = $revision->getDiffIDs(); if ($diffs) { $load_ids[] = max($diffs); } } $active_diffs = array(); if ($load_ids) { $active_diffs = $diff_table->loadAllWhere( 'id IN (%Ld)', $load_ids); } $active_diffs = mpull($active_diffs, null, 'getRevisionID'); foreach ($revisions as $revision) { $revision->attachActiveDiff(idx($active_diffs, $revision->getID())); } } private function loadHashes( AphrontDatabaseConnection $conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $data = queryfx_all( $conn_r, 'SELECT * FROM %T WHERE revisionID IN (%Ld)', 'differential_revisionhash', mpull($revisions, 'getID')); $data = igroup($data, 'revisionID'); foreach ($revisions as $revision) { $hashes = idx($data, $revision->getID(), array()); $list = array(); foreach ($hashes as $hash) { $list[] = array($hash['type'], $hash['hash']); } $revision->attachHashes($list); } } private function loadReviewers( AphrontDatabaseConnection $conn_r, array $revisions) { assert_instances_of($revisions, 'DifferentialRevision'); $edge_type = DifferentialRevisionHasReviewerEdgeType::EDGECONST; $edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(mpull($revisions, 'getPHID')) ->withEdgeTypes(array($edge_type)) ->needEdgeData(true) ->setOrder(PhabricatorEdgeQuery::ORDER_OLDEST_FIRST) ->execute(); $viewer = $this->getViewer(); $viewer_phid = $viewer->getPHID(); $allow_key = 'differential.allow-self-accept'; $allow_self = PhabricatorEnv::getEnvConfig($allow_key); // Figure out which of these reviewers the viewer has authority to act as. if ($this->needReviewerAuthority && $viewer_phid) { $authority = $this->loadReviewerAuthority( $revisions, $edges, $allow_self); } foreach ($revisions as $revision) { $revision_edges = $edges[$revision->getPHID()][$edge_type]; $reviewers = array(); foreach ($revision_edges as $reviewer_phid => $edge) { $reviewer = new DifferentialReviewer($reviewer_phid, $edge['data']); if ($this->needReviewerAuthority) { if (!$viewer_phid) { // Logged-out users never have authority. $has_authority = false; } else if ((!$allow_self) && ($revision->getAuthorPHID() == $viewer_phid)) { // The author can never have authority unless we allow self-accept. $has_authority = false; } else { // Otherwise, look up whether the viewer has authority. $has_authority = isset($authority[$reviewer_phid]); } $reviewer->attachAuthority($viewer, $has_authority); } $reviewers[$reviewer_phid] = $reviewer; } $revision->attachReviewerStatus($reviewers); } } public static function splitResponsible(array $revisions, array $user_phids) { $blocking = array(); $active = array(); $waiting = array(); $status_review = ArcanistDifferentialRevisionStatus::NEEDS_REVIEW; // Bucket revisions into $blocking (revisions where you are blocking // others), $active (revisions you need to do something about) and $waiting // (revisions you're waiting on someone else to do something about). foreach ($revisions as $revision) { $needs_review = ($revision->getStatus() == $status_review); $filter_is_author = in_array($revision->getAuthorPHID(), $user_phids); if (!$revision->getReviewers()) { $needs_review = false; $author_is_reviewer = false; } else { $author_is_reviewer = in_array( $revision->getAuthorPHID(), $revision->getReviewers()); } // If exactly one of "needs review" and "the user is the author" is // true, the user needs to act on it. Otherwise, they're waiting on // it. if ($needs_review ^ $filter_is_author) { if ($needs_review) { array_unshift($blocking, $revision); } else { $active[] = $revision; } // User is author **and** reviewer. An exotic but configurable workflow. // User needs to act on it double. } else if ($needs_review && $author_is_reviewer) { array_unshift($blocking, $revision); $active[] = $revision; } else { $waiting[] = $revision; } } return array($blocking, $active, $waiting); } private function loadReviewerAuthority( array $revisions, array $edges, $allow_self) { $revision_map = mpull($revisions, null, 'getPHID'); $viewer_phid = $this->getViewer()->getPHID(); // Find all the project reviewers which the user may have authority over. $project_phids = array(); $project_type = PhabricatorProjectProjectPHIDType::TYPECONST; $edge_type = DifferentialRevisionHasReviewerEdgeType::EDGECONST; foreach ($edges as $src => $types) { if (!$allow_self) { if ($revision_map[$src]->getAuthorPHID() == $viewer_phid) { // If self-review isn't permitted, the user will never have // authority over projects on revisions they authored because you // can't accept your own revisions, so we don't need to load any // data about these reviewers. continue; } } $edge_data = idx($types, $edge_type, array()); foreach ($edge_data as $dst => $data) { if (phid_get_type($dst) == $project_type) { $project_phids[] = $dst; } } } // Now, figure out which of these projects the viewer is actually a // member of. $project_authority = array(); if ($project_phids) { $project_authority = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withPHIDs($project_phids) ->withMemberPHIDs(array($viewer_phid)) ->execute(); $project_authority = mpull($project_authority, 'getPHID'); } // Finally, the viewer has authority over themselves. return array( $viewer_phid => true, ) + array_fuse($project_authority); } public function getQueryApplicationClass() { return 'PhabricatorDifferentialApplication'; } protected function getPrimaryTableAlias() { return 'r'; } } diff --git a/src/applications/differential/storage/DifferentialRevision.php b/src/applications/differential/storage/DifferentialRevision.php index 5f2131475b..affa0c4d9a 100644 --- a/src/applications/differential/storage/DifferentialRevision.php +++ b/src/applications/differential/storage/DifferentialRevision.php @@ -1,626 +1,634 @@ setViewer($actor) ->withClasses(array('PhabricatorDifferentialApplication')) ->executeOne(); $view_policy = $app->getPolicy( DifferentialDefaultViewCapability::CAPABILITY); return id(new DifferentialRevision()) ->setViewPolicy($view_policy) ->setAuthorPHID($actor->getPHID()) ->attachRelationships(array()) ->attachRepository(null) ->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'attached' => self::SERIALIZATION_JSON, 'unsubscribed' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'title' => 'text255', 'originalTitle' => 'text255', 'status' => 'text32', 'summary' => 'text', 'testPlan' => 'text', 'authorPHID' => 'phid?', 'lastReviewerPHID' => 'phid?', 'lineCount' => 'uint32?', 'mailKey' => 'bytes40', 'branchName' => 'text255?', 'arcanistProjectPHID' => 'phid?', 'repositoryPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'authorPHID' => array( 'columns' => array('authorPHID', 'status'), ), 'repositoryPHID' => array( 'columns' => array('repositoryPHID'), ), + // If you (or a project you are a member of) is reviewing a significant + // fraction of the revisions on an install, the result set of open + // revisions may be smaller than the result set of revisions where you + // are a reviewer. In these cases, this key is better than keys on the + // edge table. + 'key_status' => array( + 'columns' => array('status', 'phid'), + ), ), ) + parent::getConfiguration(); } public function getMonogram() { $id = $this->getID(); return "D{$id}"; } public function setTitle($title) { $this->title = $title; if (!$this->getID()) { $this->originalTitle = $title; } return $this; } public function loadIDsByCommitPHIDs($phids) { if (!$phids) { return array(); } $revision_ids = queryfx_all( $this->establishConnection('r'), 'SELECT * FROM %T WHERE commitPHID IN (%Ls)', self::TABLE_COMMIT, $phids); return ipull($revision_ids, 'revisionID', 'commitPHID'); } public function loadCommitPHIDs() { if (!$this->getID()) { return ($this->commits = array()); } $commits = queryfx_all( $this->establishConnection('r'), 'SELECT commitPHID FROM %T WHERE revisionID = %d', self::TABLE_COMMIT, $this->getID()); $commits = ipull($commits, 'commitPHID'); return ($this->commits = $commits); } public function getCommitPHIDs() { return $this->assertAttached($this->commits); } public function getActiveDiff() { // TODO: Because it's currently technically possible to create a revision // without an associated diff, we allow an attached-but-null active diff. // It would be good to get rid of this once we make diff-attaching // transactional. return $this->assertAttached($this->activeDiff); } public function attachActiveDiff($diff) { $this->activeDiff = $diff; return $this; } public function getDiffIDs() { return $this->assertAttached($this->diffIDs); } public function attachDiffIDs(array $ids) { rsort($ids); $this->diffIDs = array_values($ids); return $this; } public function attachCommitPHIDs(array $phids) { $this->commits = array_values($phids); return $this; } public function getAttachedPHIDs($type) { return array_keys(idx($this->attached, $type, array())); } public function setAttachedPHIDs($type, array $phids) { $this->attached[$type] = array_fill_keys($phids, array()); return $this; } public function generatePHID() { return PhabricatorPHID::generateNewPHID( DifferentialRevisionPHIDType::TYPECONST); } public function loadActiveDiff() { return id(new DifferentialDiff())->loadOneWhere( 'revisionID = %d ORDER BY id DESC LIMIT 1', $this->getID()); } public function save() { if (!$this->getMailKey()) { $this->mailKey = Filesystem::readRandomCharacters(40); } return parent::save(); } public function loadRelationships() { if (!$this->getID()) { $this->relationships = array(); return; } $data = array(); $subscriber_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $this->getPHID(), PhabricatorObjectHasSubscriberEdgeType::EDGECONST); $subscriber_phids = array_reverse($subscriber_phids); foreach ($subscriber_phids as $phid) { $data[] = array( 'relation' => self::RELATION_SUBSCRIBED, 'objectPHID' => $phid, 'reasonPHID' => null, ); } $reviewer_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $this->getPHID(), DifferentialRevisionHasReviewerEdgeType::EDGECONST); $reviewer_phids = array_reverse($reviewer_phids); foreach ($reviewer_phids as $phid) { $data[] = array( 'relation' => self::RELATION_REVIEWER, 'objectPHID' => $phid, 'reasonPHID' => null, ); } return $this->attachRelationships($data); } public function attachRelationships(array $relationships) { $this->relationships = igroup($relationships, 'relation'); return $this; } public function getReviewers() { return $this->getRelatedPHIDs(self::RELATION_REVIEWER); } public function getCCPHIDs() { return $this->getRelatedPHIDs(self::RELATION_SUBSCRIBED); } private function getRelatedPHIDs($relation) { $this->assertAttached($this->relationships); return ipull($this->getRawRelations($relation), 'objectPHID'); } public function getRawRelations($relation) { return idx($this->relationships, $relation, array()); } public function getPrimaryReviewer() { $reviewers = $this->getReviewers(); $last = $this->lastReviewerPHID; if (!$last || !in_array($last, $reviewers)) { return head($this->getReviewers()); } return $last; } public function getHashes() { return $this->assertAttached($this->hashes); } public function attachHashes(array $hashes) { $this->hashes = $hashes; return $this; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $user) { // A revision's author (which effectively means "owner" after we added // commandeering) can always view and edit it. $author_phid = $this->getAuthorPHID(); if ($author_phid) { if ($user->getPHID() == $author_phid) { return true; } } return false; } public function describeAutomaticCapability($capability) { $description = array( pht('The owner of a revision can always view and edit it.'), ); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $description[] = pht("A revision's reviewers can always view it."); $description[] = pht( 'If a revision belongs to a repository, other users must be able '. 'to view the repository in order to view the revision.'); break; } return $description; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: // NOTE: In Differential, an automatic capability on a revision (being // an author) is sufficient to view it, even if you can not see the // repository the revision belongs to. We can bail out early in this // case. if ($this->hasAutomaticCapability($capability, $viewer)) { break; } $repository_phid = $this->getRepositoryPHID(); $repository = $this->getRepository(); // Try to use the object if we have it, since it will save us some // data fetching later on. In some cases, we might not have it. $repository_ref = nonempty($repository, $repository_phid); if ($repository_ref) { $extended[] = array( $repository_ref, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getAuthorPHID(), ); } public function getReviewerStatus() { return $this->assertAttached($this->reviewerStatus); } public function attachReviewerStatus(array $reviewers) { assert_instances_of($reviewers, 'DifferentialReviewer'); $this->reviewerStatus = $reviewers; return $this; } public function getRepository() { return $this->assertAttached($this->repository); } public function attachRepository(PhabricatorRepository $repository = null) { $this->repository = $repository; return $this; } public function isClosed() { return DifferentialRevisionStatus::isClosedStatus($this->getStatus()); } public function getFlag(PhabricatorUser $viewer) { return $this->assertAttachedKey($this->flags, $viewer->getPHID()); } public function attachFlag( PhabricatorUser $viewer, PhabricatorFlag $flag = null) { $this->flags[$viewer->getPHID()] = $flag; return $this; } public function getDrafts(PhabricatorUser $viewer) { return $this->assertAttachedKey($this->drafts, $viewer->getPHID()); } public function attachDrafts(PhabricatorUser $viewer, array $drafts) { $this->drafts[$viewer->getPHID()] = $drafts; return $this; } /* -( HarbormasterBuildableInterface )------------------------------------- */ public function getHarbormasterBuildablePHID() { return $this->loadActiveDiff()->getPHID(); } public function getHarbormasterContainerPHID() { return $this->getPHID(); } public function getBuildVariables() { return array(); } public function getAvailableBuildVariables() { return array(); } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { if ($phid == $this->getAuthorPHID()) { return true; } // TODO: This only happens when adding or removing CCs, and is safe from a // policy perspective, but the subscription pathway should have some // opportunity to load this data properly. For now, this is the only case // where implicit subscription is not an intrinsic property of the object. if ($this->reviewerStatus == self::ATTACHABLE) { $reviewers = id(new DifferentialRevisionQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($this->getPHID())) ->needReviewerStatus(true) ->executeOne() ->getReviewerStatus(); } else { $reviewers = $this->getReviewerStatus(); } foreach ($reviewers as $reviewer) { if ($reviewer->getReviewerPHID() == $phid) { return true; } } return false; } public function shouldShowSubscribersProperty() { return true; } public function shouldAllowSubscription($phid) { return true; } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('differential.fields'); } public function getCustomFieldBaseClass() { return 'DifferentialCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new DifferentialTransactionEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new DifferentialTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { $viewer = $request->getViewer(); $render_data = $timeline->getRenderData(); $left = $request->getInt('left', idx($render_data, 'left')); $right = $request->getInt('right', idx($render_data, 'right')); $diffs = id(new DifferentialDiffQuery()) ->setViewer($request->getUser()) ->withIDs(array($left, $right)) ->execute(); $diffs = mpull($diffs, null, 'getID'); $left_diff = $diffs[$left]; $right_diff = $diffs[$right]; $old_ids = $request->getStr('old', idx($render_data, 'old')); $new_ids = $request->getStr('new', idx($render_data, 'new')); $old_ids = array_filter(explode(',', $old_ids)); $new_ids = array_filter(explode(',', $new_ids)); $type_inline = DifferentialTransaction::TYPE_INLINE; $changeset_ids = array_merge($old_ids, $new_ids); $inlines = array(); foreach ($timeline->getTransactions() as $xaction) { if ($xaction->getTransactionType() == $type_inline) { $inlines[] = $xaction->getComment(); $changeset_ids[] = $xaction->getComment()->getChangesetID(); } } if ($changeset_ids) { $changesets = id(new DifferentialChangesetQuery()) ->setViewer($request->getUser()) ->withIDs($changeset_ids) ->execute(); $changesets = mpull($changesets, null, 'getID'); } else { $changesets = array(); } foreach ($inlines as $key => $inline) { $inlines[$key] = DifferentialInlineComment::newFromModernComment( $inline); } $query = id(new DifferentialInlineCommentQuery()) ->needHidden(true) ->setViewer($viewer); // NOTE: This is a bit sketchy: this method adjusts the inlines as a // side effect, which means it will ultimately adjust the transaction // comments and affect timeline rendering. $query->adjustInlinesForChangesets( $inlines, array_select_keys($changesets, $old_ids), array_select_keys($changesets, $new_ids), $this); return $timeline ->setChangesets($changesets) ->setRevision($this) ->setLeftDiff($left_diff) ->setRightDiff($right_diff); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $diffs = id(new DifferentialDiffQuery()) ->setViewer($engine->getViewer()) ->withRevisionIDs(array($this->getID())) ->execute(); foreach ($diffs as $diff) { $engine->destroyObject($diff); } $conn_w = $this->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', self::TABLE_COMMIT, $this->getID()); // we have to do paths a little differentally as they do not have // an id or phid column for delete() to act on $dummy_path = new DifferentialAffectedPath(); queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', $dummy_path->getTableName(), $this->getID()); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php b/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php index b2475cdee5..9e7a9d11a9 100644 --- a/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php +++ b/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php @@ -1,435 +1,466 @@ overrideEnvConfig('policy.allow-public', true); $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_PUBLIC), array( 'public' => true, 'user' => true, 'admin' => true, ), pht('Public Policy (Enabled in Config)')); } /** * Verify that POLICY_PUBLIC is interpreted as POLICY_USER when public * policies are disallowed. */ public function testPublicPolicyDisabled() { $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('policy.allow-public', false); $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_PUBLIC), array( 'public' => false, 'user' => true, 'admin' => true, ), pht('Public Policy (Disabled in Config)')); } /** * Verify that any logged-in user can view an object with POLICY_USER, but * logged-out users can not. */ public function testUsersPolicy() { $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_USER), array( 'public' => false, 'user' => true, 'admin' => true, ), pht('User Policy')); } /** * Verify that only administrators can view an object with POLICY_ADMIN. */ public function testAdminPolicy() { $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_ADMIN), array( 'public' => false, 'user' => false, 'admin' => true, ), pht('Admin Policy')); } /** * Verify that no one can view an object with POLICY_NOONE. */ public function testNoOnePolicy() { $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_NOONE), array( 'public' => false, 'user' => false, 'admin' => false, ), pht('No One Policy')); } /** * Test offset-based filtering. */ public function testOffsets() { $results = array( $this->buildObject(PhabricatorPolicies::POLICY_NOONE), $this->buildObject(PhabricatorPolicies::POLICY_NOONE), $this->buildObject(PhabricatorPolicies::POLICY_NOONE), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), ); $query = new PhabricatorPolicyAwareTestQuery(); $query->setResults($results); $query->setViewer($this->buildUser('user')); $this->assertEqual( 3, count($query->setLimit(3)->setOffset(0)->execute()), pht('Invisible objects are ignored.')); $this->assertEqual( 0, count($query->setLimit(3)->setOffset(3)->execute()), pht('Offset pages through visible objects only.')); $this->assertEqual( 2, count($query->setLimit(3)->setOffset(1)->execute()), pht('Offsets work correctly.')); $this->assertEqual( 2, count($query->setLimit(0)->setOffset(1)->execute()), pht('Offset with no limit works.')); } /** * Test limits. */ public function testLimits() { $results = array( $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), $this->buildObject(PhabricatorPolicies::POLICY_USER), ); $query = new PhabricatorPolicyAwareTestQuery(); $query->setResults($results); $query->setViewer($this->buildUser('user')); $this->assertEqual( 3, count($query->setLimit(3)->setOffset(0)->execute()), pht('Limits work.')); $this->assertEqual( 2, count($query->setLimit(3)->setOffset(4)->execute()), pht('Limit + offset work.')); } /** * Test that omnipotent users bypass policies. */ public function testOmnipotence() { $results = array( $this->buildObject(PhabricatorPolicies::POLICY_NOONE), ); $query = new PhabricatorPolicyAwareTestQuery(); $query->setResults($results); $query->setViewer(PhabricatorUser::getOmnipotentUser()); $this->assertEqual( 1, count($query->execute())); } /** * Test that invalid policies reject viewers of all types. */ public function testRejectInvalidPolicy() { $invalid_policy = 'the duck goes quack'; $object = $this->buildObject($invalid_policy); $this->expectVisibility( $object = $this->buildObject($invalid_policy), array( 'public' => false, 'user' => false, 'admin' => false, ), pht('Invalid Policy')); } /** * Test that extended policies work. */ public function testExtendedPolicies() { $object = $this->buildObject(PhabricatorPolicies::POLICY_USER) ->setPHID('PHID-TEST-1'); $this->expectVisibility( $object, array( 'public' => false, 'user' => true, 'admin' => true, ), pht('No Extended Policy')); // Add a restrictive extended policy. $extended = $this->buildObject(PhabricatorPolicies::POLICY_ADMIN) ->setPHID('PHID-TEST-2'); $object->setExtendedPolicies( array( PhabricatorPolicyCapability::CAN_VIEW => array( array($extended, PhabricatorPolicyCapability::CAN_VIEW), ), )); $this->expectVisibility( $object, array( 'public' => false, 'user' => false, 'admin' => true, ), pht('With Extended Policy')); // Depend on a different capability. $object->setExtendedPolicies( array( PhabricatorPolicyCapability::CAN_VIEW => array( array($extended, PhabricatorPolicyCapability::CAN_EDIT), ), )); $extended->setCapabilities(array(PhabricatorPolicyCapability::CAN_EDIT)); $extended->setPolicies( array( PhabricatorPolicyCapability::CAN_EDIT => PhabricatorPolicies::POLICY_NOONE, )); $this->expectVisibility( $object, array( 'public' => false, 'user' => false, 'admin' => false, ), pht('With Extended Policy + Edit')); } /** * Test that cyclic extended policies are arrested properly. */ public function testExtendedPolicyCycles() { $object = $this->buildObject(PhabricatorPolicies::POLICY_USER) ->setPHID('PHID-TEST-1'); $this->expectVisibility( $object, array( 'public' => false, 'user' => true, 'admin' => true, ), pht('No Extended Policy')); // Set a self-referential extended policy on the object. This should // make it fail all policy checks. $object->setExtendedPolicies( array( PhabricatorPolicyCapability::CAN_VIEW => array( array($object, PhabricatorPolicyCapability::CAN_VIEW), ), )); $this->expectVisibility( $object, array( 'public' => false, 'user' => false, 'admin' => false, ), pht('Extended Policy with Cycle')); } /** * An omnipotent user should be able to see even objects with invalid * policies. */ public function testInvalidPolicyVisibleByOmnipotentUser() { $invalid_policy = 'the cow goes moo'; $object = $this->buildObject($invalid_policy); $results = array( $object, ); $query = new PhabricatorPolicyAwareTestQuery(); $query->setResults($results); $query->setViewer(PhabricatorUser::getOmnipotentUser()); $this->assertEqual( 1, count($query->execute())); } public function testAllQueriesBelongToActualApplications() { $queries = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorPolicyAwareQuery') ->loadObjects(); foreach ($queries as $qclass => $query) { $class = $query->getQueryApplicationClass(); if (!$class) { continue; } $this->assertTrue( (bool)PhabricatorApplication::getByClass($class), pht( "Application class '%s' for query '%s'.", $class, $qclass)); } } public function testMultipleCapabilities() { $object = new PhabricatorPolicyTestObject(); $object->setCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )); $object->setPolicies( array( PhabricatorPolicyCapability::CAN_VIEW => PhabricatorPolicies::POLICY_USER, PhabricatorPolicyCapability::CAN_EDIT => PhabricatorPolicies::POLICY_NOONE, )); $filter = new PhabricatorPolicyFilter(); $filter->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )); $filter->setViewer($this->buildUser('user')); $result = $filter->apply(array($object)); $this->assertEqual(array(), $result); } + public function testPolicyStrength() { + $public = PhabricatorPolicyQuery::getGlobalPolicy( + PhabricatorPolicies::POLICY_PUBLIC); + $user = PhabricatorPolicyQuery::getGlobalPolicy( + PhabricatorPolicies::POLICY_USER); + $admin = PhabricatorPolicyQuery::getGlobalPolicy( + PhabricatorPolicies::POLICY_ADMIN); + $noone = PhabricatorPolicyQuery::getGlobalPolicy( + PhabricatorPolicies::POLICY_NOONE); + + $this->assertFalse($public->isStrongerThan($public)); + $this->assertFalse($public->isStrongerThan($user)); + $this->assertFalse($public->isStrongerThan($admin)); + $this->assertFalse($public->isStrongerThan($noone)); + + $this->assertTrue($user->isStrongerThan($public)); + $this->assertFalse($user->isStrongerThan($user)); + $this->assertFalse($user->isStrongerThan($admin)); + $this->assertFalse($user->isStrongerThan($noone)); + + $this->assertTrue($admin->isStrongerThan($public)); + $this->assertTrue($admin->isStrongerThan($user)); + $this->assertFalse($admin->isStrongerThan($admin)); + $this->assertFalse($admin->isStrongerThan($noone)); + + $this->assertTrue($noone->isStrongerThan($public)); + $this->assertTrue($noone->isStrongerThan($user)); + $this->assertTrue($noone->isStrongerThan($admin)); + $this->assertFalse($admin->isStrongerThan($noone)); + } + /** * Test an object for visibility across multiple user specifications. */ private function expectVisibility( PhabricatorPolicyTestObject $object, array $map, $description) { foreach ($map as $spec => $expect) { $viewer = $this->buildUser($spec); $query = new PhabricatorPolicyAwareTestQuery(); $query->setResults(array($object)); $query->setViewer($viewer); $caught = null; $result = null; try { $result = $query->executeOne(); } catch (PhabricatorPolicyException $ex) { $caught = $ex; } if ($expect) { $this->assertEqual( $object, $result, pht('%s with user %s should succeed.', $description, $spec)); } else { $this->assertTrue( $caught instanceof PhabricatorPolicyException, pht('%s with user %s should fail.', $description, $spec)); } } } /** * Build a test object to spec. */ private function buildObject($policy) { $object = new PhabricatorPolicyTestObject(); $object->setCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, )); $object->setPolicies( array( PhabricatorPolicyCapability::CAN_VIEW => $policy, )); return $object; } /** * Build a test user to spec. */ private function buildUser($spec) { $user = new PhabricatorUser(); switch ($spec) { case 'public': break; case 'user': $user->setPHID(1); break; case 'admin': $user->setPHID(1); $user->setIsAdmin(true); break; default: throw new Exception(pht("Unknown user spec '%s'.", $spec)); } return $user; } } diff --git a/src/applications/policy/controller/PhabricatorPolicyExplainController.php b/src/applications/policy/controller/PhabricatorPolicyExplainController.php index 4224319d9c..46dfad3f57 100644 --- a/src/applications/policy/controller/PhabricatorPolicyExplainController.php +++ b/src/applications/policy/controller/PhabricatorPolicyExplainController.php @@ -1,112 +1,183 @@ phid = $data['phid']; $this->capability = $data['capability']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $phid = $this->phid; $capability = $this->capability; $object = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); if (!$object) { return new Aphront404Response(); } $policies = PhabricatorPolicyQuery::loadPolicies( $viewer, $object); $policy = idx($policies, $capability); if (!$policy) { return new Aphront404Response(); } $handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); $object_uri = nonempty($handle->getURI(), '/'); $explanation = PhabricatorPolicy::getPolicyExplanation( $viewer, $policy->getPHID()); $auto_info = (array)$object->describeAutomaticCapability($capability); $auto_info = array_merge( array($explanation), $auto_info); $auto_info = array_filter($auto_info); foreach ($auto_info as $key => $info) { $auto_info[$key] = phutil_tag('li', array(), $info); } if ($auto_info) { $auto_info = phutil_tag('ul', array(), $auto_info); } $capability_name = $capability; $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if ($capobj) { $capability_name = $capobj->getCapabilityName(); } - $space_info = null; - if ($object instanceof PhabricatorSpacesInterface) { - if (PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($viewer)) { - $space_phid = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID( - $object); - - $handles = $viewer->loadHandles(array($space_phid)); + $dialog = id(new AphrontDialogView()) + ->setUser($viewer) + ->setClass('aphront-access-dialog'); - $space_info = array( - pht( - 'This object is in %s, and can only be seen by users with '. - 'access to that space.', - $handles[$space_phid]->renderLink()), - phutil_tag('br'), - phutil_tag('br'), - ); - } - } + $this->appendSpaceInformation($dialog, $object, $policy, $capability); - $content = array( - $space_info, - pht('Users with the "%s" capability:', $capability_name), - $auto_info, - ); + $intro = pht( + 'Users with the "%s" capability for this object:', + $capability_name); $object_name = pht( '%s %s', $handle->getTypeName(), $handle->getObjectName()); - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) - ->setClass('aphront-access-dialog') + return $dialog ->setTitle(pht('Policy Details: %s', $object_name)) - ->appendChild($content) + ->appendParagraph($intro) + ->appendChild($auto_info) ->addCancelButton($object_uri, pht('Done')); + } + + private function appendSpaceInformation( + AphrontDialogView $dialog, + PhabricatorPolicyInterface $object, + PhabricatorPolicy $policy, + $capability) { + $viewer = $this->getViewer(); + + if (!($object instanceof PhabricatorSpacesInterface)) { + return; + } + + if (!PhabricatorSpacesNamespaceQuery::getSpacesExist($viewer)) { + return; + } + + // NOTE: We're intentionally letting users through here, even if they only + // have access to one space. The intent is to help users in "space jail" + // understand who objects they create are visible to: + + $space_phid = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID( + $object); + + $handles = $viewer->loadHandles(array($space_phid)); + $doc_href = PhabricatorEnv::getDoclink('Spaces User Guide'); + + $dialog->appendParagraph( + array( + pht( + 'This object is in %s, and can only be seen or edited by users with '. + 'access to view objects in the space.', + $handles[$space_phid]->renderLink()), + ' ', + phutil_tag( + 'strong', + array(), + phutil_tag( + 'a', + array( + 'href' => $doc_href, + 'target' => '_blank', + ), + pht('Learn More'))), + )); + + $spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($viewer); + $space = idx($spaces, $space_phid); + if (!$space) { + return; + } + + $space_policies = PhabricatorPolicyQuery::loadPolicies($viewer, $space); + $space_policy = idx($space_policies, PhabricatorPolicyCapability::CAN_VIEW); + if (!$space_policy) { + return; + } + + $space_explanation = PhabricatorPolicy::getPolicyExplanation( + $viewer, + $space_policy->getPHID()); + $items = array(); + $items[] = $space_explanation; + + foreach ($items as $key => $item) { + $items[$key] = phutil_tag('li', array(), $item); + } + + $dialog->appendParagraph(pht('Users who can see objects in this space:')); + $dialog->appendChild(phutil_tag('ul', array(), $items)); + + $view_capability = PhabricatorPolicyCapability::CAN_VIEW; + if ($capability == $view_capability) { + $stronger = $space_policy->isStrongerThan($policy); + if ($stronger) { + $dialog->appendParagraph( + pht( + 'The space this object is in has a more restrictive view '. + 'policy ("%s") than the object does ("%s"), so the space\'s '. + 'view policy is shown as a hint instead of the object policy.', + $space_policy->getShortName(), + $policy->getShortName())); + } + } - return id(new AphrontDialogResponse())->setDialog($dialog); + $dialog->appendParagraph( + pht( + 'After a user passes space policy checks, they must still pass '. + 'object policy checks.')); } } diff --git a/src/applications/policy/storage/PhabricatorPolicy.php b/src/applications/policy/storage/PhabricatorPolicy.php index e01e0dd416..c23858b0a8 100644 --- a/src/applications/policy/storage/PhabricatorPolicy.php +++ b/src/applications/policy/storage/PhabricatorPolicy.php @@ -1,400 +1,454 @@ true, self::CONFIG_SERIALIZATION => array( 'rules' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'defaultAction' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPolicyPHIDTypePolicy::TYPECONST); } public static function newFromPolicyAndHandle( $policy_identifier, PhabricatorObjectHandle $handle = null) { $is_global = PhabricatorPolicyQuery::isGlobalPolicy($policy_identifier); if ($is_global) { return PhabricatorPolicyQuery::getGlobalPolicy($policy_identifier); } $policy = PhabricatorPolicyQuery::getObjectPolicy($policy_identifier); if ($policy) { return $policy; } if (!$handle) { throw new Exception( pht( "Policy identifier is an object PHID ('%s'), but no object handle ". "was provided. A handle must be provided for object policies.", $policy_identifier)); } $handle_phid = $handle->getPHID(); if ($policy_identifier != $handle_phid) { throw new Exception( pht( "Policy identifier is an object PHID ('%s'), but the provided ". "handle has a different PHID ('%s'). The handle must correspond ". "to the policy identifier.", $policy_identifier, $handle_phid)); } $policy = id(new PhabricatorPolicy()) ->setPHID($policy_identifier) ->setHref($handle->getURI()); $phid_type = phid_get_type($policy_identifier); switch ($phid_type) { case PhabricatorProjectProjectPHIDType::TYPECONST: $policy->setType(PhabricatorPolicyType::TYPE_PROJECT); $policy->setName($handle->getName()); break; case PhabricatorPeopleUserPHIDType::TYPECONST: $policy->setType(PhabricatorPolicyType::TYPE_USER); $policy->setName($handle->getFullName()); break; case PhabricatorPolicyPHIDTypePolicy::TYPECONST: // TODO: This creates a weird handle-based version of a rule policy. // It behaves correctly, but can't be applied since it doesn't have // any rules. It is used to render transactions, and might need some // cleanup. break; default: $policy->setType(PhabricatorPolicyType::TYPE_MASKED); $policy->setName($handle->getFullName()); break; } $policy->makeEphemeral(); return $policy; } public function setType($type) { $this->type = $type; return $this; } public function getType() { if (!$this->type) { return PhabricatorPolicyType::TYPE_CUSTOM; } return $this->type; } public function setName($name) { $this->name = $name; return $this; } public function getName() { if (!$this->name) { return pht('Custom Policy'); } return $this->name; } public function setShortName($short_name) { $this->shortName = $short_name; return $this; } public function getShortName() { if ($this->shortName) { return $this->shortName; } return $this->getName(); } public function setHref($href) { $this->href = $href; return $this; } public function getHref() { return $this->href; } public function setWorkflow($workflow) { $this->workflow = $workflow; return $this; } public function getWorkflow() { return $this->workflow; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function getIcon() { if ($this->icon) { return $this->icon; } switch ($this->getType()) { case PhabricatorPolicyType::TYPE_GLOBAL: static $map = array( PhabricatorPolicies::POLICY_PUBLIC => 'fa-globe', PhabricatorPolicies::POLICY_USER => 'fa-users', PhabricatorPolicies::POLICY_ADMIN => 'fa-eye', PhabricatorPolicies::POLICY_NOONE => 'fa-ban', ); return idx($map, $this->getPHID(), 'fa-question-circle'); case PhabricatorPolicyType::TYPE_USER: return 'fa-user'; case PhabricatorPolicyType::TYPE_PROJECT: return 'fa-briefcase'; case PhabricatorPolicyType::TYPE_CUSTOM: case PhabricatorPolicyType::TYPE_MASKED: return 'fa-certificate'; default: return 'fa-question-circle'; } } public function getSortKey() { return sprintf( '%02d%s', PhabricatorPolicyType::getPolicyTypeOrder($this->getType()), $this->getSortName()); } private function getSortName() { if ($this->getType() == PhabricatorPolicyType::TYPE_GLOBAL) { static $map = array( PhabricatorPolicies::POLICY_PUBLIC => 0, PhabricatorPolicies::POLICY_USER => 1, PhabricatorPolicies::POLICY_ADMIN => 2, PhabricatorPolicies::POLICY_NOONE => 3, ); return idx($map, $this->getPHID()); } return $this->getName(); } public static function getPolicyExplanation( PhabricatorUser $viewer, $policy) { $rule = PhabricatorPolicyQuery::getObjectPolicyRule($policy); if ($rule) { return $rule->getPolicyExplanation(); } switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: return pht('This object is public.'); case PhabricatorPolicies::POLICY_USER: return pht('Logged in users can take this action.'); case PhabricatorPolicies::POLICY_ADMIN: return pht('Administrators can take this action.'); case PhabricatorPolicies::POLICY_NOONE: return pht('By default, no one can take this action.'); default: $handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($policy)) ->executeOne(); $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { return pht( 'Members of the project "%s" can take this action.', $handle->getFullName()); } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) { return pht( '%s can take this action.', $handle->getFullName()); } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { return pht( 'This object has a custom policy controlling who can take this '. 'action.'); } else { return pht( 'This object has an unknown or invalid policy setting ("%s").', $policy); } } } public function getFullName() { switch ($this->getType()) { case PhabricatorPolicyType::TYPE_PROJECT: return pht('Project: %s', $this->getName()); case PhabricatorPolicyType::TYPE_MASKED: return pht('Other: %s', $this->getName()); default: return $this->getName(); } } public function renderDescription($icon = false) { $img = null; if ($icon) { $img = id(new PHUIIconView()) ->setIconFont($this->getIcon()); } if ($this->getHref()) { $desc = javelin_tag( 'a', array( 'href' => $this->getHref(), 'class' => 'policy-link', 'sigil' => $this->getWorkflow() ? 'workflow' : null, ), array( $img, $this->getName(), )); } else { if ($img) { $desc = array($img, $this->getName()); } else { $desc = $this->getName(); } } switch ($this->getType()) { case PhabricatorPolicyType::TYPE_PROJECT: return pht('%s (Project)', $desc); case PhabricatorPolicyType::TYPE_CUSTOM: return $desc; case PhabricatorPolicyType::TYPE_MASKED: return pht( '%s (You do not have permission to view policy details.)', $desc); default: return $desc; } } /** * Return a list of custom rule classes (concrete subclasses of * @{class:PhabricatorPolicyRule}) this policy uses. * * @return list List of class names. */ public function getCustomRuleClasses() { $classes = array(); foreach ($this->getRules() as $rule) { $class = idx($rule, 'rule'); try { if (class_exists($class)) { $classes[$class] = $class; } } catch (Exception $ex) { continue; } } return array_keys($classes); } /** * Return a list of all values used by a given rule class to implement this * policy. This is used to bulk load data (like project memberships) in order * to apply policy filters efficiently. * * @param string Policy rule classname. * @return list List of values used in this policy. */ public function getCustomRuleValues($rule_class) { $values = array(); foreach ($this->getRules() as $rule) { if ($rule['rule'] == $rule_class) { $values[] = $rule['value']; } } return $values; } public function attachRuleObjects(array $objects) { $this->ruleObjects = $objects; return $this; } public function getRuleObjects() { return $this->assertAttached($this->ruleObjects); } + /** + * Return `true` if this policy is stronger (more restrictive) than some + * other policy. + * + * Because policies are complicated, determining which policies are + * "stronger" is not trivial. This method uses a very coarse working + * definition of policy strength which is cheap to compute, unambiguous, + * and intuitive in the common cases. + * + * This method returns `true` if the //class// of this policy is stronger + * than the other policy, even if the policies are (or might be) the same in + * practice. For example, "Members of Project X" is considered a stronger + * policy than "All Users", even though "Project X" might (in some rare + * cases) contain every user. + * + * Generally, the ordering here is: + * + * - Public + * - All Users + * - (Everything Else) + * - No One + * + * In the "everything else" bucket, we can't make any broad claims about + * which policy is stronger (and we especially can't make those claims + * cheaply). + * + * Even if we fully evaluated each policy, the two policies might be + * "Members of X" and "Members of Y", each of which permits access to some + * set of unique users. In this case, neither is strictly stronger than + * the other. + * + * @param PhabricatorPolicy Other policy. + * @return bool `true` if this policy is more restrictive than the other + * policy. + */ + public function isStrongerThan(PhabricatorPolicy $other) { + $this_policy = $this->getPHID(); + $other_policy = $other->getPHID(); + + $strengths = array( + PhabricatorPolicies::POLICY_PUBLIC => -2, + PhabricatorPolicies::POLICY_USER => -1, + // (Default policies have strength 0.) + PhabricatorPolicies::POLICY_NOONE => 1, + ); + + $this_strength = idx($strengths, $this->getPHID(), 0); + $other_strength = idx($strengths, $other->getPHID(), 0); + + return ($this_strength > $other_strength); + } + + + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { // NOTE: We implement policies only so we can comply with the interface. // The actual query skips them, as enforcing policies on policies seems // perilous and isn't currently required by the application. return PhabricatorPolicies::POLICY_PUBLIC; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } public function describeAutomaticCapability($capability) { return null; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->delete(); } } diff --git a/src/view/phui/PHUIHeaderView.php b/src/view/phui/PHUIHeaderView.php index 2dec5f7689..b78c36cedd 100644 --- a/src/view/phui/PHUIHeaderView.php +++ b/src/view/phui/PHUIHeaderView.php @@ -1,375 +1,401 @@ header = $header; return $this; } public function setObjectName($object_name) { $this->objectName = $object_name; return $this; } public function setNoBackground($nada) { $this->noBackground = $nada; return $this; } public function setTall($tall) { $this->tall = $tall; return $this; } public function addTag(PHUITagView $tag) { $this->tags[] = $tag; return $this; } public function setImage($uri) { $this->image = $uri; return $this; } public function setImageURL($url) { $this->imageURL = $url; return $this; } public function setSubheader($subheader) { $this->subheader = $subheader; return $this; } public function setBleedHeader($bleed) { $this->bleedHeader = $bleed; return $this; } public function setHeaderIcon($icon) { $this->headerIcon = $icon; return $this; } public function setPolicyObject(PhabricatorPolicyInterface $object) { $this->policyObject = $object; return $this; } public function addProperty($property, $value) { $this->properties[$property] = $value; return $this; } public function addActionLink(PHUIButtonView $button) { $this->actionLinks[] = $button; return $this; } public function addActionIcon(PHUIIconView $action) { $this->actionIcons[] = $action; return $this; } public function setButtonBar(PHUIButtonBarView $bb) { $this->buttonBar = $bb; return $this; } public function setStatus($icon, $color, $name) { $header_class = 'phui-header-status'; if ($color) { $icon = $icon.' '.$color; $header_class = $header_class.'-'.$color; } $img = id(new PHUIIconView()) ->setIconFont($icon); $tag = phutil_tag( 'span', array( 'class' => "{$header_class} plr", ), array( $img, $name, )); return $this->addProperty(self::PROPERTY_STATUS, $tag); } public function setEpoch($epoch) { $age = time() - $epoch; $age = floor($age / (60 * 60 * 24)); if ($age < 1) { $when = pht('Today'); } else if ($age == 1) { $when = pht('Yesterday'); } else { $when = pht('%d Days Ago', $age); } $this->setStatus('fa-clock-o bluegrey', null, pht('Updated %s', $when)); return $this; } protected function getTagName() { return 'div'; } protected function getTagAttributes() { require_celerity_resource('phui-header-view-css'); $classes = array(); $classes[] = 'phui-header-shell'; if ($this->noBackground) { $classes[] = 'phui-header-no-backgound'; } if ($this->bleedHeader) { $classes[] = 'phui-bleed-header'; } if ($this->properties || $this->policyObject || $this->subheader || $this->tall) { $classes[] = 'phui-header-tall'; } return array( 'class' => $classes, ); } protected function getTagContent() { $image = null; if ($this->image) { $image = phutil_tag( ($this->imageURL ? 'a' : 'span'), array( 'href' => $this->imageURL, 'class' => 'phui-header-image', 'style' => 'background-image: url('.$this->image.')', ), ' '); } $viewer = $this->getUser(); $left = array(); $right = array(); if ($viewer) { $left[] = id(new PHUISpacesNamespaceContextView()) ->setUser($viewer) ->setObject($this->policyObject); } if ($this->objectName) { $left[] = array( phutil_tag( 'a', array( 'href' => '/'.$this->objectName, ), $this->objectName), ' ', ); } if ($this->actionLinks) { $actions = array(); foreach ($this->actionLinks as $button) { $button->setColor(PHUIButtonView::SIMPLE); $button->addClass(PHUI::MARGIN_SMALL_LEFT); $button->addClass('phui-header-action-link'); $actions[] = $button; } $right[] = phutil_tag( 'div', array( 'class' => 'phui-header-action-links', ), $actions); } if ($this->buttonBar) { $right[] = phutil_tag( 'div', array( 'class' => 'phui-header-action-links', ), $this->buttonBar); } if ($this->actionIcons || $this->tags) { $action_list = array(); if ($this->actionIcons) { foreach ($this->actionIcons as $icon) { $action_list[] = phutil_tag( 'li', array( 'class' => 'phui-header-action-icon', ), $icon); } } if ($this->tags) { $action_list[] = phutil_tag( 'li', array( 'class' => 'phui-header-action-tag', ), array_interleave(' ', $this->tags)); } $right[] = phutil_tag( 'ul', array( 'class' => 'phui-header-action-list', ), $action_list); } if ($this->headerIcon) { $icon = id(new PHUIIconView()) ->setIconFont($this->headerIcon); $left[] = $icon; } $left[] = phutil_tag( 'span', array( 'class' => 'phui-header-header', ), $this->header); if ($this->subheader) { $left[] = phutil_tag( 'div', array( 'class' => 'phui-header-subheader', ), $this->subheader); } if ($this->properties || $this->policyObject) { $property_list = array(); foreach ($this->properties as $type => $property) { switch ($type) { case self::PROPERTY_STATUS: $property_list[] = $property; break; default: throw new Exception(pht('Incorrect Property Passed')); break; } } if ($this->policyObject) { $property_list[] = $this->renderPolicyProperty($this->policyObject); } $left[] = phutil_tag( 'div', array( 'class' => 'phui-header-subheader', ), $property_list); } // We here at @phabricator $header_image = null; if ($image) { $header_image = phutil_tag( 'div', array( 'class' => 'phui-header-col1', ), $image); } // All really love $header_left = phutil_tag( 'div', array( 'class' => 'phui-header-col2', ), $left); // Tables and Pokemon. $header_right = phutil_tag( 'div', array( 'class' => 'phui-header-col3', ), $right); $header_row = phutil_tag( 'div', array( 'class' => 'phui-header-row', ), array( $header_image, $header_left, $header_right, )); return phutil_tag( 'h1', array( 'class' => 'phui-header-view', ), $header_row); } private function renderPolicyProperty(PhabricatorPolicyInterface $object) { $viewer = $this->getUser(); $policies = PhabricatorPolicyQuery::loadPolicies($viewer, $object); $view_capability = PhabricatorPolicyCapability::CAN_VIEW; $policy = idx($policies, $view_capability); if (!$policy) { return null; } + // If an object is in a Space with a strictly stronger (more restrictive) + // policy, we show the more restrictive policy. This better aligns the + // UI hint with the actual behavior. + + // NOTE: We'll do this even if the viewer has access to only one space, and + // show them information about the existence of spaces if they click + // through. + if ($object instanceof PhabricatorSpacesInterface) { + $space_phid = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID( + $object); + + $spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($viewer); + $space = idx($spaces, $space_phid); + if ($space) { + $space_policies = PhabricatorPolicyQuery::loadPolicies( + $viewer, + $space); + $space_policy = idx($space_policies, $view_capability); + if ($space_policy) { + if ($space_policy->isStrongerThan($policy)) { + $policy = $space_policy; + } + } + } + } + $phid = $object->getPHID(); $icon = id(new PHUIIconView()) ->setIconFont($policy->getIcon().' bluegrey'); $link = javelin_tag( 'a', array( 'class' => 'policy-link', 'href' => '/policy/explain/'.$phid.'/'.$view_capability.'/', 'sigil' => 'workflow', ), $policy->getShortName()); return array($icon, $link); } }