diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php index 23a759d2aa..95045623d0 100644 --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -1,646 +1,646 @@ pht('Core Applications'), self::GROUP_UTILITIES => pht('Utilities'), self::GROUP_ADMIN => pht('Administration'), self::GROUP_DEVELOPER => pht('Developer Tools'), ); } /* -( Application Information )-------------------------------------------- */ abstract public function getName(); public function getShortDescription() { return pht('%s Application', $this->getName()); } final public function isInstalled() { if (!$this->canUninstall()) { return true; } $prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes'); if (!$prototypes && $this->isPrototype()) { return false; } $uninstalled = PhabricatorEnv::getEnvConfig( 'phabricator.uninstalled-applications'); return empty($uninstalled[get_class($this)]); } public function isPrototype() { return false; } /** * Return `true` if this application should never appear in application lists * in the UI. Primarily intended for unit test applications or other * pseudo-applications. * * Few applications should be unlisted. For most applications, use * @{method:isLaunchable} to hide them from main launch views instead. * * @return bool True to remove application from UI lists. */ public function isUnlisted() { return false; } /** * Return `true` if this application is a normal application with a base * URI and a web interface. * * Launchable applications can be pinned to the home page, and show up in the * "Launcher" view of the Applications application. Making an application * unlauncahble prevents pinning and hides it from this view. * * Usually, an application should be marked unlaunchable if: * * - it is available on every page anyway (like search); or * - it does not have a web interface (like subscriptions); or * - it is still pre-release and being intentionally buried. * * To hide applications more completely, use @{method:isUnlisted}. * * @return bool True if the application is launchable. */ public function isLaunchable() { return true; } /** * Return `true` if this application should be pinned by default. * * Users who have not yet set preferences see a default list of applications. * * @param PhabricatorUser User viewing the pinned application list. * @return bool True if this application should be pinned by default. */ public function isPinnedByDefault(PhabricatorUser $viewer) { return false; } /** * Returns true if an application is first-party (developed by Phacility) * and false otherwise. * * @return bool True if this application is developed by Phacility. */ final public function isFirstParty() { $where = id(new ReflectionClass($this))->getFileName(); $root = phutil_get_library_root('phabricator'); if (!Filesystem::isDescendant($where, $root)) { return false; } if (Filesystem::isDescendant($where, $root.'/extensions')) { return false; } return true; } public function canUninstall() { return true; } final public function getPHID() { return 'PHID-APPS-'.get_class($this); } public function getTypeaheadURI() { return $this->isLaunchable() ? $this->getBaseURI() : null; } public function getBaseURI() { return null; } final public function getApplicationURI($path = '') { return $this->getBaseURI().ltrim($path, '/'); } public function getIcon() { return 'fa-puzzle-piece'; } public function getApplicationOrder() { return PHP_INT_MAX; } public function getApplicationGroup() { return self::GROUP_CORE; } public function getTitleGlyph() { return null; } final public function getHelpMenuItems(PhabricatorUser $viewer) { $items = array(); $articles = $this->getHelpDocumentationArticles($viewer); if ($articles) { $items[] = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LABEL) ->setName(pht('%s Documentation', $this->getName())); foreach ($articles as $article) { $item = id(new PHUIListItemView()) ->setName($article['name']) ->setIcon('fa-book') ->setHref($article['href']) ->setOpenInNewWindow(true); $items[] = $item; } } $command_specs = $this->getMailCommandObjects(); if ($command_specs) { $items[] = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LABEL) ->setName(pht('Email Help')); foreach ($command_specs as $key => $spec) { $object = $spec['object']; $class = get_class($this); $href = '/applications/mailcommands/'.$class.'/'.$key.'/'; $item = id(new PHUIListItemView()) ->setName($spec['name']) ->setIcon('fa-envelope-o') ->setHref($href) ->setOpenInNewWindow(true); $items[] = $item; } } return $items; } public function getHelpDocumentationArticles(PhabricatorUser $viewer) { return array(); } public function getOverview() { return null; } public function getEventListeners() { return array(); } public function getRemarkupRules() { return array(); } public function getQuicksandURIPatternBlacklist() { return array(); } public function getMailCommandObjects() { return array(); } /* -( URI Routing )-------------------------------------------------------- */ public function getRoutes() { return array(); } public function getResourceRoutes() { return array(); } /* -( Email Integration )-------------------------------------------------- */ public function supportsEmailIntegration() { return false; } final protected function getInboundEmailSupportLink() { return PhabricatorEnv::getDocLink('Configuring Inbound Email'); } public function getAppEmailBlurb() { throw new PhutilMethodNotImplementedException(); } /* -( Fact Integration )--------------------------------------------------- */ public function getFactObjectsForAnalysis() { return array(); } /* -( UI Integration )----------------------------------------------------- */ /** * Render status elements (like "3 Waiting Reviews") for application list * views. These provide a way to alert users to new or pending action items * in applications. * * @param PhabricatorUser Viewing user. * @return list Application status elements. * @task ui */ public function loadStatus(PhabricatorUser $user) { return array(); } /** * You can provide an optional piece of flavor text for the application. This * is currently rendered in application launch views if the application has no * status elements. * * @return string|null Flavor text. * @task ui */ public function getFlavorText() { return null; } /** * Build items for the main menu. * * @param PhabricatorUser The viewing user. * @param AphrontController The current controller. May be null for special * pages like 404, exception handlers, etc. * @return list List of menu items. * @task ui */ public function buildMainMenuItems( PhabricatorUser $user, PhabricatorController $controller = null) { return array(); } /** * Build extra items for the main menu. Generally, this is used to render * static dropdowns. * * @param PhabricatorUser The viewing user. * @param AphrontController The current controller. May be null for special * pages like 404, exception handlers, etc. * @return view List of menu items. * @task ui */ public function buildMainMenuExtraNodes( PhabricatorUser $viewer, PhabricatorController $controller = null) { return array(); } /* -( Application Management )--------------------------------------------- */ final public static function getByClass($class_name) { $selected = null; $applications = self::getAllApplications(); foreach ($applications as $application) { if (get_class($application) == $class_name) { $selected = $application; break; } } if (!$selected) { throw new Exception(pht("No application '%s'!", $class_name)); } return $selected; } final public static function getAllApplications() { static $applications; if ($applications === null) { $apps = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setSortMethod('getApplicationOrder') ->execute(); // Reorder the applications into "application order". Notably, this // ensures their event handlers register in application order. $apps = mgroup($apps, 'getApplicationGroup'); $group_order = array_keys(self::getApplicationGroups()); $apps = array_select_keys($apps, $group_order) + $apps; $apps = array_mergev($apps); $applications = $apps; } return $applications; } final public static function getAllInstalledApplications() { $all_applications = self::getAllApplications(); $apps = array(); foreach ($all_applications as $app) { if (!$app->isInstalled()) { continue; } $apps[] = $app; } return $apps; } /** * Determine if an application is installed, by application class name. * * To check if an application is installed //and// available to a particular * viewer, user @{method:isClassInstalledForViewer}. * * @param string Application class name. * @return bool True if the class is installed. * @task meta */ final public static function isClassInstalled($class) { return self::getByClass($class)->isInstalled(); } /** * Determine if an application is installed and available to a viewer, by * application class name. * * To check if an application is installed at all, use * @{method:isClassInstalled}. * * @param string Application class name. * @param PhabricatorUser Viewing user. * @return bool True if the class is installed for the viewer. * @task meta */ final public static function isClassInstalledForViewer( $class, PhabricatorUser $viewer) { if ($viewer->isOmnipotent()) { return true; } $cache = PhabricatorCaches::getRequestCache(); - $viewer_phid = $viewer->getPHID(); - $key = 'app.'.$class.'.installed.'.$viewer_phid; + $viewer_fragment = $viewer->getCacheFragment(); + $key = 'app.'.$class.'.installed.'.$viewer_fragment; $result = $cache->getKey($key); if ($result === null) { if (!self::isClassInstalled($class)) { $result = false; } else { $application = self::getByClass($class); if (!$application->canUninstall()) { // If the application can not be uninstalled, always allow viewers // to see it. In particular, this allows logged-out viewers to see // Settings and load global default settings even if the install // does not allow public viewers. $result = true; } else { $result = PhabricatorPolicyFilter::hasCapability( $viewer, self::getByClass($class), PhabricatorPolicyCapability::CAN_VIEW); } } $cache->setKey($key, $result); } return $result; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array_merge( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ), array_keys($this->getCustomCapabilities())); } public function getPolicy($capability) { $default = $this->getCustomPolicySetting($capability); if ($default) { return $default; } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_ADMIN; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'default', PhabricatorPolicies::POLICY_USER); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( Policies )----------------------------------------------------------- */ protected function getCustomCapabilities() { return array(); } final private function getCustomPolicySetting($capability) { if (!$this->isCapabilityEditable($capability)) { return null; } $policy_locked = PhabricatorEnv::getEnvConfig('policy.locked'); if (isset($policy_locked[$capability])) { return $policy_locked[$capability]; } $config = PhabricatorEnv::getEnvConfig('phabricator.application-settings'); $app = idx($config, $this->getPHID()); if (!$app) { return null; } $policy = idx($app, 'policy'); if (!$policy) { return null; } return idx($policy, $capability); } final private function getCustomCapabilitySpecification($capability) { $custom = $this->getCustomCapabilities(); if (!isset($custom[$capability])) { throw new Exception(pht("Unknown capability '%s'!", $capability)); } return $custom[$capability]; } final public function getCapabilityLabel($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Can Use Application'); case PhabricatorPolicyCapability::CAN_EDIT: return pht('Can Configure Application'); } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if ($capobj) { return $capobj->getCapabilityName(); } return null; } final public function isCapabilityEditable($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->canUninstall(); case PhabricatorPolicyCapability::CAN_EDIT: return false; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'edit', true); } } final public function getCapabilityCaption($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if (!$this->canUninstall()) { return pht( 'This application is required for Phabricator to operate, so all '. 'users must have access to it.'); } else { return null; } case PhabricatorPolicyCapability::CAN_EDIT: return null; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'caption'); } } final public function getCapabilityTemplatePHIDType($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: case PhabricatorPolicyCapability::CAN_EDIT: return null; } $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'template'); } final public function getDefaultObjectTypePolicyMap() { $map = array(); foreach ($this->getCustomCapabilities() as $capability => $spec) { if (empty($spec['template'])) { continue; } if (empty($spec['capability'])) { continue; } $default = $this->getPolicy($capability); $map[$spec['template']][$spec['capability']] = $default; } return $map; } public function getApplicationSearchDocumentTypes() { return array(); } protected function getEditRoutePattern($base = null) { return $base.'(?:'. '(?P[0-9]\d*)/)?'. '(?:'. '(?:'. '(?Pparameters|nodefault|nocreate|nomanage|comment)/'. '|'. '(?:form/(?P[^/]+)/)?(?:page/(?P[^/]+)/)?'. ')'. ')?'; } protected function getQueryRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/)?'; } protected function getProfileMenuRouting($controller) { $edit_route = $this->getEditRoutePattern(); return array( '(?Pview)/(?P[^/]+)/' => $controller, '(?Phide)/(?P[^/]+)/' => $controller, '(?Pdefault)/(?P[^/]+)/' => $controller, '(?Pconfigure)/' => $controller, '(?Preorder)/' => $controller, '(?Pedit)/'.$edit_route => $controller, '(?Pnew)/(?[^/]+)/'.$edit_route => $controller, '(?Pbuiltin)/(?[^/]+)/'.$edit_route => $controller, ); } } diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php index 1466d4e569..855149f6b3 100644 --- a/src/applications/policy/filter/PhabricatorPolicyFilter.php +++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php @@ -1,867 +1,918 @@ setViewer($user) ->requireCapabilities(array($capability)) ->raisePolicyExceptions(true) ->apply(array($object)); } /** * Perform a capability check, acting as though an object had a specific * policy. This is primarily used to check if a policy is valid (for example, * to prevent users from editing away their ability to edit an object). * * Specifically, a check like this: * * PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy( * $viewer, * $object, * PhabricatorPolicyCapability::CAN_EDIT, * $potential_new_policy); * * ...will throw a @{class:PhabricatorPolicyException} if the new policy would * remove the user's ability to edit the object. * * @param PhabricatorUser The viewer to perform a policy check for. * @param PhabricatorPolicyInterface The object to perform a policy check on. * @param string Capability to test. * @param string Perform the test as though the object has this * policy instead of the policy it actually has. * @return void */ public static function requireCapabilityWithForcedPolicy( PhabricatorUser $viewer, PhabricatorPolicyInterface $object, $capability, $forced_policy) { id(new PhabricatorPolicyFilter()) ->setViewer($viewer) ->requireCapabilities(array($capability)) ->raisePolicyExceptions(true) ->forcePolicy($forced_policy) ->apply(array($object)); } public static function hasCapability( PhabricatorUser $user, PhabricatorPolicyInterface $object, $capability) { $filter = new PhabricatorPolicyFilter(); $filter->setViewer($user); $filter->requireCapabilities(array($capability)); $result = $filter->apply(array($object)); return (count($result) == 1); } public function setViewer(PhabricatorUser $user) { $this->viewer = $user; return $this; } public function requireCapabilities(array $capabilities) { $this->capabilities = $capabilities; return $this; } public function raisePolicyExceptions($raise) { $this->raisePolicyExceptions = $raise; return $this; } public function forcePolicy($forced_policy) { $this->forcedPolicy = $forced_policy; return $this; } public function apply(array $objects) { assert_instances_of($objects, 'PhabricatorPolicyInterface'); $viewer = $this->viewer; $capabilities = $this->capabilities; if (!$viewer || !$capabilities) { throw new PhutilInvalidStateException('setViewer', 'requireCapabilities'); } // If the viewer is omnipotent, short circuit all the checks and just // return the input unmodified. This is an optimization; we know the // result already. if ($viewer->isOmnipotent()) { return $objects; } + // Before doing any actual object checks, make sure the viewer can see + // the applications that these objects belong to. This is normally enforced + // in the Query layer before we reach object filtering, but execution + // sometimes reaches policy filtering without running application checks. + $objects = $this->applyApplicationChecks($objects); + $filtered = array(); $viewer_phid = $viewer->getPHID(); if (empty($this->userProjects[$viewer_phid])) { $this->userProjects[$viewer_phid] = array(); } $need_projects = array(); $need_policies = array(); $need_objpolicies = array(); foreach ($objects as $key => $object) { $object_capabilities = $object->getCapabilities(); foreach ($capabilities as $capability) { if (!in_array($capability, $object_capabilities)) { throw new Exception( pht( "Testing for capability '%s' on an object which does ". "not have that capability!", $capability)); } $policy = $this->getObjectPolicy($object, $capability); if (PhabricatorPolicyQuery::isObjectPolicy($policy)) { $need_objpolicies[$policy][] = $object; continue; } $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { $need_projects[$policy] = $policy; continue; } if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { $need_policies[$policy][] = $object; continue; } } } if ($need_objpolicies) { $this->loadObjectPolicies($need_objpolicies); } if ($need_policies) { $this->loadCustomPolicies($need_policies); } // If we need projects, check if any of the projects we need are also the // objects we're filtering. Because of how project rules work, this is a // common case. if ($need_projects) { foreach ($objects as $object) { if ($object instanceof PhabricatorProject) { $project_phid = $object->getPHID(); if (isset($need_projects[$project_phid])) { $is_member = $object->isUserMember($viewer_phid); $this->userProjects[$viewer_phid][$project_phid] = $is_member; unset($need_projects[$project_phid]); } } } } if ($need_projects) { $need_projects = array_unique($need_projects); // NOTE: We're using the omnipotent user here to avoid a recursive // descent into madness. We don't actually need to know if the user can // see these projects or not, since: the check is "user is member of // project", not "user can see project"; and membership implies // visibility anyway. Without this, we may load other projects and // re-enter the policy filter and generally create a huge mess. $projects = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withMemberPHIDs(array($viewer->getPHID())) ->withPHIDs($need_projects) ->execute(); foreach ($projects as $project) { $this->userProjects[$viewer_phid][$project->getPHID()] = true; } } foreach ($objects as $key => $object) { foreach ($capabilities as $capability) { if (!$this->checkCapability($object, $capability)) { // If we're missing any capability, move on to the next object. continue 2; } } // If we make it here, we have all of the required capabilities. $filtered[$key] = $object; } // If we survied the primary checks, apply extended checks to objects // with extended policies. $results = array(); $extended = array(); foreach ($filtered as $key => $object) { if ($object instanceof PhabricatorExtendedPolicyInterface) { $extended[$key] = $object; } else { $results[$key] = $object; } } if ($extended) { $results += $this->applyExtendedPolicyChecks($extended); // Put results back in the original order. $results = array_select_keys($results, array_keys($filtered)); } return $results; } private function applyExtendedPolicyChecks(array $extended_objects) { $viewer = $this->viewer; $filter_capabilities = $this->capabilities; // Iterate over the objects we need to filter and pull all the nonempty // policies into a flat, structured list. $all_structs = array(); foreach ($extended_objects as $key => $extended_object) { foreach ($filter_capabilities as $extended_capability) { $extended_policies = $extended_object->getExtendedPolicy( $extended_capability, $viewer); if (!$extended_policies) { continue; } foreach ($extended_policies as $extended_policy) { list($object, $capabilities) = $extended_policy; // Build a description of the capabilities we need to check. This // will be something like `"view"`, or `"edit view"`, or possibly // a longer string with custom capabilities. Later, group the objects // up into groups which need the same capabilities tested. $capabilities = (array)$capabilities; $capabilities = array_fuse($capabilities); ksort($capabilities); $group = implode(' ', $capabilities); $struct = array( 'key' => $key, 'for' => $extended_capability, 'object' => $object, 'capabilities' => $capabilities, 'group' => $group, ); $all_structs[] = $struct; } } } // Extract any bare PHIDs from the structs; we need to load these objects. // These are objects which are required in order to perform an extended // policy check but which the original viewer did not have permission to // see (they presumably had other permissions which let them load the // object in the first place). $all_phids = array(); foreach ($all_structs as $idx => $struct) { $object = $struct['object']; if (is_string($object)) { $all_phids[$object] = $object; } } // If we have some bare PHIDs, we need to load the corresponding objects. if ($all_phids) { // We can pull these with the omnipotent user because we're immediately // filtering them. $ref_objects = id(new PhabricatorObjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($all_phids) ->execute(); $ref_objects = mpull($ref_objects, null, 'getPHID'); } else { $ref_objects = array(); } // Group the list of checks by the capabilities we need to check. $groups = igroup($all_structs, 'group'); foreach ($groups as $structs) { $head = head($structs); // All of the items in each group are checking for the same capabilities. $capabilities = $head['capabilities']; $key_map = array(); $objects_in = array(); foreach ($structs as $struct) { $extended_key = $struct['key']; if (empty($extended_objects[$extended_key])) { // If this object has already been rejected by an earlier filtering // pass, we don't need to do any tests on it. continue; } $object = $struct['object']; if (is_string($object)) { // This is really a PHID, so look it up. $object_phid = $object; if (empty($ref_objects[$object_phid])) { // We weren't able to load the corresponding object, so just // reject this result outright. $reject = $extended_objects[$extended_key]; unset($extended_objects[$extended_key]); // TODO: This could be friendlier. $this->rejectObject($reject, false, ''); continue; } $object = $ref_objects[$object_phid]; } $phid = $object->getPHID(); $key_map[$phid][] = $extended_key; $objects_in[$phid] = $object; } if ($objects_in) { $objects_out = $this->executeExtendedPolicyChecks( $viewer, $capabilities, $objects_in, $key_map); $objects_out = mpull($objects_out, null, 'getPHID'); } else { $objects_out = array(); } // If any objects were removed by filtering, we're going to reject all // of the original objects which needed them. foreach ($objects_in as $phid => $object_in) { if (isset($objects_out[$phid])) { // This object survived filtering, so we don't need to throw any // results away. continue; } foreach ($key_map[$phid] as $extended_key) { if (empty($extended_objects[$extended_key])) { // We've already rejected this object, so we don't need to reject // it again. continue; } $reject = $extended_objects[$extended_key]; unset($extended_objects[$extended_key]); // It's possible that we're rejecting this object for multiple // capability/policy failures, but just pick the first one to show // to the user. $first_capability = head($capabilities); $first_policy = $object_in->getPolicy($first_capability); $this->rejectObject($reject, $first_policy, $first_capability); } } } return $extended_objects; } private function executeExtendedPolicyChecks( PhabricatorUser $viewer, array $capabilities, array $objects, array $key_map) { // Do crude cycle detection by seeing if we have a huge stack depth. // Although more sophisticated cycle detection is possible in theory, // it is difficult with hierarchical objects like subprojects. Many other // checks make it difficult to create cycles normally, so just do a // simple check here to limit damage. static $depth; $depth++; if ($depth > 32) { foreach ($objects as $key => $object) { $this->rejectObject($objects[$key], false, ''); unset($objects[$key]); continue; } } if (!$objects) { return array(); } $caught = null; try { $result = id(new PhabricatorPolicyFilter()) ->setViewer($viewer) ->requireCapabilities($capabilities) ->apply($objects); } catch (Exception $ex) { $caught = $ex; } $depth--; if ($caught) { throw $caught; } return $result; } private function checkCapability( PhabricatorPolicyInterface $object, $capability) { $policy = $this->getObjectPolicy($object, $capability); if (!$policy) { // TODO: Formalize this somehow? $policy = PhabricatorPolicies::POLICY_USER; } if ($policy == PhabricatorPolicies::POLICY_PUBLIC) { // If the object is set to "public" but that policy is disabled for this // install, restrict the policy to "user". if (!PhabricatorEnv::getEnvConfig('policy.allow-public')) { $policy = PhabricatorPolicies::POLICY_USER; } // If the object is set to "public" but the capability is not a public // capability, restrict the policy to "user". $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if (!$capobj || !$capobj->shouldAllowPublicPolicySetting()) { $policy = PhabricatorPolicies::POLICY_USER; } } $viewer = $this->viewer; if ($viewer->isOmnipotent()) { return true; } if ($object instanceof PhabricatorSpacesInterface) { $space_phid = $object->getSpacePHID(); if (!$this->canViewerSeeObjectsInSpace($viewer, $space_phid)) { $this->rejectObjectFromSpace($object, $space_phid); return false; } } if ($object->hasAutomaticCapability($capability, $viewer)) { return true; } switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: return true; case PhabricatorPolicies::POLICY_USER: if ($viewer->getPHID()) { return true; } else { $this->rejectObject($object, $policy, $capability); } break; case PhabricatorPolicies::POLICY_ADMIN: if ($viewer->getIsAdmin()) { return true; } else { $this->rejectObject($object, $policy, $capability); } break; case PhabricatorPolicies::POLICY_NOONE: $this->rejectObject($object, $policy, $capability); break; default: if (PhabricatorPolicyQuery::isObjectPolicy($policy)) { if ($this->checkObjectPolicy($policy, $object)) { return true; } else { $this->rejectObject($object, $policy, $capability); break; } } $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { if (!empty($this->userProjects[$viewer->getPHID()][$policy])) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) { if ($viewer->getPHID() == $policy) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { if ($this->checkCustomPolicy($policy, $object)) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else { // Reject objects with unknown policies. $this->rejectObject($object, false, $capability); } } return false; } public function rejectObject( PhabricatorPolicyInterface $object, $policy, $capability) { if (!$this->raisePolicyExceptions) { return; } if ($this->viewer->isOmnipotent()) { // Never raise policy exceptions for the omnipotent viewer. Although we // will never normally issue a policy rejection for the omnipotent // viewer, we can end up here when queries blanket reject objects that // have failed to load, without distinguishing between nonexistent and // nonvisible objects. return; } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); $rejection = null; if ($capobj) { $rejection = $capobj->describeCapabilityRejection(); $capability_name = $capobj->getCapabilityName(); } else { $capability_name = $capability; } if (!$rejection) { // We couldn't find the capability object, or it doesn't provide a // tailored rejection string. $rejection = pht( 'You do not have the required capability ("%s") to do whatever you '. 'are trying to do.', $capability); } $more = PhabricatorPolicy::getPolicyExplanation($this->viewer, $policy); $more = (array)$more; $more = array_filter($more); $exceptions = PhabricatorPolicy::getSpecialRules( $object, $this->viewer, $capability, true); $details = array_filter(array_merge($more, $exceptions)); $access_denied = $this->renderAccessDenied($object); $full_message = pht( '[%s] (%s) %s // %s', $access_denied, $capability_name, $rejection, implode(' ', $details)); $exception = id(new PhabricatorPolicyException($full_message)) ->setTitle($access_denied) ->setObjectPHID($object->getPHID()) ->setRejection($rejection) ->setCapability($capability) ->setCapabilityName($capability_name) ->setMoreInfo($details); throw $exception; } private function loadObjectPolicies(array $map) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $rules = PhabricatorPolicyQuery::getObjectPolicyRules(null); // Make sure we have clean, empty policy rule objects. foreach ($rules as $key => $rule) { $rules[$key] = clone $rule; } $results = array(); foreach ($map as $key => $object_list) { $rule = idx($rules, $key); if (!$rule) { continue; } foreach ($object_list as $object_key => $object) { if (!$rule->canApplyToObject($object)) { unset($object_list[$object_key]); } } $rule->willApplyRules($viewer, array(), $object_list); $results[$key] = $rule; } $this->objectPolicies[$viewer_phid] = $results; } private function loadCustomPolicies(array $map) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $custom_policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->withPHIDs(array_keys($map)) ->execute(); $custom_policies = mpull($custom_policies, null, 'getPHID'); $classes = array(); $values = array(); $objects = array(); foreach ($custom_policies as $policy_phid => $policy) { foreach ($policy->getCustomRuleClasses() as $class) { $classes[$class] = $class; $values[$class][] = $policy->getCustomRuleValues($class); foreach (idx($map, $policy_phid, array()) as $object) { $objects[$class][] = $object; } } } foreach ($classes as $class => $ignored) { $rule_object = newv($class, array()); // Filter out any objects which the rule can't apply to. $target_objects = idx($objects, $class, array()); foreach ($target_objects as $key => $target_object) { if (!$rule_object->canApplyToObject($target_object)) { unset($target_objects[$key]); } } $rule_object->willApplyRules( $viewer, array_mergev($values[$class]), $target_objects); $classes[$class] = $rule_object; } foreach ($custom_policies as $policy) { $policy->attachRuleObjects($classes); } if (empty($this->customPolicies[$viewer_phid])) { $this->customPolicies[$viewer_phid] = array(); } $this->customPolicies[$viewer->getPHID()] += $custom_policies; } private function checkObjectPolicy( $policy_phid, PhabricatorPolicyInterface $object) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $rule = idx($this->objectPolicies[$viewer_phid], $policy_phid); if (!$rule) { return false; } if (!$rule->canApplyToObject($object)) { return false; } return $rule->applyRule($viewer, null, $object); } private function checkCustomPolicy( $policy_phid, PhabricatorPolicyInterface $object) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $policy = idx($this->customPolicies[$viewer_phid], $policy_phid); if (!$policy) { // Reject, this policy is bogus. return false; } $objects = $policy->getRuleObjects(); $action = null; foreach ($policy->getRules() as $rule) { if (!is_array($rule)) { // Reject, this policy rule is invalid. return false; } $rule_object = idx($objects, idx($rule, 'rule')); if (!$rule_object) { // Reject, this policy has a bogus rule. return false; } if (!$rule_object->canApplyToObject($object)) { // Reject, this policy rule can't be applied to the given object. return false; } // If the user matches this rule, use this action. if ($rule_object->applyRule($viewer, idx($rule, 'value'), $object)) { $action = idx($rule, 'action'); break; } } if ($action === null) { $action = $policy->getDefaultAction(); } if ($action === PhabricatorPolicy::ACTION_ALLOW) { return true; } return false; } private function getObjectPolicy( PhabricatorPolicyInterface $object, $capability) { if ($this->forcedPolicy) { return $this->forcedPolicy; } else { return $object->getPolicy($capability); } } private function renderAccessDenied(PhabricatorPolicyInterface $object) { // NOTE: Not every type of policy object has a real PHID; just load an // empty handle if a real PHID isn't available. $phid = nonempty($object->getPHID(), PhabricatorPHIDConstants::PHID_VOID); $handle = id(new PhabricatorHandleQuery()) ->setViewer($this->viewer) ->withPHIDs(array($phid)) ->executeOne(); $object_name = $handle->getObjectName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($is_serious) { $access_denied = pht( 'Access Denied: %s', $object_name); } else { $access_denied = pht( 'You Shall Not Pass: %s', $object_name); } return $access_denied; } private function canViewerSeeObjectsInSpace( PhabricatorUser $viewer, $space_phid) { $spaces = PhabricatorSpacesNamespaceQuery::getAllSpaces(); // If there are no spaces, everything exists in an implicit default space // with no policy controls. This is the default state. if (!$spaces) { if ($space_phid !== null) { return false; } else { return true; } } if ($space_phid === null) { $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); } else { $space = idx($spaces, $space_phid); } if (!$space) { return false; } // This may be more involved later, but for now being able to see the // space is equivalent to being able to see everything in it. return self::hasCapability( $viewer, $space, PhabricatorPolicyCapability::CAN_VIEW); } private function rejectObjectFromSpace( PhabricatorPolicyInterface $object, $space_phid) { if (!$this->raisePolicyExceptions) { return; } if ($this->viewer->isOmnipotent()) { return; } $access_denied = $this->renderAccessDenied($object); $rejection = pht( 'This object is in a space you do not have permission to access.'); $full_message = pht('[%s] %s', $access_denied, $rejection); $exception = id(new PhabricatorPolicyException($full_message)) ->setTitle($access_denied) ->setObjectPHID($object->getPHID()) ->setRejection($rejection) ->setCapability(PhabricatorPolicyCapability::CAN_VIEW); throw $exception; } + private function applyApplicationChecks(array $objects) { + $viewer = $this->viewer; + + foreach ($objects as $key => $object) { + $phid = $object->getPHID(); + if (!$phid) { + continue; + } + + $application_class = $this->getApplicationForPHID($phid); + if ($application_class === null) { + continue; + } + + $can_see = PhabricatorApplication::isClassInstalledForViewer( + $application_class, + $viewer); + if ($can_see) { + continue; + } + + unset($objects[$key]); + + $application = newv($application_class, array()); + $this->rejectObject( + $application, + $application->getPolicy(PhabricatorPolicyCapability::CAN_VIEW), + PhabricatorPolicyCapability::CAN_VIEW); + } + + return $objects; + } + + private function getApplicationForPHID($phid) { + $phid_type = phid_get_type($phid); + + $type_objects = PhabricatorPHIDType::getTypes(array($phid_type)); + $type_object = idx($type_objects, $phid_type); + if (!$type_object) { + return null; + } + + return $type_object->getPHIDTypeApplicationClass(); + } + } diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index b6d3aeebcf..cb977dd681 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1,1528 +1,1568 @@ true, ); } public function testViewProject() { $user = $this->createUser(); $user->save(); $user2 = $this->createUser(); $user2->save(); $proj = $this->createProject($user); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj->setViewPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $can_view = PhabricatorPolicyCapability::CAN_VIEW; // When the view policy is set to "users", any user can see the project. $this->assertTrue((bool)$this->refreshProject($proj, $user)); $this->assertTrue((bool)$this->refreshProject($proj, $user2)); // When the view policy is set to "no one", members can still see the // project. $proj->setViewPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $this->assertTrue((bool)$this->refreshProject($proj, $user)); $this->assertFalse((bool)$this->refreshProject($proj, $user2)); } + public function testApplicationPolicy() { + $user = $this->createUser() + ->save(); + + $proj = $this->createProject($user); + + $this->assertTrue( + PhabricatorPolicyFilter::hasCapability( + $user, + $proj, + PhabricatorPolicyCapability::CAN_VIEW)); + + // Change the "Can Use Application" policy for Projecs to "No One". This + // should cause filtering checks to fail even when they are executed + // directly rather than via a Query. + $env = PhabricatorEnv::beginScopedEnv(); + $env->overrideEnvConfig( + 'phabricator.application-settings', + array( + 'PHID-APPS-PhabricatorProjectApplication' => array( + 'policy' => array( + 'view' => PhabricatorPolicies::POLICY_NOONE, + ), + ), + )); + + // Application visibility is cached because it does not normally change + // over the course of a single request. Drop the cache so the next filter + // test uses the new visibility. + PhabricatorCaches::destroyRequestCache(); + + $this->assertFalse( + PhabricatorPolicyFilter::hasCapability( + $user, + $proj, + PhabricatorPolicyCapability::CAN_VIEW)); + + unset($env); + } + public function testIsViewerMemberOrWatcher() { $user1 = $this->createUser() ->save(); $user2 = $this->createUser() ->save(); $user3 = $this->createUser() ->save(); $proj1 = $this->createProject($user1); $proj1 = $this->refreshProject($proj1, $user1); $this->joinProject($proj1, $user1); $this->joinProject($proj1, $user3); $this->watchProject($proj1, $user3); $proj1 = $this->refreshProject($proj1, $user1); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, false, true); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserWatcher($user1->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, true, false); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserMember($user2->getPHID())); $this->assertTrue($proj1->isUserMember($user3->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, true, true); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserMember($user2->getPHID())); $this->assertTrue($proj1->isUserMember($user3->getPHID())); $this->assertFalse($proj1->isUserWatcher($user1->getPHID())); $this->assertFalse($proj1->isUserWatcher($user2->getPHID())); $this->assertTrue($proj1->isUserWatcher($user3->getPHID())); } public function testEditProject() { $user = $this->createUser(); $user->save(); $user2 = $this->createUser(); $user2->save(); $proj = $this->createProject($user); // When edit and view policies are set to "user", anyone can edit. $proj->setViewPolicy(PhabricatorPolicies::POLICY_USER); $proj->setEditPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $this->assertTrue($this->attemptProjectEdit($proj, $user)); // When edit policy is set to "no one", no one can edit. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $caught = null; try { $this->attemptProjectEdit($proj, $user); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); } public function testAncestorMembers() { $user1 = $this->createUser(); $user1->save(); $user2 = $this->createUser(); $user2->save(); $parent = $this->createProject($user1); $child = $this->createProject($user1, $parent); $this->joinProject($child, $user1); $this->joinProject($child, $user2); $project = id(new PhabricatorProjectQuery()) ->setViewer($user1) ->withPHIDs(array($child->getPHID())) ->needAncestorMembers(true) ->executeOne(); $members = array_fuse($project->getParentProject()->getMemberPHIDs()); ksort($members); $expect = array_fuse( array( $user1->getPHID(), $user2->getPHID(), )); ksort($expect); $this->assertEqual($expect, $members); } public function testAncestryQueries() { $user = $this->createUser(); $user->save(); $ancestor = $this->createProject($user); $parent = $this->createProject($user, $ancestor); $child = $this->createProject($user, $parent); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(2, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withParentProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(1, count($projects)); $this->assertEqual( $parent->getPHID(), head($projects)->getPHID()); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(2, null) ->execute(); $this->assertEqual(1, count($projects)); $this->assertEqual( $child->getPHID(), head($projects)->getPHID()); $parent2 = $this->createProject($user, $ancestor); $child2 = $this->createProject($user, $parent2); $grandchild2 = $this->createProject($user, $child2); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(5, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withParentProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(2, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(2, null) ->execute(); $this->assertEqual(3, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(3, null) ->execute(); $this->assertEqual(1, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs( array( $child->getPHID(), $grandchild2->getPHID(), )) ->execute(); $this->assertEqual(2, count($projects)); } public function testMemberMaterialization() { $material_type = PhabricatorProjectMaterializedMemberEdgeType::EDGECONST; $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $child = $this->createProject($user, $parent); $this->joinProject($child, $user); $parent_material = PhabricatorEdgeQuery::loadDestinationPHIDs( $parent->getPHID(), $material_type); $this->assertEqual( array($user->getPHID()), $parent_material); } public function testMilestones() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $m1 = $this->createProject($user, $parent, true); $m2 = $this->createProject($user, $parent, true); $m3 = $this->createProject($user, $parent, true); $this->assertEqual(1, $m1->getMilestoneNumber()); $this->assertEqual(2, $m2->getMilestoneNumber()); $this->assertEqual(3, $m3->getMilestoneNumber()); } public function testMilestoneMembership() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $milestone = $this->createProject($user, $parent, true); $this->joinProject($parent, $user); $milestone = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($milestone->getPHID())) ->executeOne(); $this->assertTrue($milestone->isUserMember($user->getPHID())); $milestone = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($milestone->getPHID())) ->needMembers(true) ->executeOne(); $this->assertEqual( array($user->getPHID()), $milestone->getMemberPHIDs()); } public function testSameSlugAsName() { // It should be OK to type the primary hashtag into "additional hashtags", // even if the primary hashtag doesn't exist yet because you're creating // or renaming the project. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); // In this first case, set the name and slugs at the same time. $name = 'slugproject'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); $this->applyTransactions($project, $user, $xactions); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($name)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($name, $slugs)); // In this second case, set the name first and then the slugs separately. $name2 = 'slugproject2'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name2); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($name2)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($name2, $slugs)); } public function testDuplicateSlugs() { // Creating a project with multiple duplicate slugs should succeed. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); $input = 'duplicate'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($input, $input)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($input, $slugs)); } public function testNormalizeSlugs() { // When a user creates a project with slug "XxX360n0sc0perXxX", normalize // it before writing it. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); $input = 'NoRmAlIzE'; $expect = 'normalize'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($input)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($expect, $slugs)); // If another user tries to add the same slug in denormalized form, it // should be caught and fail, even though the database version of the slug // is normalized. $project2 = $this->createProject($user); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($input)); $caught = null; try { $this->applyTransactions($project2, $user, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $caught = $ex; } $this->assertTrue((bool)$caught); } public function testProjectMembersVisibility() { // This is primarily testing that you can create a project and set the // visibility or edit policy to "Project Members" immediately. $user1 = $this->createUser(); $user1->save(); $user2 = $this->createUser(); $user2->save(); $project = PhabricatorProject::initializeNewProject($user1); $name = pht('Test Project %d', mt_rand()); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue( id(new PhabricatorProjectMembersPolicyRule()) ->getObjectPolicyFullKey()); $edge_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue( array( '=' => array($user1->getPHID() => $user1->getPHID()), )); $this->applyTransactions($project, $user1, $xactions); $this->assertTrue((bool)$this->refreshProject($project, $user1)); $this->assertFalse((bool)$this->refreshProject($project, $user2)); $this->leaveProject($project, $user1); $this->assertFalse((bool)$this->refreshProject($project, $user1)); } public function testParentProject() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $child = $this->createProject($user, $parent); $this->assertTrue(true); $child = $this->refreshProject($child, $user); $this->assertEqual( $parent->getPHID(), $child->getParentProject()->getPHID()); $this->assertEqual(1, (int)$child->getProjectDepth()); $this->assertFalse( $child->isUserMember($user->getPHID())); $this->assertFalse( $child->getParentProject()->isUserMember($user->getPHID())); $this->joinProject($child, $user); $child = $this->refreshProject($child, $user); $this->assertTrue( $child->isUserMember($user->getPHID())); $this->assertTrue( $child->getParentProject()->isUserMember($user->getPHID())); // Test that hiding a parent hides the child. $user2 = $this->createUser(); $user2->save(); // Second user can see the project for now. $this->assertTrue((bool)$this->refreshProject($child, $user2)); // Hide the parent. $this->setViewPolicy($parent, $user, $user->getPHID()); // First user (who can see the parent because they are a member of // the child) can see the project. $this->assertTrue((bool)$this->refreshProject($child, $user)); // Second user can not, because they can't see the parent. $this->assertFalse((bool)$this->refreshProject($child, $user2)); } public function testSlugMaps() { // When querying by slugs, slugs should be normalized and the mapping // should be reported correctly. $user = $this->createUser(); $user->save(); $name = 'queryslugproject'; $name2 = 'QUERYslugPROJECT'; $slug = 'queryslugextra'; $slug2 = 'QuErYSlUgExTrA'; $project = PhabricatorProject::initializeNewProject($user); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->setNewValue(array($slug)); $this->applyTransactions($project, $user, $xactions); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($name)); $project_query->execute(); $map = $project_query->getSlugMap(); $this->assertEqual( array( $name => $project->getPHID(), ), ipull($map, 'projectPHID')); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($slug)); $project_query->execute(); $map = $project_query->getSlugMap(); $this->assertEqual( array( $slug => $project->getPHID(), ), ipull($map, 'projectPHID')); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($name, $slug, $name2, $slug2)); $project_query->execute(); $map = $project_query->getSlugMap(); $expect = array( $name => $project->getPHID(), $slug => $project->getPHID(), $name2 => $project->getPHID(), $slug2 => $project->getPHID(), ); $actual = ipull($map, 'projectPHID'); ksort($expect); ksort($actual); $this->assertEqual($expect, $actual); $expect = array( $name => $name, $slug => $slug, $name2 => $name, $slug2 => $slug, ); $actual = ipull($map, 'slug'); ksort($expect); ksort($actual); $this->assertEqual($expect, $actual); } public function testJoinLeaveProject() { $user = $this->createUser(); $user->save(); $proj = $this->createProjectWithNewAuthor(); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( (bool)$proj, pht( 'Assumption that projects are default visible '. 'to any user when created.')); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Arbitrary user not member of project.')); // Join the project. $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join works.')); // Join the project again. $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Joining an already-joined project is a no-op.')); // Leave the project. $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leave works.')); // Leave the project again. $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leaving an already-left project is a no-op.')); // If a user can't edit or join a project, joining fails. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $caught = null; try { $this->joinProject($proj, $user); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($ex instanceof Exception); // If a user can edit a project, they can join. $proj->setEditPolicy(PhabricatorPolicies::POLICY_USER); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join allowed with edit permission.')); $this->leaveProject($proj, $user); // If a user can join a project, they can join, even if they can't edit. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join allowed with join permission.')); // A user can leave a project even if they can't edit it or join. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leave allowed without any permission.')); } public function testComplexConstraints() { $user = $this->createUser(); $user->save(); $engineering = $this->createProject($user); $engineering_scan = $this->createProject($user, $engineering); $engineering_warp = $this->createProject($user, $engineering); $exploration = $this->createProject($user); $exploration_diplomacy = $this->createProject($user, $exploration); $task_engineering = $this->newTask( $user, array($engineering), pht('Engineering Only')); $task_exploration = $this->newTask( $user, array($exploration), pht('Exploration Only')); $task_warp_explore = $this->newTask( $user, array($engineering_warp, $exploration), pht('Warp to New Planet')); $task_diplomacy_scan = $this->newTask( $user, array($engineering_scan, $exploration_diplomacy), pht('Scan Diplomat')); $task_diplomacy = $this->newTask( $user, array($exploration_diplomacy), pht('Diplomatic Meeting')); $task_warp_scan = $this->newTask( $user, array($engineering_scan, $engineering_warp), pht('Scan Warp Drives')); $this->assertQueryByProjects( $user, array( $task_engineering, $task_warp_explore, $task_diplomacy_scan, $task_warp_scan, ), array($engineering), pht('All Engineering')); $this->assertQueryByProjects( $user, array( $task_diplomacy_scan, $task_warp_scan, ), array($engineering_scan), pht('All Scan')); $this->assertQueryByProjects( $user, array( $task_warp_explore, $task_diplomacy_scan, ), array($engineering, $exploration), pht('Engineering + Exploration')); // This is testing that a query for "Parent" and "Parent > Child" works // properly. $this->assertQueryByProjects( $user, array( $task_diplomacy_scan, $task_warp_scan, ), array($engineering, $engineering_scan), pht('Engineering + Scan')); } public function testTagAncestryConflicts() { $user = $this->createUser(); $user->save(); $stonework = $this->createProject($user); $stonework_masonry = $this->createProject($user, $stonework); $stonework_sculpting = $this->createProject($user, $stonework); $task = $this->newTask($user, array()); $this->assertEqual(array(), $this->getTaskProjects($task)); $this->addProjectTags($user, $task, array($stonework->getPHID())); $this->assertEqual( array( $stonework->getPHID(), ), $this->getTaskProjects($task)); // Adding a descendant should remove the parent. $this->addProjectTags($user, $task, array($stonework_masonry->getPHID())); $this->assertEqual( array( $stonework_masonry->getPHID(), ), $this->getTaskProjects($task)); // Adding an ancestor should remove the descendant. $this->addProjectTags($user, $task, array($stonework->getPHID())); $this->assertEqual( array( $stonework->getPHID(), ), $this->getTaskProjects($task)); // Adding two tags in the same hierarchy which are not mutual ancestors // should remove the ancestor but otherwise work fine. $this->addProjectTags( $user, $task, array( $stonework_masonry->getPHID(), $stonework_sculpting->getPHID(), )); $expect = array( $stonework_masonry->getPHID(), $stonework_sculpting->getPHID(), ); sort($expect); $this->assertEqual($expect, $this->getTaskProjects($task)); } public function testTagMilestoneConflicts() { $user = $this->createUser(); $user->save(); $stonework = $this->createProject($user); $stonework_1 = $this->createProject($user, $stonework, true); $stonework_2 = $this->createProject($user, $stonework, true); $task = $this->newTask($user, array()); $this->assertEqual(array(), $this->getTaskProjects($task)); $this->addProjectTags($user, $task, array($stonework->getPHID())); $this->assertEqual( array( $stonework->getPHID(), ), $this->getTaskProjects($task)); // Adding a milesone should remove the parent. $this->addProjectTags($user, $task, array($stonework_1->getPHID())); $this->assertEqual( array( $stonework_1->getPHID(), ), $this->getTaskProjects($task)); // Adding the parent should remove the milestone. $this->addProjectTags($user, $task, array($stonework->getPHID())); $this->assertEqual( array( $stonework->getPHID(), ), $this->getTaskProjects($task)); // First, add one milestone. $this->addProjectTags($user, $task, array($stonework_1->getPHID())); // Now, adding a second milestone should remove the first milestone. $this->addProjectTags($user, $task, array($stonework_2->getPHID())); $this->assertEqual( array( $stonework_2->getPHID(), ), $this->getTaskProjects($task)); } public function testBoardMoves() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $backlog = $this->addColumn($user, $board, 0); $column = $this->addColumn($user, $board, 1); // New tasks should appear in the backlog. $task1 = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task1); // Moving a task should move it to the destination column. $this->moveToColumn($user, $board, $task1, $backlog, $column); $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task1); // Same thing again, with a new task. $task2 = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task2); // Move it, too. $this->moveToColumn($user, $board, $task2, $backlog, $column); $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task2); // Now the stuff should be in the column, in order, with the more recently // moved task on top. $expect = array( $task2->getPHID(), $task1->getPHID(), ); $this->assertTasksInColumn($expect, $user, $board, $column); // Move the second task after the first task. $options = array( 'afterPHID' => $task1->getPHID(), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task1->getPHID(), $task2->getPHID(), ); $this->assertTasksInColumn($expect, $user, $board, $column); // Move the second task before the first task. $options = array( 'beforePHID' => $task1->getPHID(), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task2->getPHID(), $task1->getPHID(), ); $this->assertTasksInColumn($expect, $user, $board, $column); } public function testMilestoneMoves() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $backlog = $this->addColumn($user, $board, 0); // Create a task into the backlog. $task = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task); $milestone = $this->createProject($user, $board, true); $this->addProjectTags($user, $task, array($milestone->getPHID())); // We just want the side effect of looking at the board: creation of the // milestone column. $this->loadColumns($user, $board, $task); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($user) ->withProjectPHIDs(array($board->getPHID())) ->withProxyPHIDs(array($milestone->getPHID())) ->executeOne(); $this->assertTrue((bool)$column); // Moving the task to the milestone should have moved it to the milestone // column. $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task); // Move the task within the "Milestone" column. This should not affect // the projects the task is tagged with. See T10912. $task_a = $task; $task_b = $this->newTask($user, array($backlog)); $this->moveToColumn($user, $board, $task_b, $backlog, $column); $a_options = array( 'beforePHID' => $task_b->getPHID(), ); $b_options = array( 'beforePHID' => $task_a->getPHID(), ); $old_projects = $this->getTaskProjects($task); // Move the target task to the top. $this->moveToColumn($user, $board, $task_a, $column, $column, $a_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Move the other task. $this->moveToColumn($user, $board, $task_b, $column, $column, $b_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Move the target task again. $this->moveToColumn($user, $board, $task_a, $column, $column, $a_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Add the parent project to the task. This should move it out of the // milestone column and into the parent's backlog. $this->addProjectTags($user, $task, array($board->getPHID())); $expect_columns = array( $backlog->getPHID(), ); $this->assertColumns($expect_columns, $user, $board, $task); $new_projects = $this->getTaskProjects($task); $expect_projects = array( $board->getPHID(), ); $this->assertEqual($expect_projects, $new_projects); } public function testColumnExtendedPolicies() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $column = $this->addColumn($user, $board, 0); // At first, the user should be able to view and edit the column. $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertTrue($can_edit); // Now, set the project edit policy to "Members of Project". This should // disable editing. $members_policy = id(new PhabricatorProjectMembersPolicyRule()) ->getObjectPolicyFullKey(); $board->setEditPolicy($members_policy)->save(); $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertFalse($can_edit); // Now, join the project. This should make the column editable again. $this->joinProject($board, $user); $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); // This test has been failing randomly in a way that doesn't reproduce // on any host, so add some extra assertions to try to nail it down. $board = $this->refreshProject($board, $user, true); $this->assertTrue((bool)$board); $this->assertTrue($board->isUserMember($user->getPHID())); $can_view = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_VIEW); $this->assertTrue($can_view); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertTrue($can_edit); } private function moveToColumn( PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task, PhabricatorProjectColumn $src, PhabricatorProjectColumn $dst, $options = null) { $xactions = array(); if (!$options) { $options = array(); } $value = array( 'columnPHID' => $dst->getPHID(), ) + $options; $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COLUMNS) ->setNewValue(array($value)); $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); } private function assertColumns( array $expect, PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task) { $column_phids = $this->loadColumns($viewer, $board, $task); $this->assertEqual($expect, $column_phids); } private function loadColumns( PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) ->setObjectPHIDs( array( $task->getPHID(), )) ->executeLayout(); $columns = $engine->getObjectColumns($board->getPHID(), $task->getPHID()); $column_phids = mpull($columns, 'getPHID'); $column_phids = array_values($column_phids); return $column_phids; } private function assertTasksInColumn( array $expect, PhabricatorUser $viewer, PhabricatorProject $board, PhabricatorProjectColumn $column) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) ->setObjectPHIDs($expect) ->executeLayout(); $object_phids = $engine->getColumnObjectPHIDs( $board->getPHID(), $column->getPHID()); $object_phids = array_values($object_phids); $this->assertEqual($expect, $object_phids); } private function addColumn( PhabricatorUser $viewer, PhabricatorProject $project, $sequence) { $project->setHasWorkboard(1)->save(); return PhabricatorProjectColumn::initializeNewColumn($viewer) ->setSequence(0) ->setProperty('isDefault', ($sequence == 0)) ->setProjectPHID($project->getPHID()) ->save(); } private function getTaskProjects(ManiphestTask $task) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $task->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); sort($project_phids); return $project_phids; } private function attemptProjectEdit( PhabricatorProject $proj, PhabricatorUser $user, $skip_refresh = false) { $proj = $this->refreshProject($proj, $user, true); $new_name = $proj->getName().' '.mt_rand(); $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME); $xaction->setNewValue($new_name); $this->applyTransactions($proj, $user, array($xaction)); return true; } private function addProjectTags( PhabricatorUser $viewer, ManiphestTask $task, array $phids) { $xactions = array(); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setNewValue( array( '+' => array_fuse($phids), )); $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); } private function newTask( PhabricatorUser $viewer, array $projects, $name = null) { $task = ManiphestTask::initializeNewTask($viewer); if (!strlen($name)) { $name = pht('Test Task'); } $xactions = array(); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_TITLE) ->setNewValue($name); if ($projects) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setNewValue( array( '=' => array_fuse(mpull($projects, 'getPHID')), )); } $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); return $task; } private function assertQueryByProjects( PhabricatorUser $viewer, array $expect, array $projects, $label = null) { $datasource = id(new PhabricatorProjectLogicalDatasource()) ->setViewer($viewer); $project_phids = mpull($projects, 'getPHID'); $constraints = $datasource->evaluateTokens($project_phids); $query = id(new ManiphestTaskQuery()) ->setViewer($viewer); $query->withEdgeLogicConstraints( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, $constraints); $tasks = $query->execute(); $expect_phids = mpull($expect, 'getTitle', 'getPHID'); ksort($expect_phids); $actual_phids = mpull($tasks, 'getTitle', 'getPHID'); ksort($actual_phids); $this->assertEqual($expect_phids, $actual_phids, $label); } private function refreshProject( PhabricatorProject $project, PhabricatorUser $viewer, $need_members = false, $need_watchers = false) { $results = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->needMembers($need_members) ->needWatchers($need_watchers) ->withIDs(array($project->getID())) ->execute(); if ($results) { return head($results); } else { return null; } } private function refreshColumn( PhabricatorUser $viewer, PhabricatorProjectColumn $column) { $results = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($column->getID())) ->execute(); if ($results) { return head($results); } else { return null; } } private function createProject( PhabricatorUser $user, PhabricatorProject $parent = null, $is_milestone = false) { $project = PhabricatorProject::initializeNewProject($user); $name = pht('Test Project %d', mt_rand()); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); if ($parent) { if ($is_milestone) { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE) ->setNewValue($parent->getPHID()); } else { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT) ->setNewValue($parent->getPHID()); } } $this->applyTransactions($project, $user, $xactions); // Force these values immediately; they are normally updated by the // index engine. if ($parent) { if ($is_milestone) { $parent->setHasMilestones(1)->save(); } else { $parent->setHasSubprojects(1)->save(); } } return $project; } private function setViewPolicy( PhabricatorProject $project, PhabricatorUser $user, $policy) { $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($policy); $this->applyTransactions($project, $user, $xactions); return $project; } private function createProjectWithNewAuthor() { $author = $this->createUser(); $author->save(); $project = $this->createProject($author); return $project; } private function createUser() { $rand = mt_rand(); $user = new PhabricatorUser(); $user->setUsername('unittestuser'.$rand); $user->setRealName(pht('Unit Test User %d', $rand)); return $user; } private function joinProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->joinOrLeaveProject($project, $user, '+'); } private function leaveProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->joinOrLeaveProject($project, $user, '-'); } private function watchProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->watchOrUnwatchProject($project, $user, '+'); } private function unwatchProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->watchOrUnwatchProject($project, $user, '-'); } private function joinOrLeaveProject( PhabricatorProject $project, PhabricatorUser $user, $operation) { return $this->applyProjectEdgeTransaction( $project, $user, $operation, PhabricatorProjectProjectHasMemberEdgeType::EDGECONST); } private function watchOrUnwatchProject( PhabricatorProject $project, PhabricatorUser $user, $operation) { return $this->applyProjectEdgeTransaction( $project, $user, $operation, PhabricatorObjectHasWatcherEdgeType::EDGECONST); } private function applyProjectEdgeTransaction( PhabricatorProject $project, PhabricatorUser $user, $operation, $edge_type) { $spec = array( $operation => array($user->getPHID() => $user->getPHID()), ); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue($spec); $this->applyTransactions($project, $user, $xactions); return $project; } private function applyTransactions( PhabricatorProject $project, PhabricatorUser $user, array $xactions) { $editor = id(new PhabricatorProjectTransactionEditor()) ->setActor($user) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($project, $xactions); } }