diff --git a/app/config/collections.php b/app/config/collections.php index b5c9daccc8..f4d7064f41 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1277,6 +1277,17 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('labels'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], [ '$id' => ID::custom('passwordHistory'), 'type' => Database::VAR_STRING, @@ -1429,7 +1440,7 @@ $collections = [ 'required' => false, 'default' => null, 'array' => false, - 'filters' => [], + 'filters' => ['userSearch'], ] ], 'indexes' => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 49c5ef3376..837e6eff15 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1531,9 +1531,7 @@ App::patch('/v1/account/name') ->inject('events') ->action(function (string $name, ?\DateTime $requestTimestamp, Response $response, Document $user, Database $dbForProject, Event $events) { - $user - ->setAttribute('name', $name) - ->setAttribute('search', implode(' ', [$user->getId(), $name, $user->getAttribute('email', ''), $user->getAttribute('phone', '')])); + $user->setAttribute('name', $name); $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); @@ -1644,7 +1642,7 @@ App::patch('/v1/account/email') $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again - ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $email, $user->getAttribute('phone', '')])); + ; if (empty($passwordUpdate)) { $user @@ -1704,7 +1702,7 @@ App::patch('/v1/account/phone') $user ->setAttribute('phone', $phone) ->setAttribute('phoneVerification', false) // After this user needs to confirm phone number again - ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $user->getAttribute('email', ''), $phone])); + ; if (empty($passwordUpdate)) { $user diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 2eae494758..683858aed4 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -28,6 +28,7 @@ use Utopia\Database\Validator\UID; use Utopia\Database\Database; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; +use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\WhiteList; use Utopia\Validator\Text; @@ -65,6 +66,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'phone' => $phone, 'phoneVerification' => false, 'status' => true, + 'labels' => [], 'password' => $password, 'passwordHistory' => is_null($password) && $passwordHistory === 0 ? [] : [$password], 'passwordUpdate' => (!empty($password)) ? DateTime::now() : null, @@ -663,6 +665,45 @@ App::patch('/v1/users/:userId/status') $response->dynamic($user, Response::MODEL_USER); }); +App::put('/v1/users/:userId/labels') + ->desc('Update User Labels') + ->groups(['api', 'users']) + ->label('event', 'users.[userId].update.labels') + ->label('scope', 'users.write') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{response.$id}') + ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'updateLabels') + ->label('sdk.description', '/docs/references/users/update-user-labels.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USER) + ->param('userId', '', new UID(), 'User ID.') + ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), 5), 'Array of user labels. Replaces the previous labels. Maximum of 5 labels are allowed, each up to 36 alphanumeric characters long.') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $userId, array $labels, Response $response, Database $dbForProject, Event $events) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $user->setAttribute('labels', (array) \array_values(\array_unique($labels))); + + $user = $dbForProject->updateDocument('users', $user->getId(), $user); + + $events + ->setParam('userId', $user->getId()); + + $response->dynamic($user, Response::MODEL_USER); + }); + App::patch('/v1/users/:userId/verification') ->desc('Update Email Verification') ->groups(['api', 'users']) @@ -764,10 +805,7 @@ App::patch('/v1/users/:userId/name') throw new Exception(Exception::USER_NOT_FOUND); } - $user - ->setAttribute('name', $name) - ->setAttribute('search', \implode(' ', [$user->getId(), $user->getAttribute('email', ''), $name, $user->getAttribute('phone', '')])); - ; + $user->setAttribute('name', $name); $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -869,7 +907,8 @@ App::patch('/v1/users/:userId/email') $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) - ->setAttribute('search', \implode(' ', [$user->getId(), $email, $user->getAttribute('name', ''), $user->getAttribute('phone', '')])); + ; + try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -913,7 +952,6 @@ App::patch('/v1/users/:userId/phone') $user ->setAttribute('phone', $number) ->setAttribute('phoneVerification', false) - ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $user->getAttribute('email', ''), $number])); ; try { diff --git a/app/init.php b/app/init.php index 2936529c0d..854353bd3b 100644 --- a/app/init.php +++ b/app/init.php @@ -457,6 +457,29 @@ Database::addFilter( } ); +Database::addFilter( + 'userSearch', + function (mixed $value, Document $user) { + $searchValues = [ + $user->getId(), + $user->getAttribute('email', ''), + $user->getAttribute('name', ''), + $user->getAttribute('phone', '') + ]; + + foreach ($user->getAttribute('labels', []) as $label) { + $searchValues[] = 'label:' . $label; + } + + $search = implode(' ', \array_filter($searchValues)); + + return $search; + }, + function (mixed $value) { + return $value; + } +); + /** * DB Formats */ diff --git a/docs/references/users/update-user-labels.md b/docs/references/users/update-user-labels.md new file mode 100644 index 0000000000..3ff0388eae --- /dev/null +++ b/docs/references/users/update-user-labels.md @@ -0,0 +1,3 @@ +Update the user labels by its unique ID. + +Labels can be used to grant access to resources. While teams are a way for user's to share access to a resource, labels can be defined by the developer to grant access without an invitation. See the [Permissions docs](/docs/permissions) for more info. \ No newline at end of file diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 7217a87680..25c6b36e08 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -456,6 +456,10 @@ class Auth } } + foreach ($user->getAttribute('labels', []) as $label) { + $roles[] = 'label:' . $label; + } + return $roles; } diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 118864e804..c134142185 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -80,7 +80,7 @@ class Membership extends Model 'type' => self::TYPE_STRING, 'description' => 'User list of roles', 'default' => [], - 'example' => 'admin', + 'example' => ['owner'], 'array' => true, ]) ; diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 648b1c5dce..0fa07cb79a 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -77,6 +77,13 @@ class User extends Model 'default' => true, 'example' => true, ]) + ->addRule('labels', [ + 'type' => self::TYPE_STRING, + 'description' => 'Labels for the user.', + 'default' => [], + 'example' => ['vip'], + 'array' => true, + ]) ->addRule('passwordUpdate', [ 'type' => self::TYPE_DATETIME, 'description' => 'Password update time in ISO 8601 format.', diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index ba751516d3..7665b10dc0 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -39,6 +39,7 @@ trait AccountBase $this->assertEquals(true, $dateValidator->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $this->assertEquals($response['body']['name'], $name); + $this->assertEquals($response['body']['labels'], []); /** * Test for FAILURE diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 9f14c59306..baf601789a 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Users; use Appwrite\Tests\Retry; +use Appwrite\Utopia\Response; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; @@ -35,6 +36,7 @@ trait UsersBase $this->assertEquals($body['email'], 'cristiano.ronaldo@manchester-united.co.uk'); $this->assertEquals($body['status'], true); $this->assertGreaterThan('2000-01-01 00:00:00', $body['registration']); + $this->assertEquals($body['labels'], []); /** * Test Create with Custom ID for SUCCESS @@ -1015,6 +1017,98 @@ trait UsersBase $this->assertEquals($response['body']['users'][0]['phone'], $newNumber); } + /** + * @return array{} + */ + public function userLabelsProvider() + { + return [ + 'single label' => [ + ['admin'], + Response::STATUS_CODE_OK, + ['admin'], + ], + 'replace with multiple labels' => [ + ['vip', 'pro'], + Response::STATUS_CODE_OK, + ['vip', 'pro'], + ], + 'clear labels' => [ + [], + Response::STATUS_CODE_OK, + [], + ], + 'duplicate labels' => [ + ['vip', 'vip', 'pro'], + Response::STATUS_CODE_OK, + ['vip', 'pro'], + ], + 'invalid label' => [ + ['invalid-label'], + Response::STATUS_CODE_BAD_REQUEST, + [], + ], + 'too long' => [ + [\str_repeat('a', 129)], + Response::STATUS_CODE_BAD_REQUEST, + [], + ], + 'too many labels' => [ + [\array_fill(0, 101, 'a')], + Response::STATUS_CODE_BAD_REQUEST, + [], + ], + ]; + } + + /** + * @depends testGetUser + * @dataProvider userLabelsProvider + */ + public function testUpdateUserLabels(array $labels, int $expectedStatus, array $expectedLabels, array $data): array + { + $user = $this->client->call(Client::METHOD_PUT, '/users/' . $data['userId'] . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => $labels, + ]); + + $this->assertEquals($expectedStatus, $user['headers']['status-code']); + if ($expectedStatus === Response::STATUS_CODE_OK) { + $this->assertEquals($user['body']['labels'], $expectedLabels); + } + + return $data; + } + + /** + * @depends testGetUser + */ + public function testUpdateUserLabelsWithoutLabels(array $data): array + { + $user = $this->client->call(Client::METHOD_PUT, '/users/' . $data['userId'] . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(Response::STATUS_CODE_BAD_REQUEST, $user['headers']['status-code']); + + return $data; + } + + public function testUpdateUserLabelsNonExistentUser(): void + { + $user = $this->client->call(Client::METHOD_PUT, '/users/dne/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['admin'], + ]); + + $this->assertEquals(Response::STATUS_CODE_NOT_FOUND, $user['headers']['status-code']); + } + /** * @depends testGetUser diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php index e64ef37dfe..aa8ff492f6 100644 --- a/tests/unit/Auth/AuthTest.php +++ b/tests/unit/Auth/AuthTest.php @@ -352,6 +352,10 @@ class AuthTest extends TestCase { $user = new Document([ '$id' => ID::custom('123'), + 'labels' => [ + 'vip', + 'admin' + ], 'emailVerification' => true, 'phoneVerification' => true, 'memberships' => [ @@ -377,7 +381,7 @@ class AuthTest extends TestCase $roles = Auth::getRoles($user); - $this->assertCount(11, $roles); + $this->assertCount(13, $roles); $this->assertContains(Role::users()->toString(), $roles); $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); @@ -389,6 +393,8 @@ class AuthTest extends TestCase $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); $this->assertContains(Role::member(ID::custom('456'))->toString(), $roles); $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + $this->assertContains('label:vip', $roles); + $this->assertContains('label:admin', $roles); // Disable all verification $user['emailVerification'] = false;