diff --git a/composer.json b/composer.json index c0da283ceb..69335e3a65 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "guzzlehttp/guzzle": "^5.3", "guzzlehttp/ringphp": "^1.1", "platformsh/console-form": ">=0.0.37 <2.0", - "platformsh/client": ">=0.78.1 <2.0", + "platformsh/client": ">=0.79.0 <2.0", "symfony/console": "^3.0 >=3.2", "symfony/yaml": "^3.0 || ^2.6", "symfony/finder": "^3.0", diff --git a/composer.lock b/composer.lock index 2f67edbbe6..3d7498e569 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a7baeaf0a600d06a2099b5c73997a253", + "content-hash": "363a27ed00bf2e8a635fedb1e7fedc62", "packages": [ { "name": "cocur/slugify", @@ -918,16 +918,16 @@ }, { "name": "platformsh/client", - "version": "0.78.1", + "version": "0.79.0", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "4aac948237b97fabc199c8fe040599cddd96af49" + "reference": "13d9cf4cc76067ea8cb9e08660ad7bcf5004806e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/4aac948237b97fabc199c8fe040599cddd96af49", - "reference": "4aac948237b97fabc199c8fe040599cddd96af49", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/13d9cf4cc76067ea8cb9e08660ad7bcf5004806e", + "reference": "13d9cf4cc76067ea8cb9e08660ad7bcf5004806e", "shasum": "" }, "require": { @@ -959,9 +959,9 @@ "description": "Platform.sh API client", "support": { "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.78.1" + "source": "https://github.com/platformsh/platformsh-client-php/tree/0.79.0" }, - "time": "2023-12-12T16:08:35+00:00" + "time": "2023-12-18T12:27:13+00:00" }, { "name": "platformsh/console-form", diff --git a/src/Command/User/UserAddCommand.php b/src/Command/User/UserAddCommand.php index c35f2ad8c4..85aae2ea6c 100644 --- a/src/Command/User/UserAddCommand.php +++ b/src/Command/User/UserAddCommand.php @@ -1,14 +1,15 @@ getArgument('email'); if (!$email) { $update = stripos($input->getFirstArgument(), ':u'); - if ($update && $input->isInteractive()) { - $choices = []; - foreach ($this->api()->getProjectAccesses($project) as $access) { - $account = $this->api()->getAccount($access); - $choices[$account['email']] = $this->getUserLabel($access); - } - $email = $questionHelper->choose($choices, 'Enter a number to choose a user to update:'); + if (!$input->isInteractive()) { + throw new InvalidArgumentException('An email address is required (in non-interactive mode).'); + } elseif ($update) { + $email = $questionHelper->choose($this->listUsers($project), 'Enter a number to choose a user to update:'); } else { $question = new Question("Enter the user's email address: "); $question->setValidator(function ($answer) { @@ -140,41 +138,59 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->validateEmail($email); - // Check the user's existing role on the project. - $existingProjectAccess = $this->api()->loadProjectAccessByEmail($project, $email); $existingTypeRoles = []; - if ($existingProjectAccess) { - // Exit if the user is the owner already. - if ($existingProjectAccess->id === $project->owner) { - if ($hasOutput) { - $this->stdErr->writeln(''); - } - - $this->stdErr->writeln(sprintf('The user %s is the owner of %s.', $this->getUserLabel($existingProjectAccess), $this->api()->getProjectLabel($project))); - if ($specifiedProjectRole || $specifiedTypeRoles) { - $this->stdErr->writeln(''); - $this->stdErr->writeln("The project owner's role(s) cannot be changed."); + $existingProjectRole = null; + $existingUserLabel = null; + $existingUserId = null; + + /** + * @var ProjectUserAccess|ProjectAccess|null $selection + */ + $selection = $this->loadProjectUserByEmail($project, $email); + if ($selection instanceof ProjectUserAccess) { + $existingUserId = $selection->user_id; + $existingProjectRole = $selection->getProjectRole(); + $existingTypeRoles = $selection->getEnvironmentTypeRoles(); + $userInfo = $selection->getUserInfo(); + $existingUserLabel = sprintf('%s (%s)', trim($userInfo->first_name . ' ' . $userInfo->last_name), $userInfo->email); + } elseif ($selection instanceof ProjectAccess) { + $existingUserId = $selection->id; + $existingProjectRole = $selection->role; + $existingUserLabel = $this->getUserLabel($selection); + $existingTypeRoles = $this->getTypeRoles($selection, $environmentTypes); + } + + if ($existingUserId !== null) { + $this->debug(sprintf('The user %s already exists on the project (user ID: %s)', $email, $existingUserId)); + } + + // Exit if the user is the owner already. + if ($existingUserId !== null && $existingUserId === $project->owner) { + if ($hasOutput) { + $this->stdErr->writeln(''); + } - return 1; - } + $this->stdErr->writeln(sprintf('The user %s is the owner of %s.', $existingUserLabel, $this->api()->getProjectLabel($project))); + if ($specifiedProjectRole || $specifiedTypeRoles) { + $this->stdErr->writeln(''); + $this->stdErr->writeln("The project owner's role(s) cannot be changed."); - return 0; + return 1; } - // Check the user's existing role(s) on the project's environments and types. - $existingTypeRoles = $this->getTypeRoles($existingProjectAccess, $environmentTypes); + return 0; } // If the user already exists, print a summary of their roles on the // project and environments. - if ($existingProjectAccess) { + if ($existingUserId !== null) { if ($hasOutput) { $this->stdErr->writeln(''); } - $this->stdErr->writeln(sprintf('Current role(s) of %s on %s:', $this->getUserLabel($existingProjectAccess), $this->api()->getProjectLabel($project))); - $this->stdErr->writeln(sprintf(' Project role: %s', $existingProjectAccess->role)); - if ($existingProjectAccess->role !== ProjectAccess::ROLE_ADMIN) { + $this->stdErr->writeln(sprintf('Current role(s) of %s on %s:', $existingUserLabel, $this->api()->getProjectLabel($project))); + $this->stdErr->writeln(sprintf(' Project role: %s', $existingProjectRole)); + if ($existingProjectRole !== ProjectAccess::ROLE_ADMIN) { foreach ($environmentTypes as $type) { $role = isset($existingTypeRoles[$type->id]) ? $existingTypeRoles[$type->id] : '[none]'; $this->stdErr->writeln(sprintf(' Role on environment type %s: %s', $type->id, $role)); @@ -184,7 +200,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Resolve or merge the project role. - $desiredProjectRole = $specifiedProjectRole ?: ($existingProjectAccess ? $existingProjectAccess->role : ProjectAccess::ROLE_VIEWER); + $desiredProjectRole = $specifiedProjectRole ?: ($existingProjectRole ?: ProjectAccess::ROLE_VIEWER); $provideProjectForm = !$input->getOption('role') && $input->isInteractive(); if ($provideProjectForm) { if ($hasOutput) { @@ -219,9 +235,9 @@ protected function execute(InputInterface $input, OutputInterface $output) // Build a list of the changes that are going to be made. $changesText = []; - if ($existingProjectAccess) { - if ($existingProjectAccess->role !== $desiredProjectRole) { - $changesText[] = sprintf('Project role: %s -> %s', $existingProjectAccess->role, $desiredProjectRole); + if ($existingUserId !== null) { + if ($existingProjectRole !== $desiredProjectRole) { + $changesText[] = sprintf('Project role: %s -> %s', $existingProjectRole, $desiredProjectRole); } } else { $changesText[] = sprintf('Project role: %s', $desiredProjectRole); @@ -271,12 +287,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('No environment types selected.'); $this->stdErr->writeln('A non-admin user must be added to at least one environment type.'); - if ($existingProjectAccess) { + if ($existingUserId !== null) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To delete the user, run: %s user:delete %s', $this->config()->get('application.executable'), - $this->api()->getAccount($existingProjectAccess)['email'] + OsUtil::escapeShellArg($email) )); } @@ -290,7 +306,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Print a summary of the changes that are about to be made. - if ($existingProjectAccess) { + if ($existingUserId !== null) { $this->stdErr->writeln('Summary of changes:'); } else { $this->stdErr->writeln(sprintf('Adding the user %s to %s:', $email, $this->api()->getProjectLabel($project))); @@ -301,7 +317,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); // Ask for confirmation. - if ($existingProjectAccess) { + if ($existingUserId !== null) { if (!$questionHelper->confirm('Are you sure you want to make these change(s)?')) { return 1; } @@ -317,7 +333,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); // If the user does not already exist on the project, then use the Invitations API. - if (!$existingProjectAccess) { + if ($existingUserId === null) { $this->stdErr->writeln('Inviting the user to the project...'); $permissions = []; foreach ($desiredTypeRoles as $type => $role) { @@ -340,56 +356,68 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - // Make the desired changes at the project level. - if ($existingProjectAccess->role !== $desiredProjectRole) { - $this->stdErr->writeln("Setting the user's project role to: $desiredProjectRole"); - $result = $existingProjectAccess->update(['role' => $desiredProjectRole]); - $activities = $result->getActivities(); - $userId = $existingProjectAccess->id; - } else { - $userId = $existingProjectAccess->id; - $activities = []; - } - - // Make the desired changes at the environment type level. - if ($desiredProjectRole !== ProjectAccess::ROLE_ADMIN) { - foreach ($typeChanges as $typeId => $role) { - $type = $project->getEnvironmentType($typeId); - if (!$type) { - $this->stdErr->writeln('Environment type not found: ' . $typeId . ''); - continue; - } - $access = $type->getUser($userId); - if ($role === 'none') { - if ($access) { - $this->stdErr->writeln("Removing the user from the environment type $typeId"); - $result = $access->delete(); - } else { + $activities = []; + if ($selection instanceof ProjectUserAccess) { + $permissions = [$desiredProjectRole]; + foreach ($desiredTypeRoles as $typeId => $role) { + $permissions[] = sprintf('%s:%s', $typeId, $role); + } + if ($permissions != $selection->permissions) { + $this->stdErr->writeln("Updating the user's project access"); + $this->debug('New permissions: ' . implode(', ', $permissions)); + $selection->update(['permissions' => $permissions]); + } else { + $this->stdErr->writeln('No changes to make'); + $this->debug('Permissions match: ' . implode(', ', $permissions)); + } + } elseif ($selection instanceof ProjectAccess) { + // Make the desired changes at the project level. + if ($existingProjectRole !== $desiredProjectRole) { + $this->stdErr->writeln("Setting the user's project role to: $desiredProjectRole"); + $result = $selection->update(['role' => $desiredProjectRole]); + $activities = $result->getActivities(); + } + + // Make the desired changes at the environment type level. + if ($desiredProjectRole !== ProjectAccess::ROLE_ADMIN) { + foreach ($typeChanges as $typeId => $role) { + $type = $project->getEnvironmentType($typeId); + if (!$type) { + $this->stdErr->writeln('Environment type not found: ' . $typeId . ''); continue; } - } elseif ($access) { - if ($access->role === $role) { - continue; + $access = $type->getUser($existingUserId); + if ($role === 'none') { + if ($access) { + $this->stdErr->writeln("Removing the user from the environment type $typeId"); + $result = $access->delete(); + } else { + continue; + } + } elseif ($access) { + if ($access->role === $role) { + continue; + } + $this->stdErr->writeln("Setting the user's role on the environment type $typeId to: $role"); + $result = $access->update(['role' => $role]); + } else { + $this->stdErr->writeln("Adding the user to the environment type: $typeId"); + $result = $type->addUser($existingUserId, $role); } - $this->stdErr->writeln("Setting the user's role on the environment type $typeId to: $role"); - $result = $access->update(['role' => $role]); - } else { - $this->stdErr->writeln("Adding the user to the environment type: $typeId"); - $result = $type->addUser($userId, $role); + $activities = array_merge($activities, $result->getActivities()); } - $activities = array_merge($activities, $result->getActivities()); } } // Wait for activities to complete. - if (!$activities) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { + if ($activities && $this->shouldWait($input)) { /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ $activityMonitor = $this->getService('activity_monitor'); if (!$activityMonitor->waitMultiple($activities, $project)) { return 1; } + } elseif (!$this->centralizedPermissionsEnabled()) { + $this->redeployWarning(); } return 0; diff --git a/src/Command/User/UserCommandBase.php b/src/Command/User/UserCommandBase.php new file mode 100644 index 0000000000..66a87e4461 --- /dev/null +++ b/src/Command/User/UserCommandBase.php @@ -0,0 +1,145 @@ +config()->get('api.centralized_permissions'); + } + + /** + * Loads a project user by email address. + * + * Uses 2 different APIs to support backwards compatibility. + * + * @return ProjectUserAccess|ProjectAccess|null + * Null if the user does not exist, a ProjectUserAccess object if + * "Centralized Permissions" are enabled, or a ProjectAccess object + * otherwise. + */ + protected function loadProjectUserByEmail(Project $project, $email, $reset = false) + { + $cacheKey = $project->id . ':' . $email; + if ($reset || !isset(self::$userCache[$cacheKey])) { + if ($this->centralizedPermissionsEnabled()) { + self::$userCache[$cacheKey] = $this->doLoadProjectUserByEmail($project, $email); + } else { + self::$userCache[$cacheKey] = $this->doLoadLegacyProjectAccessByEmail($project, $email); + } + } + return self::$userCache[$cacheKey]; + } + + /** + * Loads a legacy project user ("project access" record) by email address. + * + * @param Project $project + * @param string $email + * + * @return ProjectAccess|null + */ + private function doLoadLegacyProjectAccessByEmail(Project $project, $email) + { + foreach ($this->api()->getProjectAccesses($project) as $user) { + $account = $this->api()->getAccount($user); + if ($account['email'] === $email || strtolower($account['email']) === strtolower($email)) { + return $user; + } + } + + return null; + } + + /** + * Loads a project user by email, by paging through all the users on the project. + * + * @TODO replace this with a more efficient API when available + * + * @param string $email + * @param Project $project + * @return ProjectUserAccess|null + */ + private function doLoadProjectUserByEmail(Project $project, $email) + { + $client = $this->api()->getHttpClient(); + + $progress = new ProgressMessage($this->stdErr); + $progress->showIfOutputDecorated('Loading user information...'); + $endpointUrl = $project->getUri() . '/user-access'; + $collection = ProjectUserAccess::getCollectionWithParent($endpointUrl, $client, [ + 'query' => ['page[size]' => 50], + ])['collection']; + $userRef = null; + while (true) { + $data = $collection->getData(); + if (!empty($data['ref:users'])) { + foreach ($data['ref:users'] as $candidate) { + /** @var UserRef $candidate */ + if ($candidate->email === $email) { + $userRef = $candidate; + break; + } + } + } + if (isset($userRef)) { + foreach ($data['items'] as $itemData) { + if (isset($itemData['user_id']) && $itemData['user_id'] === $userRef->id) { + $itemData['ref:users'][$userRef->id] = $userRef; + $progress->done(); + return new ProjectUserAccess($itemData, $endpointUrl, $client); + } + } + } + if (!$collection->hasNextPage()) { + break; + } + $collection = $collection->fetchNextPage(); + } + $progress->done(); + return null; + } + + /** + * Lists project users. + * + * Uses 2 different APIs to support backwards compatibility. + * + * @param Project $project + * + * @return array An array of user labels keyed by email address. + */ + protected function listUsers(Project $project) + { + $choices = []; + if ($this->centralizedPermissionsEnabled()) { + $result = ProjectUserAccess::getCollectionWithParent($project->getUri() . '/user-access', $this->api()->getHttpClient(), ['query' => ['page[size]' => 200]]); + foreach ($result['items'] as $item) { + /** @var ProjectUserAccess $item */ + $userInfo = $item->getUserInfo(); + $choices[$userInfo->email] = sprintf('%s (%s)', trim($userInfo->first_name . ' ' . $userInfo->last_name), $userInfo->email); + } + } else { + foreach ($this->api()->getProjectAccesses($project) as $access) { + $account = $this->api()->getAccount($access); + $choices[$account['email']] = sprintf('%s (%s)', $account['display_name'], $account['email']); + } + } + ksort($choices, SORT_NATURAL); + return $choices; + } +} diff --git a/src/Command/User/UserDeleteCommand.php b/src/Command/User/UserDeleteCommand.php index 7faf04a604..8650cc8df6 100644 --- a/src/Command/User/UserDeleteCommand.php +++ b/src/Command/User/UserDeleteCommand.php @@ -1,12 +1,12 @@ validateInput($input); - $project = $this->getSelectedProject(); - $email = $input->getArgument('email'); - $selectedUser = $this->api()->loadProjectAccessByEmail($project, $email); - if (empty($selectedUser)) { + + $selection = $this->loadProjectUserByEmail($project, $email); + if (!$selection) { $this->stdErr->writeln("User not found: $email"); return 1; } + $userId = $selection instanceof ProjectUserAccess ? $selection->user_id : $selection->id; - if ($project->owner === $selectedUser->id) { + if ($project->owner === $userId) { $this->stdErr->writeln(sprintf( 'The user %s is the owner of the project %s.', $email, @@ -49,7 +49,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $result = $selectedUser->delete(); + $result = $selection->delete(); $this->stdErr->writeln("User $email deleted"); @@ -63,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // If the user was deleting themselves from the project, then invalidate // the projects cache. - if ($this->api()->getMyUserId() === $selectedUser->id) { + if ($this->api()->getMyUserId() === $userId) { $this->api()->clearProjectsCache(); } diff --git a/src/Command/User/UserGetCommand.php b/src/Command/User/UserGetCommand.php index 5d08909c5a..ea9dc64e3f 100644 --- a/src/Command/User/UserGetCommand.php +++ b/src/Command/User/UserGetCommand.php @@ -1,14 +1,14 @@ getArgument('email'); if ($email === null && $input->isInteractive()) { - $choices = []; - foreach ($this->api()->getProjectAccesses($project) as $access) { - $account = $this->api()->getAccount($access); - $choices[$account['email']] = sprintf('%s (%s)', $account['display_name'], $account['email']); - } - $email = $questionHelper->choose($choices, 'Enter a number to choose a user:'); + $email = $questionHelper->choose($this->listUsers($project), 'Enter a number to choose a user:'); } - $projectAccess = $this->api()->loadProjectAccessByEmail($project, $email); - if (!$projectAccess) { + + $selection = $this->loadProjectUserByEmail($project, $email); + if (!$selection) { $this->stdErr->writeln("User not found: $email"); return 1; } if ($input->getOption('pipe')) { - $this->displayRole($projectAccess, $level, $output); + $this->displayRole($selection, $level, $output); return 0; } @@ -89,17 +85,23 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * @param \Platformsh\Client\Model\ProjectAccess $projectAccess - * @param string $level - * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param ProjectAccess|ProjectUserAccess $user + * @param string $level + * @param OutputInterface $output */ - private function displayRole(ProjectAccess $projectAccess, $level, OutputInterface $output) + private function displayRole($user, $level, OutputInterface $output) { - if ($level !== 'environment') { - $currentRole = $projectAccess->role; + if ($level === 'environment') { + if ($user instanceof ProjectAccess) { + $access = $this->getSelectedEnvironment()->getUser($user->id); + $currentRole = $access ? $access->role : 'none'; + } else { + $typeRoles = $user->getEnvironmentTypeRoles(); + $envType = $this->getSelectedEnvironment()->type; + $currentRole = isset($typeRoles[$envType]) ? $typeRoles[$envType] : 'none'; + } } else { - $access = $this->getSelectedEnvironment()->getUser($projectAccess->id); - $currentRole = $access ? $access->role : 'none'; + $currentRole = $user instanceof ProjectAccess ? $user->role : $user->getProjectRole(); } $output->writeln($currentRole); } diff --git a/src/Command/User/UserListCommand.php b/src/Command/User/UserListCommand.php index 72b732328d..a9b09b6034 100644 --- a/src/Command/User/UserListCommand.php +++ b/src/Command/User/UserListCommand.php @@ -1,14 +1,14 @@ 'Email address', 'Name', 'role' => 'Project role', 'ID']; + private $tableHeader = ['email' => 'Email address', 'name' => 'Name', 'role' => 'Project role', 'id' => 'ID']; protected function configure() { @@ -26,25 +26,45 @@ protected function execute(InputInterface $input, OutputInterface $output) $project = $this->getSelectedProject(); - $rows = []; - $i = 0; /** @var \Platformsh\Cli\Service\Table $table */ $table = $this->getService('table'); - foreach ($this->api()->getProjectAccesses($project) as $projectAccess) { - $account = $this->api()->getAccount($projectAccess); - $role = $projectAccess->role; - $weight = $i++; - if ($project->owner === $projectAccess->id) { - $weight = -1; - if (!$table->formatIsMachineReadable()) { - $role .= ' (owner)'; + + $rows = []; + + if ($this->centralizedPermissionsEnabled()) { + $result = ProjectUserAccess::getCollectionWithParent($project->getUri() . '/user-access', $this->api()->getHttpClient(), ['query' => ['page[size]' => 200]]); + foreach ($result['items'] as $item) { + /** @var ProjectUserAccess $item */ + $userInfo = $item->getUserInfo(); + $rows[] = [ + 'email' => $userInfo->email, + 'name' => trim(sprintf('%s %s', $userInfo->first_name, $userInfo->last_name)), + 'role' => $item->getProjectRole(), + 'id' => $item->user_id, + ]; + } + } else { + $i = 0; + foreach ($this->api()->getProjectAccesses($project) as $projectAccess) { + $account = $this->api()->getAccount($projectAccess); + $role = $projectAccess->role; + $weight = $i++; + if ($project->owner === $projectAccess->id) { + $weight = -1; + if (!$table->formatIsMachineReadable()) { + $role .= ' (owner)'; + } } + $rows[$weight] = [ + 'email' => $account['email'], + 'name' => $account['display_name'], + 'role' => $role, + 'id' => $projectAccess->id, + ]; } - $rows[$weight] = ['email' => $account['email'], $account['display_name'], 'role' => $role, $projectAccess->id]; + ksort($rows); } - ksort($rows); - if (!$table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Users on the project %s:', diff --git a/src/Service/Api.php b/src/Service/Api.php index 125e2471ee..8ee70a514d 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1060,27 +1060,6 @@ public function getProjectAccesses(Project $project, $reset = false) return self::$projectAccessesCache[$project->id]; } - /** - * Load a project user ("project access" record) by email address. - * - * @param Project $project - * @param string $email - * @param bool $reset - * - * @return ProjectAccess|false - */ - public function loadProjectAccessByEmail(Project $project, $email, $reset = false) - { - foreach ($this->getProjectAccesses($project, $reset) as $user) { - $account = $this->getAccount($user); - if ($account['email'] === $email || strtolower($account['email']) === strtolower($email)) { - return $user; - } - } - - return false; - } - /** * Returns a project label. *