diff --git a/composer.json b/composer.json index c0da283ceb..40924e880a 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.2 <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..37c6aa05ef 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": "01e924ca0297c790dc0f022e2cd89dd0", "packages": [ { "name": "cocur/slugify", @@ -918,16 +918,16 @@ }, { "name": "platformsh/client", - "version": "0.78.1", + "version": "0.79.1", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "4aac948237b97fabc199c8fe040599cddd96af49" + "reference": "9ff1a5666016539185f73cce7b1d46f3bae3add7" }, "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/9ff1a5666016539185f73cce7b1d46f3bae3add7", + "reference": "9ff1a5666016539185f73cce7b1d46f3bae3add7", "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.1" }, - "time": "2023-12-12T16:08:35+00:00" + "time": "2023-12-19T15:53:24+00:00" }, { "name": "platformsh/console-form", diff --git a/src/Command/CompletionCommand.php b/src/Command/CompletionCommand.php index cda28cb507..838be4937c 100644 --- a/src/Command/CompletionCommand.php +++ b/src/Command/CompletionCommand.php @@ -72,24 +72,12 @@ protected function runCompletion() Completion::TYPE_ARGUMENT, [$this, 'getEnvironmentsForCheckout'] ), - new Completion( - 'user:role', - 'email', - Completion::TYPE_ARGUMENT, - [$this, 'getUserEmails'] - ), new Completion( 'user:role', 'level', Completion::TYPE_OPTION, ['project', 'environment'] ), - new Completion( - 'user:delete', - 'email', - Completion::TYPE_ARGUMENT, - [$this, 'getUserEmails'] - ), new Completion\ShellPathCompletion( 'ssh-key:add', 'path', @@ -288,27 +276,6 @@ public function getEnvironments() return array_keys($this->api->getEnvironments($project, false, false)); } - /** - * Get a list of user email addresses. - * - * @return string[] - */ - public function getUserEmails() - { - $project = $this->getProject(); - if (!$project) { - return []; - } - - $emails = []; - foreach ($this->api->getProjectAccesses($project) as $projectAccess) { - $account = $this->api->getAccount($projectAccess); - $emails[] = $account['email']; - } - - return $emails; - } - /** * Get the project ID the user has already entered on the command line. * diff --git a/src/Command/User/UserAddCommand.php b/src/Command/User/UserAddCommand.php index c35f2ad8c4..bca530f3d7 100644 --- a/src/Command/User/UserAddCommand.php +++ b/src/Command/User/UserAddCommand.php @@ -1,14 +1,14 @@ getSpecifiedEnvironmentRoles($roleInput, $this->api()->getEnvironments($project)); } - if ($specifiedProjectRole === ProjectAccess::ROLE_ADMIN && (!empty($specifiedTypeRoles) || !empty($specifiedEnvironmentRoles))) { + if ($specifiedProjectRole === ProjectUserAccess::ROLE_ADMIN && (!empty($specifiedTypeRoles) || !empty($specifiedEnvironmentRoles))) { $this->warnProjectAdminConflictingRoles(); return 1; } @@ -118,63 +118,94 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Process the [email] argument. - $email = $input->getArgument('email'); - if (!$email) { + $email = null; + if ($emailOrId = $input->getArgument('email')) { + $selection = $this->loadProjectUser($project, $emailOrId); $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); + if ($update && !$selection) { + throw new InvalidArgumentException('User not found: ' . $emailOrId); + } + } else { + $update = stripos($input->getFirstArgument(), ':u'); + if (!$input->isInteractive()) { + throw new InvalidArgumentException('An email address is required (in non-interactive mode).'); + } elseif ($update) { + $userId = $questionHelper->choose($this->listUsers($project), 'Enter a number to choose a user to update:'); + $hasOutput = true; + $selection = $this->loadProjectUser($project, $userId); + if (!$selection) { + throw new InvalidArgumentException('User not found: ' . $userId); } - $email = $questionHelper->choose($choices, 'Enter a number to choose a user to update:'); } else { $question = new Question("Enter the user's email address: "); $question->setValidator(function ($answer) { - return $this->validateEmail($answer); + if (empty($value)) { + throw new InvalidArgumentException('An email address is required.'); + } + if (!$filtered = filter_var($answer, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException('Invalid email address: ' . $answer); + } + + return $filtered; }); $question->setMaxAttempts(5); $email = $questionHelper->ask($input, $this->stdErr, $question); $hasOutput = true; + // A user may or may not already exist with this email address. + $selection = $this->loadProjectUser($project, $email); } } - $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; + + if ($selection instanceof ProjectAccess) { + $existingUserId = $selection->id; + $existingUserLabel = $this->getUserLabel($selection); + $existingProjectRole = $selection->role; + $existingTypeRoles = $this->getTypeRoles($selection, $environmentTypes); + $email = $this->legacyUserInfo($selection)['email']; + } elseif ($selection) { + $existingUserId = $selection->user_id; + $existingUserLabel = $this->getUserLabel($selection); + $existingProjectRole = $selection->getProjectRole(); + $existingTypeRoles = $selection->getEnvironmentTypeRoles(); + $email = $selection->getUserInfo()->email; + } + + if ($existingUserId !== null) { + $this->debug(sprintf('User %s found with 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 !== ProjectUserAccess::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 +215,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 ?: ProjectUserAccess::ROLE_VIEWER); $provideProjectForm = !$input->getOption('role') && $input->isInteractive(); if ($provideProjectForm) { if ($hasOutput) { @@ -196,10 +227,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $desiredTypeRoles = []; $provideEnvironmentTypeForm = $input->isInteractive() - && $desiredProjectRole !== ProjectAccess::ROLE_ADMIN + && $desiredProjectRole !== ProjectUserAccess::ROLE_ADMIN && !$specifiedTypeRoles; // Resolve or merge the environment type role(s). - if ($desiredProjectRole !== ProjectAccess::ROLE_ADMIN) { + if ($desiredProjectRole !== ProjectUserAccess::ROLE_ADMIN) { foreach ($environmentTypes as $type) { $id = $type->id; if (isset($specifiedTypeRoles[$id])) { @@ -219,15 +250,15 @@ 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); } $typeChanges = []; - if ($desiredProjectRole !== ProjectAccess::ROLE_ADMIN) { + if ($desiredProjectRole !== ProjectUserAccess::ROLE_ADMIN) { if ($desiredTypeRoles) { foreach ($environmentTypes as $environmentType) { $id = $environmentType->id; @@ -267,16 +298,16 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Require project non-admins to be added to at least one environment. - if ($desiredProjectRole === ProjectAccess::ROLE_VIEWER && !$desiredTypeRoles) { + if ($desiredProjectRole === ProjectUserAccess::ROLE_VIEWER && !$desiredTypeRoles) { $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) )); } @@ -284,13 +315,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Prevent changing environment access for project admins. - if ($desiredProjectRole === ProjectAccess::ROLE_ADMIN && $specifiedTypeRoles) { + if ($desiredProjectRole === ProjectUserAccess::ROLE_ADMIN && $specifiedTypeRoles) { $this->warnProjectAdminConflictingRoles(); return 1; } // 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 +332,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 +348,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 +371,69 @@ 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]); + $this->stdErr->writeln('Access was updated successfully.'); + } 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; @@ -402,7 +446,7 @@ protected function execute(InputInterface $input, OutputInterface $output) */ private function validateProjectRole($value) { - return $this->matchRole($value, ProjectAccess::$roles); + return $this->matchRole($value, ProjectUserAccess::$projectRoles); } /** @@ -412,28 +456,7 @@ private function validateProjectRole($value) */ private function validateEnvironmentRole($value) { - return $this->matchRole($value, array_merge(EnvironmentAccess::$roles, ['none'])); - } - - /** - * Validate an email address. - * - * @param string $value - * - * @throws InvalidArgumentException - * - * @return string - */ - private function validateEmail($value) - { - if (empty($value)) { - throw new InvalidArgumentException('An email address is required.'); - } - if (!$filtered = filter_var($value, FILTER_VALIDATE_EMAIL)) { - throw new InvalidArgumentException('Invalid email address: ' . $value); - } - - return $filtered; + return $this->matchRole($value, array_merge(ProjectUserAccess::$environmentTypeRoles, ['none'])); } /** @@ -486,20 +509,6 @@ private function describeRoleInput(array $roles) }, $roles)) . ']'; } - /** - * Return a label describing a user. - * - * @param ProjectAccess $access - * - * @return string - */ - private function getUserLabel(ProjectAccess $access) - { - $account = $this->api()->getAccount($access); - - return sprintf('%s (%s)', $account['display_name'], $account['email']); - } - /** * Show the form for entering the project role. * @@ -513,17 +522,17 @@ private function showProjectRoleForm($defaultRole, InputInterface $input) /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ $questionHelper = $this->getService('question_helper'); - $this->stdErr->writeln("The user's project role can be " . $this->describeRoles(ProjectAccess::$roles) . '.'); + $this->stdErr->writeln("The user's project role can be " . $this->describeRoles(ProjectUserAccess::$projectRoles) . '.'); $this->stdErr->writeln(''); $question = new Question( - sprintf('Project role (default: %s) %s: ', $defaultRole, $this->describeRoleInput(ProjectAccess::$roles)), + sprintf('Project role (default: %s) %s: ', $defaultRole, $this->describeRoleInput(ProjectUserAccess::$projectRoles)), $defaultRole ); $question->setValidator(function ($answer) { return $this->validateProjectRole($answer); }); $question->setMaxAttempts(5); - $question->setAutocompleterValues(ProjectAccess::$roles); + $question->setAutocompleterValues(ProjectUserAccess::$projectRoles); return $questionHelper->ask($input, $this->stdErr, $question); } @@ -579,7 +588,7 @@ private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTy /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ $questionHelper = $this->getService('question_helper'); $desiredTypeRoles = []; - $validRoles = array_merge(EnvironmentAccess::$roles, ['none']); + $validRoles = array_merge(ProjectUserAccess::$environmentTypeRoles, ['none']); $this->stdErr->writeln("The user's environment type role(s) can be " . $this->describeRoles($validRoles) . '.'); $initials = $this->describeRoleInput($validRoles); $this->stdErr->writeln(''); @@ -668,12 +677,11 @@ private function getSpecifiedEnvironmentRoles(array $roles, array $environments) * An array of role options (e.g. type:role or environment:role). * The $roles array will be modified to remove the values that were used. * @param EnvironmentType[] $environmentTypes - * @param bool $ignoreErrors * * @return array * An array of environment type roles, keyed by environment type ID. */ - private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes, $ignoreErrors = true) + private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes) { $typeRoles = []; $typeIds = array_map(function (EnvironmentType $type) { return $type->id; }, $environmentTypes); @@ -684,12 +692,9 @@ private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes, $ list($id, $role) = explode(':', $role, 2); $role = $this->validateEnvironmentRole($role); // Match type IDs by wildcard. - // Error for non-wildcard matches. $matched = Wildcard::select($typeIds, [$id]); if (empty($matched)) { - if (!$ignoreErrors) { - throw new InvalidArgumentException('No environment type IDs match: ' . $id); - } + $this->stdErr->writeln('No environment type IDs match: ' . $id . ''); continue; } foreach ($matched as $typeId) { @@ -727,7 +732,7 @@ private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentR } foreach ($byType as $type => $roles) { - if (count(\array_unique($roles, SORT_STRING)) > 1) { + if (count(\array_unique($roles)) > 1) { $stdErr->writeln(\sprintf("Conflicting roles were given for environments of type %s:", $type)); \ksort($roles, SORT_STRING); foreach ($roles as $id => $role) { @@ -769,6 +774,6 @@ private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentR private function warnProjectAdminConflictingRoles() { $this->stdErr->writeln('A project admin has administrative access to all environment types.'); - $this->stdErr->writeln("To set the user's environment type role(s), set their project role to '" . ProjectAccess::ROLE_VIEWER . "'."); + $this->stdErr->writeln("To set the user's environment type role(s), set their project role to '" . ProjectUserAccess::ROLE_VIEWER . "'."); } } diff --git a/src/Command/User/UserCommandBase.php b/src/Command/User/UserCommandBase.php new file mode 100644 index 0000000000..3cae67a6b6 --- /dev/null +++ b/src/Command/User/UserCommandBase.php @@ -0,0 +1,217 @@ +config()->get('api.centralized_permissions'); + } + + /** + * Loads a legacy project user ("project access" record) by ID. + * + * @param Project $project + * @param string $id + * + * @return ProjectAccess|null + */ + private function doLoadLegacyProjectAccessById(Project $project, $id) + { + return ProjectAccess::get($id, $project->getUri() . '/access', $this->api()->getHttpClient()) ?: null; + } + + /** + * Loads a project user by email address. + * + * Uses 2 different APIs to support backwards compatibility. + * + * @param Project $project + * @param string $identifier + * @param bool $reset + * + * @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 loadProjectUser(Project $project, $identifier, $reset = false) + { + $byEmail = strpos($identifier, '@') !== false; + $cacheKey = $project->id . ':' . $identifier; + if ($reset || !isset(self::$userCache[$cacheKey])) { + if ($this->centralizedPermissionsEnabled()) { + self::$userCache[$cacheKey] = $byEmail + ? $this->doLoadProjectUserByEmail($project, $identifier) + : $this->doLoadProjectUserById($project, $identifier); + } else { + self::$userCache[$cacheKey] = $byEmail + ? $this->doLoadLegacyProjectAccessByEmail($project, $identifier) + : $this->doLoadLegacyProjectAccessById($project, $identifier); + } + } + 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 ($project->getUsers() as $user) { + $info = $this->legacyUserInfo($user); + if ($info['email'] === $email || strtolower($info['email']) === strtolower($email)) { + return $user; + } + } + + return null; + } + + /** + * Extracts a user's account info embedded in the legacy access API. + * + * @param ProjectAccess $access + * + * @return array{id: string, email: string, display_name: string, created_at: string, updated_at: ?string} + */ + protected function legacyUserInfo(ProjectAccess $access) + { + $data = $access->getData(); + if (isset($data['_embedded']['users'])) { + foreach ($data['_embedded']['users'] as $userData) { + if ($userData['id'] === $access->id) { + return $userData; + } + } + } + throw new \RuntimeException('Failed to find user information for project access item: ' . $access->id); + } + + /** + * Loads a project user by ID. + * + * @param string $id + * @param Project $project + * @return ProjectUserAccess|null + */ + private function doLoadProjectUserById(Project $project, $id) + { + $client = $this->api()->getHttpClient(); + $endpointUrl = $project->getUri() . '/user-access'; + return ProjectUserAccess::get($id, $endpointUrl, $client) ?: 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]' => 200], + ])['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 || strtolower($candidate->email) === strtolower($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 user ID. + */ + protected function listUsers(Project $project) + { + $choices = []; + if ($this->centralizedPermissionsEnabled()) { + $items = ProjectUserAccess::getCollection($project->getUri() . '/user-access', 0, ['query' => ['page[size]' => 200]], $this->api()->getHttpClient()); + foreach ($items as $item) { + $choices[$item->user_id] = $this->getUserLabel($item); + } + } else { + foreach ($project->getUsers() as $access) { + $choices[$access->id] = $this->getUserLabel($access); + } + } + natcasesort($choices); + return $choices; + } + + /** + * Returns a label describing a user. + * + * @param ProjectAccess|ProjectUserAccess $access + * + * @return string + */ + protected function getUserLabel($access) + { + $format = '%s (%s)'; + if ($access instanceof ProjectAccess) { + $info = $this->legacyUserInfo($access); + + return sprintf($format, $info['display_name'], $info['email']); + } + $info = $access->getUserInfo(); + return sprintf($format, trim($info->first_name . ' ' . $info->last_name), $info->email); + } +} diff --git a/src/Command/User/UserDeleteCommand.php b/src/Command/User/UserDeleteCommand.php index 7faf04a604..5417643903 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->loadProjectUser($project, $email); + if (!$selection) { $this->stdErr->writeln("User not found: $email"); return 1; } + $userId = $selection instanceof ProjectUserAccess ? $selection->user_id : $selection->id; + $email = $selection instanceof ProjectUserAccess ? $selection->getUserInfo()->email : $this->legacyUserInfo($selection)['email']; - 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,21 +50,21 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $result = $selectedUser->delete(); + $result = $selection->delete(); $this->stdErr->writeln("User $email deleted"); - if (!$result->getActivities()) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { + if ($result->getActivities() && $this->shouldWait($input)) { /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ $activityMonitor = $this->getService('activity_monitor'); $activityMonitor->waitMultiple($result->getActivities(), $project); + } elseif (!$this->centralizedPermissionsEnabled()) { + $this->redeployWarning(); } // 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..b0f75d3156 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->loadProjectUser($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..a7aedb6e2c 100644 --- a/src/Command/User/UserListCommand.php +++ b/src/Command/User/UserListCommand.php @@ -1,14 +1,22 @@ 'Email address', 'Name', 'role' => 'Project role', 'ID']; + private $tableHeader = [ + 'email' => 'Email address', + 'name' => 'Name', + 'role' => 'Project role', + 'id' => 'ID', + 'granted_at' => 'Granted at', + 'updated_at' => 'Updated at', + ]; + private $defaultColumns = ['email', 'name', 'role', 'id']; protected function configure() { @@ -16,7 +24,12 @@ protected function configure() ->setName('user:list') ->setAliases(['users']) ->setDescription('List project users'); - Table::configureInput($this->getDefinition(), $this->tableHeader); + + if ($this->centralizedPermissionsEnabled()) { + $this->tableHeader['permissions'] = 'Permissions'; + } + + Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); $this->addProjectOption(); } @@ -26,24 +39,55 @@ 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)'; - } + /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ + $formatter = $this->getService('property_formatter'); + + $rows = []; + + if ($this->centralizedPermissionsEnabled()) { + $result = ProjectUserAccess::getCollectionWithParent($project->getUri() . '/user-access', $this->api()->getHttpClient(), ['query' => ['page[size]' => 200]]); + /** @var ProjectUserAccess $item */ + foreach ($result['items'] as $item) { + $info = $item->getUserInfo(); + $rows[] = [ + 'email' => $info->email, + 'name' => trim(sprintf('%s %s', $info->first_name, $info->last_name)), + 'role' => $item->getProjectRole(), + 'id' => $item->user_id, + 'permissions' => $formatter->format($item->permissions, 'permissions'), + 'granted_at' => $formatter->format($item->granted_at, 'granted_at'), + 'updated_at' => $formatter->format($item->updated_at, 'updated_at'), + ]; + } + } else { + foreach ($project->getUsers() as $projectAccess) { + $info = $this->legacyUserInfo($projectAccess); + $rows[] = [ + 'email' => $info['email'], + 'name' => $info['display_name'], + 'role' => $projectAccess->role, + 'id' => $projectAccess->id, + 'granted_at' => $formatter->format($info['created_at'], 'granted_at'), + 'updated_at' => $formatter->format($info['updated_at'] ?: $info['created_at'], 'updated_at'), + ]; } - $rows[$weight] = ['email' => $account['email'], $account['display_name'], 'role' => $role, $projectAccess->id]; } - ksort($rows); + $ownerKey = null; + foreach ($rows as $key => $row) { + if ($row['id'] === $project->owner) { + $ownerKey = $key; + break; + } + } + if (isset($ownerKey)) { + $ownerRow = $rows[$ownerKey]; + $ownerRow['role'] .= ' (owner)'; + unset($rows[$ownerKey]); + array_unshift($rows, $ownerRow); + } if (!$table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( @@ -52,7 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output) )); } - $table->render(array_values($rows), $this->tableHeader); + $table->render($rows, $this->tableHeader, $this->defaultColumns); if (!$table->formatIsMachineReadable()) { $this->stdErr->writeln(''); diff --git a/src/Service/Api.php b/src/Service/Api.php index 1ef068bff7..b2703b0d20 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -26,7 +26,6 @@ use Platformsh\Client\Model\EnvironmentType; use Platformsh\Client\Model\Organization\Organization; use Platformsh\Client\Model\Project; -use Platformsh\Client\Model\ProjectAccess; use Platformsh\Client\Model\Ref\UserRef; use Platformsh\Client\Model\Resource as ApiResource; use Platformsh\Client\Model\SshKey; @@ -84,24 +83,6 @@ class Api */ private static $environmentsCache = []; - /** - * A cache of account details arrays, keyed by project ID. - * - * @see Api::getAccount() - * - * @var array - */ - private static $accountsCache = []; - - /** - * A cache of project access lists, keyed by project ID. - * - * @see Api::getProjectAccesses() - * - * @var array - */ - private static $projectAccessesCache = []; - /** * A cache of environment deployments. * @@ -884,40 +865,6 @@ public function getSshKeys($reset = false) return SshKey::wrapCollection($data['ssh_keys'], rtrim($this->config->getApiUrl(), '/') . '/', $this->getHttpClient()); } - /** - * Get a user's account info. - * - * @param ProjectAccess $access - * @param bool $reset - * - * @return array - * An array containing 'email' and 'display_name'. - */ - public function getAccount(ProjectAccess $access, $reset = false) - { - if (isset(self::$accountsCache[$access->id]) && !$reset) { - return self::$accountsCache[$access->id]; - } - - $cacheKey = 'account:' . $access->id; - $details = $this->cache->fetch($cacheKey); - if (!$reset && $details) { - $this->debug('Loaded account information from cache for: ' . $access->id); - } else { - $data = $access->getData(); - // Use embedded user information if possible. - if (isset($data['_embedded']['users'][0]) && count($data['_embedded']['users']) === 1) { - $details = $data['_embedded']['users'][0]; - } else { - $details = $access->getAccount()->getProperties(); - $this->cache->save($cacheKey, $details, (int) $this->config->getWithDefault('api.users_ttl', 600)); - } - self::$accountsCache[$access->id] = $details; - } - - return $details; - } - /** * Get user information. * @@ -1043,44 +990,6 @@ public function isLoggedIn() return $this->getClient(false)->getConnector()->isLoggedIn(); } - /** - * Load project users ("project access" records). - * - * @param \Platformsh\Client\Model\Project $project - * @param bool $reset - * - * @return ProjectAccess[] - */ - public function getProjectAccesses(Project $project, $reset = false) - { - if ($reset || !isset(self::$projectAccessesCache[$project->id])) { - self::$projectAccessesCache[$project->id] = $project->getUsers(); - } - - 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. *