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.
*