diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 0301e453a..cfe468a54 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -1,664 +1,696 @@ <?php final class PhabricatorUser extends PhabricatorUserDAO implements PhutilPerson { const SESSION_TABLE = 'phabricator_session'; const NAMETOKEN_TABLE = 'user_nametoken'; protected $phid; protected $userName; protected $realName; protected $sex; protected $translation; protected $passwordSalt; protected $passwordHash; protected $profileImagePHID; protected $timezoneIdentifier = ''; protected $consoleEnabled = 0; protected $consoleVisible = 0; protected $consoleTab = ''; protected $conduitCertificate; protected $isSystemAgent = 0; protected $isAdmin = 0; protected $isDisabled = 0; private $preferences = null; + private $omnipotent = false; protected function readField($field) { switch ($field) { case 'timezoneIdentifier': // If the user hasn't set one, guess the server's time. return nonempty( $this->timezoneIdentifier, date_default_timezone_get()); // Make sure these return booleans. case 'isAdmin': return (bool)$this->isAdmin; case 'isDisabled': return (bool)$this->isDisabled; case 'isSystemAgent': return (bool)$this->isSystemAgent; default: return parent::readField($field); } } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_PARTIAL_OBJECTS => true, ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPHIDConstants::PHID_TYPE_USER); } public function setPassword(PhutilOpaqueEnvelope $envelope) { if (!$this->getPHID()) { throw new Exception( "You can not set a password for an unsaved user because their PHID ". "is a salt component in the password hash."); } if (!strlen($envelope->openEnvelope())) { $this->setPasswordHash(''); } else { $this->setPasswordSalt(md5(mt_rand())); $hash = $this->hashPassword($envelope); $this->setPasswordHash($hash); } return $this; } // To satisfy PhutilPerson. public function getSex() { return $this->sex; } public function getTranslation() { try { if ($this->translation && class_exists($this->translation) && is_subclass_of($this->translation, 'PhabricatorTranslation')) { return $this->translation; } } catch (PhutilMissingSymbolException $ex) { return null; } return null; } public function isLoggedIn() { return !($this->getPHID() === null); } public function save() { if (!$this->getConduitCertificate()) { $this->setConduitCertificate($this->generateConduitCertificate()); } $result = parent::save(); $this->updateNameTokens(); id(new PhabricatorSearchIndexer()) ->indexDocumentByPHID($this->getPHID()); return $result; } private function generateConduitCertificate() { return Filesystem::readRandomCharacters(255); } public function comparePassword(PhutilOpaqueEnvelope $envelope) { if (!strlen($envelope->openEnvelope())) { return false; } if (!strlen($this->getPasswordHash())) { return false; } $password_hash = $this->hashPassword($envelope); return ($password_hash === $this->getPasswordHash()); } private function hashPassword(PhutilOpaqueEnvelope $envelope) { $hash = $this->getUsername(). $envelope->openEnvelope(). $this->getPHID(). $this->getPasswordSalt(); for ($ii = 0; $ii < 1000; $ii++) { $hash = md5($hash); } return $hash; } const CSRF_CYCLE_FREQUENCY = 3600; const CSRF_TOKEN_LENGTH = 16; const EMAIL_CYCLE_FREQUENCY = 86400; const EMAIL_TOKEN_LENGTH = 24; public function getCSRFToken($offset = 0) { return $this->generateToken( time() + (self::CSRF_CYCLE_FREQUENCY * $offset), self::CSRF_CYCLE_FREQUENCY, PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), self::CSRF_TOKEN_LENGTH); } public function validateCSRFToken($token) { if (!$this->getPHID()) { return true; } // When the user posts a form, we check that it contains a valid CSRF token. // Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept // either the current token, the next token (users can submit a "future" // token if you have two web frontends that have some clock skew) or any of // the last 6 tokens. This means that pages are valid for up to 7 hours. // There is also some Javascript which periodically refreshes the CSRF // tokens on each page, so theoretically pages should be valid indefinitely. // However, this code may fail to run (if the user loses their internet // connection, or there's a JS problem, or they don't have JS enabled). // Choosing the size of the window in which we accept old CSRF tokens is // an issue of balancing concerns between security and usability. We could // choose a very narrow (e.g., 1-hour) window to reduce vulnerability to // attacks using captured CSRF tokens, but it's also more likely that real // users will be affected by this, e.g. if they close their laptop for an // hour, open it back up, and try to submit a form before the CSRF refresh // can kick in. Since the user experience of submitting a form with expired // CSRF is often quite bad (you basically lose data, or it's a big pain to // recover at least) and I believe we gain little additional protection // by keeping the window very short (the overwhelming value here is in // preventing blind attacks, and most attacks which can capture CSRF tokens // can also just capture authentication information [sniffing networks] // or act as the user [xss]) the 7 hour default seems like a reasonable // balance. Other major platforms have much longer CSRF token lifetimes, // like Rails (session duration) and Django (forever), which suggests this // is a reasonable analysis. $csrf_window = 6; for ($ii = -$csrf_window; $ii <= 1; $ii++) { $valid = $this->getCSRFToken($ii); if ($token == $valid) { return true; } } return false; } private function generateToken($epoch, $frequency, $key, $len) { $time_block = floor($epoch / $frequency); $vec = $this->getPHID().$this->getPasswordHash().$key.$time_block; return substr(PhabricatorHash::digest($vec), 0, $len); } /** * Issue a new session key to this user. Phabricator supports different * types of sessions (like "web" and "conduit") and each session type may * have multiple concurrent sessions (this allows a user to be logged in on * multiple browsers at the same time, for instance). * * Note that this method is transport-agnostic and does not set cookies or * issue other types of tokens, it ONLY generates a new session key. * * You can configure the maximum number of concurrent sessions for various * session types in the Phabricator configuration. * * @param string Session type, like "web". * @return string Newly generated session key. */ public function establishSession($session_type) { $conn_w = $this->establishConnection('w'); if (strpos($session_type, '-') !== false) { throw new Exception("Session type must not contain hyphen ('-')!"); } // We allow multiple sessions of the same type, so when a caller requests // a new session of type "web", we give them the first available session in // "web-1", "web-2", ..., "web-N", up to some configurable limit. If none // of these sessions is available, we overwrite the oldest session and // reissue a new one in its place. $session_limit = 1; switch ($session_type) { case 'web': $session_limit = PhabricatorEnv::getEnvConfig('auth.sessions.web'); break; case 'conduit': $session_limit = PhabricatorEnv::getEnvConfig('auth.sessions.conduit'); break; default: throw new Exception("Unknown session type '{$session_type}'!"); } $session_limit = (int)$session_limit; if ($session_limit <= 0) { throw new Exception( "Session limit for '{$session_type}' must be at least 1!"); } // NOTE: Session establishment is sensitive to race conditions, as when // piping `arc` to `arc`: // // arc export ... | arc paste ... // // To avoid this, we overwrite an old session only if it hasn't been // re-established since we read it. // Consume entropy to generate a new session key, forestalling the eventual // heat death of the universe. $session_key = Filesystem::readRandomCharacters(40); // Load all the currently active sessions. $sessions = queryfx_all( $conn_w, 'SELECT type, sessionKey, sessionStart FROM %T WHERE userPHID = %s AND type LIKE %>', PhabricatorUser::SESSION_TABLE, $this->getPHID(), $session_type.'-'); $sessions = ipull($sessions, null, 'type'); $sessions = isort($sessions, 'sessionStart'); $existing_sessions = array_keys($sessions); // UNGUARDED WRITES: Logging-in users don't have CSRF stuff yet. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $retries = 0; while (true) { // Choose which 'type' we'll actually establish, i.e. what number we're // going to append to the basic session type. To do this, just check all // the numbers sequentially until we find an available session. $establish_type = null; for ($ii = 1; $ii <= $session_limit; $ii++) { $try_type = $session_type.'-'.$ii; if (!in_array($try_type, $existing_sessions)) { $establish_type = $try_type; $expect_key = $session_key; $existing_sessions[] = $try_type; // Ensure the row exists so we can issue an update below. We don't // care if we race here or not. queryfx( $conn_w, 'INSERT IGNORE INTO %T (userPHID, type, sessionKey, sessionStart) VALUES (%s, %s, %s, 0)', self::SESSION_TABLE, $this->getPHID(), $establish_type, $session_key); break; } } // If we didn't find an available session, choose the oldest session and // overwrite it. if (!$establish_type) { $oldest = reset($sessions); $establish_type = $oldest['type']; $expect_key = $oldest['sessionKey']; } // This is so that we'll only overwrite the session if it hasn't been // refreshed since we read it. If it has, the session key will be // different and we know we're racing other processes. Whichever one // won gets the session, we go back and try again. queryfx( $conn_w, 'UPDATE %T SET sessionKey = %s, sessionStart = UNIX_TIMESTAMP() WHERE userPHID = %s AND type = %s AND sessionKey = %s', self::SESSION_TABLE, $session_key, $this->getPHID(), $establish_type, $expect_key); if ($conn_w->getAffectedRows()) { // The update worked, so the session is valid. break; } else { // We know this just got grabbed, so don't try it again. unset($sessions[$establish_type]); } if (++$retries > $session_limit) { throw new Exception("Failed to establish a session!"); } } $log = PhabricatorUserLog::newLog( $this, $this, PhabricatorUserLog::ACTION_LOGIN); $log->setDetails( array( 'session_type' => $session_type, 'session_issued' => $establish_type, )); $log->setSession($session_key); $log->save(); return $session_key; } public function destroySession($session_key) { $conn_w = $this->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE userPHID = %s AND sessionKey = %s', self::SESSION_TABLE, $this->getPHID(), $session_key); } private function generateEmailToken( PhabricatorUserEmail $email, $offset = 0) { $key = implode( '-', array( PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), $this->getPHID(), $email->getVerificationCode(), )); return $this->generateToken( time() + ($offset * self::EMAIL_CYCLE_FREQUENCY), self::EMAIL_CYCLE_FREQUENCY, $key, self::EMAIL_TOKEN_LENGTH); } public function validateEmailToken( PhabricatorUserEmail $email, $token) { for ($ii = -1; $ii <= 1; $ii++) { $valid = $this->generateEmailToken($email, $ii); if ($token == $valid) { return true; } } return false; } public function getEmailLoginURI(PhabricatorUserEmail $email = null) { if (!$email) { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception("User has no primary email!"); } } $token = $this->generateEmailToken($email); $uri = PhabricatorEnv::getProductionURI('/login/etoken/'.$token.'/'); $uri = new PhutilURI($uri); return $uri->alter('email', $email->getAddress()); } public function loadPrimaryEmailAddress() { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception("User has no primary email address!"); } return $email->getAddress(); } public function loadPrimaryEmail() { return $this->loadOneRelative( new PhabricatorUserEmail(), 'userPHID', 'getPHID', '(isPrimary = 1)'); } public function loadPreferences() { if ($this->preferences) { return $this->preferences; } $preferences = id(new PhabricatorUserPreferences())->loadOneWhere( 'userPHID = %s', $this->getPHID()); if (!$preferences) { $preferences = new PhabricatorUserPreferences(); $preferences->setUserPHID($this->getPHID()); $default_dict = array( PhabricatorUserPreferences::PREFERENCE_TITLES => 'glyph', PhabricatorUserPreferences::PREFERENCE_EDITOR => '', PhabricatorUserPreferences::PREFERENCE_MONOSPACED => '', PhabricatorUserPreferences::PREFERENCE_DARK_CONSOLE => 0); $preferences->setPreferences($default_dict); } $this->preferences = $preferences; return $preferences; } public function loadEditorLink($path, $line, $callsign) { $editor = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_EDITOR); if (is_array($path)) { $multiedit = $this->loadPreferences()->getPreference( PhabricatorUserPreferences::PREFERENCE_MULTIEDIT); switch ($multiedit) { case '': $path = implode(' ', $path); break; case 'disable': return null; } } if ($editor) { return strtr($editor, array( '%%' => '%', '%f' => phutil_escape_uri($path), '%l' => phutil_escape_uri($line), '%r' => phutil_escape_uri($callsign), )); } } private static function tokenizeName($name) { if (function_exists('mb_strtolower')) { $name = mb_strtolower($name, 'UTF-8'); } else { $name = strtolower($name); } $name = trim($name); if (!strlen($name)) { return array(); } return preg_split('/\s+/', $name); } /** * Populate the nametoken table, which used to fetch typeahead results. When * a user types "linc", we want to match "Abraham Lincoln" from on-demand * typeahead sources. To do this, we need a separate table of name fragments. */ public function updateNameTokens() { $tokens = array_merge( self::tokenizeName($this->getRealName()), self::tokenizeName($this->getUserName())); $tokens = array_unique($tokens); $table = self::NAMETOKEN_TABLE; $conn_w = $this->establishConnection('w'); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf( $conn_w, '(%d, %s)', $this->getID(), $token); } queryfx( $conn_w, 'DELETE FROM %T WHERE userID = %d', $table, $this->getID()); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (userID, token) VALUES %Q', $table, implode(', ', $sql)); } } public function sendWelcomeEmail(PhabricatorUser $admin) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $user_username = $this->getUserName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $base_uri = PhabricatorEnv::getProductionURI('/'); $uri = $this->getEmailLoginURI(); $body = <<<EOBODY Welcome to Phabricator! {$admin_username} ({$admin_realname}) has created an account for you. Username: {$user_username} To login to Phabricator, follow this link and set a password: {$uri} After you have set a password, you can login in the future by going here: {$base_uri} EOBODY; if (!$is_serious) { $body .= <<<EOBODY Love, Phabricator EOBODY; } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setSubject('[Phabricator] Welcome to Phabricator') ->setBody($body) ->setFrom($admin->getPHID()) ->saveAndSend(); } public function sendUsernameChangeEmail( PhabricatorUser $admin, $old_username) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $new_username = $this->getUserName(); $password_instructions = null; if (PhabricatorEnv::getEnvConfig('auth.password-auth-enabled')) { $uri = $this->getEmailLoginURI(); $password_instructions = <<<EOTXT If you use a password to login, you'll need to reset it before you can login again. You can reset your password by following this link: {$uri} And, of course, you'll need to use your new username to login from now on. If you use OAuth to login, nothing should change. EOTXT; } $body = <<<EOBODY {$admin_username} ({$admin_realname}) has changed your Phabricator username. Old Username: {$old_username} New Username: {$new_username} {$password_instructions} EOBODY; $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setSubject('[Phabricator] Username Changed') ->setBody($body) ->setFrom($admin->getPHID()) ->saveAndSend(); } public static function describeValidUsername() { return 'Usernames must contain only numbers, letters, period, underscore '. 'and hyphen, and can not end with a period.'; } public static function validateUsername($username) { // NOTE: If you update this, make sure to update: // // - Remarkup rule for @mentions. // - Routing rule for "/p/username/". // - Unit tests, obviously. // - describeValidUsername() method, above. return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]$/', $username); } public static function getDefaultProfileImageURI() { return celerity_get_resource_uri('/rsrc/image/avatar.png'); } public function loadProfileImageURI() { $src_phid = $this->getProfileImagePHID(); if ($src_phid) { $file = id(new PhabricatorFile())->loadOneWhere('phid = %s', $src_phid); if ($file) { return $file->getBestURI(); } } return self::getDefaultProfileImageURI(); } public function getFullName() { return $this->getUsername().' ('.$this->getRealName().')'; } public function __toString() { return $this->getUsername(); } public static function loadOneWithEmailAddress($address) { $email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $address); if (!$email) { return null; } return id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $email->getUserPHID()); } + +/* -( Omnipotence )-------------------------------------------------------- */ + + + /** + * Returns true if this user is omnipotent. Omnipotent users bypass all policy + * checks. + * + * @return bool True if the user bypasses policy checks. + */ + public function isOmnipotent() { + return $this->omnipotent; + } + + + /** + * Get an omnipotent user object for use in contexts where there is no acting + * user, notably daemons. + * + * @return PhabricatorUser An omnipotent user. + */ + public static function getOmnipotentUser() { + static $user = null; + if (!$user) { + $user = new PhabricatorUser(); + $user->omnipotent = true; + $user->makeEphemeral(); + } + return $user; + } + } diff --git a/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php b/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php index 13bb00efb..aff5ee7d6 100644 --- a/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php +++ b/src/applications/policy/__tests__/PhabricatorPolicyTestCase.php @@ -1,235 +1,253 @@ <?php final class PhabricatorPolicyTestCase extends PhabricatorTestCase { /** * Verify that any user can view an object with POLICY_PUBLIC. */ public function testPublicPolicyEnabled() { $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('policy.allow-public', true); $this->expectVisibility( $this->buildObject(PhabricatorPolicies::POLICY_PUBLIC), array( 'public' => true, 'user' => true, 'admin' => true, ), '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, ), '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, ), '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, ), '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, ), '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()), 'Invisible objects are ignored.'); $this->assertEqual( 0, count($query->setLimit(3)->setOffset(3)->execute()), 'Offset pages through visible objects only.'); $this->assertEqual( 2, count($query->setLimit(3)->setOffset(1)->execute()), 'Offsets work correctly.'); $this->assertEqual( 2, count($query->setLimit(0)->setOffset(1)->execute()), '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()), 'Limits work.'); $this->assertEqual( 2, count($query->setLimit(3)->setOffset(4)->execute()), '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 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; try { $result = $query->executeOne(); } catch (PhabricatorPolicyException $ex) { $caught = $ex; } if ($expect) { $this->assertEqual( $object, $result, "{$description} with user {$spec} should succeed."); } else { $this->assertEqual( true, $caught instanceof PhabricatorPolicyException, "{$description} with user {$spec} should fail."); } } } /** * 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("Unknown user spec '{$spec}'."); } return $user; } } diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php index 1ce618b8b..765583b63 100644 --- a/src/applications/policy/filter/PhabricatorPolicyFilter.php +++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php @@ -1,280 +1,284 @@ <?php final class PhabricatorPolicyFilter { private $viewer; private $objects; private $capabilities; private $raisePolicyExceptions; private $userProjects; public static function mustRetainCapability( PhabricatorUser $user, PhabricatorPolicyInterface $object, $capability) { if (!self::hasCapability($user, $object, $capability)) { throw new Exception( "You can not make that edit, because it would remove your ability ". "to '{$capability}' the object."); } } public static function requireCapability( PhabricatorUser $user, PhabricatorPolicyInterface $object, $capability) { $filter = new PhabricatorPolicyFilter(); $filter->setViewer($user); $filter->requireCapabilities(array($capability)); $filter->raisePolicyExceptions(true); $filter->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 apply(array $objects) { assert_instances_of($objects, 'PhabricatorPolicyInterface'); $viewer = $this->viewer; $capabilities = $this->capabilities; if (!$viewer || !$capabilities) { throw new Exception( 'Call setViewer() and requireCapabilities() before apply()!'); } $filtered = array(); $need_projects = array(); foreach ($objects as $key => $object) { $object_capabilities = $object->getCapabilities(); foreach ($capabilities as $capability) { if (!in_array($capability, $object_capabilities)) { throw new Exception( "Testing for capability '{$capability}' on an object which does ". "not have that capability!"); } $policy = $object->getPolicy($capability); $type = phid_get_type($policy); if ($type == PhabricatorPHIDConstants::PHID_TYPE_PROJ) { $need_projects[] = $policy; } } } if ($need_projects) { $need_projects = array_unique($need_projects); // If projects have recursive policies, automatically fail them rather // than looping. This will fall back to automatic capabilities and // resolve the policies in a sensible way. static $querying_projects = array(); foreach ($need_projects as $key => $project) { if (empty($querying_projects[$project])) { $querying_projects[$project] = true; continue; } unset($need_projects[$key]); } if ($need_projects) { $caught = null; try { $projects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withMemberPHIDs(array($viewer->getPHID())) ->withPHIDs($need_projects) ->execute(); } catch (Exception $ex) { $caught = $ex; } foreach ($need_projects as $key => $project) { unset($querying_projects[$project]); } if ($caught) { throw $caught; } $projects = mpull($projects, null, 'getPHID'); $this->userProjects[$viewer->getPHID()] = $projects; } } foreach ($objects as $key => $object) { $object_capabilities = $object->getCapabilities(); 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; } } return $filtered; } private function checkCapability( PhabricatorPolicyInterface $object, $capability) { $policy = $object->getPolicy($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 anything other // than "view", restrict the policy to "user". if ($capability != PhabricatorPolicyCapability::CAN_VIEW) { $policy = PhabricatorPolicies::POLICY_USER; } } $viewer = $this->viewer; + if ($viewer->isOmnipotent()) { + return true; + } + 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: $type = phid_get_type($policy); if ($type == PhabricatorPHIDConstants::PHID_TYPE_PROJ) { if (isset($this->userProjects[$viewer->getPHID()][$policy])) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else if ($type == PhabricatorPHIDConstants::PHID_TYPE_USER) { if ($viewer->getPHID() == $policy) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else { throw new Exception("Object has unknown policy '{$policy}'!"); } } return false; } private function rejectImpossiblePolicy( PhabricatorPolicyInterface $object, $policy, $capability) { if (!$this->raisePolicyExceptions) { return; } // TODO: clean this up $verb = $capability; throw new PhabricatorPolicyException( "This object has an impossible {$verb} policy."); } private function rejectObject($object, $policy, $capability) { if (!$this->raisePolicyExceptions) { return; } // TODO: clean this up $verb = $capability; $message = "You do not have permission to {$verb} this object."; switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: $who = "This is curious, since anyone can {$verb} the object."; break; case PhabricatorPolicies::POLICY_USER: $who = "To {$verb} this object, you must be logged in."; break; case PhabricatorPolicies::POLICY_ADMIN: $who = "To {$verb} this object, you must be an administrator."; break; case PhabricatorPolicies::POLICY_NOONE: $who = "No one can {$verb} this object."; break; default: $handle = PhabricatorObjectHandleData::loadOneHandle( $policy, $this->viewer); $type = phid_get_type($policy); if ($type == PhabricatorPHIDConstants::PHID_TYPE_PROJ) { $who = "To {$verb} this object, you must be a member of project ". "'".$handle->getFullName()."'."; } else if ($type == PhabricatorPHIDConstants::PHID_TYPE_USER) { $who = "Only '".$handle->getFullName()."' can {$verb} this object."; } else { $who = "It is unclear who can {$verb} this object."; } break; } throw new PhabricatorPolicyException("{$message} {$who}"); } }