diff --git a/src/bundle/Controller/Permission/UsersWithPermissionInfoController.php b/src/bundle/Controller/Permission/UsersWithPermissionInfoController.php new file mode 100644 index 0000000000..118340815f --- /dev/null +++ b/src/bundle/Controller/Permission/UsersWithPermissionInfoController.php @@ -0,0 +1,87 @@ +userQueryType = $userQueryType; + $this->permissionCheckContextResolver = $permissionCheckContextResolver; + $this->searchService = $searchService; + $this->userWithPermissionsMapper = $userWithPermissionsMapper; + $this->limit = $limit; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + public function listAction( + Request $request, + string $module, + string $function + ): JsonResponse { + $context = $this->permissionCheckContextResolver->resolve($module, $function, $request); + $searchQuery = $this->getQuery( + $request->query, + $context->getCriteria() + ); + $users = $this->searchService->findContentInfo($searchQuery, [], false); + + $response = $this->userWithPermissionsMapper->mapSearchResults( + $users, + $context, + $module, + $function + ); + + return new JsonResponse($response); + } + + private function getQuery( + ParameterBag $query, + ?Query\Criterion $criteria + ): Query { + $parameters = [ + 'limit' => $query->getInt('limit', $this->limit), + 'offset' => $query->getInt('offset'), + 'phrase' => $query->get('phrase'), + 'extra_criteria' => $criteria, + ]; + + return $this->userQueryType->getQuery($parameters); + } +} diff --git a/src/bundle/Resources/config/routing.yaml b/src/bundle/Resources/config/routing.yaml index cc9fd2b3ca..18263e88b9 100644 --- a/src/bundle/Resources/config/routing.yaml +++ b/src/bundle/Resources/config/routing.yaml @@ -971,3 +971,16 @@ ibexa.focus_mode.change: path: /user/focus-mode controller: 'Ibexa\Bundle\AdminUi\Controller\User\FocusModeController::changeAction' methods: [GET, POST] + +# +# Users +# +ibexa.permission.users_with_permission_info: + path: /permission/users-with-permission-info/{module}/{function} + controller: 'Ibexa\Bundle\AdminUi\Controller\Permission\UsersWithPermissionInfoController::listAction' + methods: [GET] + options: + expose: true + requirements: + module: \w+ + function: \w+ diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index f4d9534827..f90cccc335 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -40,6 +40,10 @@ imports: - { resource: services/role_form_mappers.yaml } - { resource: services/security.yaml } +parameters: + ibexa.admin_ui.load_users_with_permission_info.limit: 10 + ibexa.admin_ui.permission_check_context.content.user_content_type_identifiers: [editor] + services: _defaults: autowire: true @@ -162,3 +166,5 @@ services: $siteAccessGroups: '%ibexa.site_access.groups%' tags: - {name: kernel.event_subscriber} + + Ibexa\AdminUi\Permission\Mapper\UsersWithPermissionInfoMapper: ~ diff --git a/src/bundle/Resources/config/services/controllers.yaml b/src/bundle/Resources/config/services/controllers.yaml index c80448447a..e6384a6886 100644 --- a/src/bundle/Resources/config/services/controllers.yaml +++ b/src/bundle/Resources/config/services/controllers.yaml @@ -252,3 +252,11 @@ services: $imageMappings: '%ibexa.dam_widget.image.mappings%' tags: - controller.service_arguments + + Ibexa\Bundle\AdminUi\Controller\Permission\UsersWithPermissionInfoController: + parent: Ibexa\Rest\Server\Controller + arguments: + $userQueryType: '@Ibexa\AdminUi\QueryType\UserQueryType' + $limit: '%ibexa.admin_ui.load_users_with_permission_info.limit%' + tags: + - controller.service_arguments diff --git a/src/bundle/Resources/config/services/permissions.yaml b/src/bundle/Resources/config/services/permissions.yaml index c152f57d37..50dbd4c7c1 100644 --- a/src/bundle/Resources/config/services/permissions.yaml +++ b/src/bundle/Resources/config/services/permissions.yaml @@ -15,3 +15,20 @@ services: Ibexa\AdminUi\Permission\LimitationResolverInterface: alias: Ibexa\AdminUi\Permission\LimitationResolver + + Ibexa\AdminUi\Permission\PermissionCheckContextResolver: + arguments: + $permissionContextProviders: !tagged_iterator ibexa.admin_ui.permission_check_context.provider + + Ibexa\AdminUi\Permission\PermissionCheckContextResolverInterface: + alias: Ibexa\AdminUi\Permission\PermissionCheckContextResolver + + Ibexa\Contracts\AdminUi\Permission\PermissionCheckContextProviderInterface: ~ + + Ibexa\AdminUi\Permission\ContextProvider\ContentItemContextProvider: + arguments: + $userContentTypeIdentifiers: '%ibexa.admin_ui.permission_check_context.content.user_content_type_identifiers%' + tags: + - + name: ibexa.admin_ui.permission_check_context.provider + priority: -100 diff --git a/src/bundle/Resources/config/services/query_types.yaml b/src/bundle/Resources/config/services/query_types.yaml index c099fcdac9..4efe77566a 100644 --- a/src/bundle/Resources/config/services/query_types.yaml +++ b/src/bundle/Resources/config/services/query_types.yaml @@ -15,3 +15,5 @@ services: Ibexa\AdminUi\QueryType\MediaLocationSubtreeQueryType: ~ Ibexa\AdminUi\QueryType\TrashSearchQueryType: ~ + + Ibexa\AdminUi\QueryType\UserQueryType: ~ diff --git a/src/contracts/Permission/PermissionCheckContextProviderInterface.php b/src/contracts/Permission/PermissionCheckContextProviderInterface.php new file mode 100644 index 0000000000..642b01997e --- /dev/null +++ b/src/contracts/Permission/PermissionCheckContextProviderInterface.php @@ -0,0 +1,19 @@ + */ + private array $targets; + + private ?Criterion $criteria; + + /** + * @param array<\Ibexa\Contracts\Core\Repository\Values\ValueObject> $targets + */ + public function __construct( + ValueObject $subject, + array $targets, + ?Criterion $criteria = null + ) { + $this->subject = $subject; + $this->targets = $targets; + $this->criteria = $criteria; + } + + public function getSubject(): ValueObject + { + return $this->subject; + } + + /** + * @return array<\Ibexa\Contracts\Core\Repository\Values\ValueObject> + */ + public function getTargets(): array + { + return $this->targets; + } + + public function getCriteria(): ?Criterion + { + return $this->criteria; + } +} diff --git a/src/lib/Permission/ContextProvider/ContentItemContextProvider.php b/src/lib/Permission/ContextProvider/ContentItemContextProvider.php new file mode 100644 index 0000000000..ba3f20853b --- /dev/null +++ b/src/lib/Permission/ContextProvider/ContentItemContextProvider.php @@ -0,0 +1,111 @@ + */ + private array $userContentTypeIdentifiers; + + /** + * @param array $userContentTypeIdentifiers + */ + public function __construct( + ContentService $contentService, + LocationService $locationService, + array $userContentTypeIdentifiers + ) { + $this->contentService = $contentService; + $this->locationService = $locationService; + $this->userContentTypeIdentifiers = $userContentTypeIdentifiers; + } + + public function supports(string $module, string $function): bool + { + return self::POLICY_MODULE_CONTENT === $module; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function getPermissionCheckContext( + string $module, + string $function, + Request $request + ): PermissionCheckContext { + $query = $request->query; + + $contentInfo = $this->getContentInfo($query); + $targets = $this->getTargets($query); + $criteria = $this->createCriteria(); + + return new PermissionCheckContext($contentInfo, $targets, $criteria); + } + + private function getContentInfo(ParameterBag $query): ContentInfo + { + $contentId = $query->getInt('contentId'); + + return $this->contentService->loadContentInfo($contentId); + } + + /** + * @return array<\Ibexa\Contracts\Core\Repository\Values\ValueObject> + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + * @throws \Ibexa\Contracts\Core\Exception\InvalidArgumentException + */ + private function getTargets(ParameterBag $query): array + { + if (!$query->has('locationId')) { + return []; + } + + $locationId = $query->getInt('locationId'); + if ($locationId <= 0) { + throw new InvalidArgumentException( + 'locationId', + 'Expected value should be greater than 0.' + ); + } + + $location = $this->locationService->loadLocation($locationId); + + return [$location]; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + private function createCriteria(): Criterion + { + $criteria = [new Criterion\ContentTypeIdentifier($this->userContentTypeIdentifiers)]; + + return new Criterion\LogicalAnd($criteria); + } +} diff --git a/src/lib/Permission/Mapper/UsersWithPermissionInfoMapper.php b/src/lib/Permission/Mapper/UsersWithPermissionInfoMapper.php new file mode 100644 index 0000000000..0ed5fef0eb --- /dev/null +++ b/src/lib/Permission/Mapper/UsersWithPermissionInfoMapper.php @@ -0,0 +1,130 @@ +, + * noAccess: array, + * } + */ +final class UsersWithPermissionInfoMapper +{ + private PermissionResolver $permissionResolver; + + private Repository $repository; + + private UserService $userService; + + public function __construct( + PermissionResolver $permissionResolver, + Repository $repository, + UserService $userService + ) { + $this->permissionResolver = $permissionResolver; + $this->repository = $repository; + $this->userService = $userService; + } + + /** + * @phpstan-return TPermissionInfoData + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + public function mapSearchResults( + SearchResult $searchResult, + PermissionCheckContext $permissionContext, + string $module, + string $function + ): array { + $currentUserReference = $this->permissionResolver->getCurrentUserReference(); + + try { + return $this->groupByPermissions($searchResult, $permissionContext, $module, $function); + } finally { + $this->permissionResolver->setCurrentUserReference($currentUserReference); + } + } + + /** + * @phpstan-return TPermissionInfoData + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + private function groupByPermissions( + SearchResult $searchResult, + PermissionCheckContext $context, + string $module, + string $function + ): array { + $results = [ + 'access' => [], + 'noAccess' => [], + ]; + + foreach ($searchResult as $result) { + /** @var \Ibexa\Contracts\Core\Repository\Values\Content\ContentInfo $userContentInfo */ + $userContentInfo = $result->valueObject; + $user = $this->loadUser($userContentInfo->getId()); + $userReference = new UserReference($user->getUserId()); + $this->permissionResolver->setCurrentUserReference($userReference); + + $object = $context->getSubject(); + $targets = $context->getTargets(); + $userData = $this->getUserData($user); + + if ($this->permissionResolver->canUser($module, $function, $object, $targets)) { + $results['access'][] = $userData; + } else { + $results['noAccess'][] = $userData; + } + } + + return $results; + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + private function loadUser(int $userId): User + { + return $this->repository->sudo( + fn (): User => $this->userService->loadUser($userId) + ); + } + + /** + * @phpstan-return TUserData + */ + private function getUserData(User $user): array + { + return [ + 'id' => $user->getUserId(), + 'name' => $user->getName() ?? $user->getLogin(), + 'email' => $user->email, + ]; + } +} diff --git a/src/lib/Permission/PermissionCheckContextResolver.php b/src/lib/Permission/PermissionCheckContextResolver.php new file mode 100644 index 0000000000..8d924b7835 --- /dev/null +++ b/src/lib/Permission/PermissionCheckContextResolver.php @@ -0,0 +1,44 @@ + */ + private iterable $permissionContextProviders; + + /** + * @param iterable<\Ibexa\Contracts\AdminUi\Permission\PermissionCheckContextProviderInterface> $permissionContextProviders + */ + public function __construct(iterable $permissionContextProviders) + { + $this->permissionContextProviders = $permissionContextProviders; + } + + /** + * @throws \Ibexa\Contracts\Core\Exception\InvalidArgumentException + */ + public function resolve(string $module, string $function, Request $request): PermissionCheckContext + { + foreach ($this->permissionContextProviders as $provider) { + if ($provider->supports($module, $function)) { + return $provider->getPermissionCheckContext($module, $function, $request); + } + } + + throw new InvalidArgumentException( + '$request', + 'Unsupported permission context.' + ); + } +} diff --git a/src/lib/Permission/PermissionCheckContextResolverInterface.php b/src/lib/Permission/PermissionCheckContextResolverInterface.php new file mode 100644 index 0000000000..5df9ed9c1d --- /dev/null +++ b/src/lib/Permission/PermissionCheckContextResolverInterface.php @@ -0,0 +1,17 @@ +setDefined('phrase'); + $resolver->setAllowedTypes('phrase', ['string', 'null']); + + $resolver->setDefined('extra_criteria'); + $resolver->setAllowedTypes('extra_criteria', [Criterion::class, 'null']); + + $resolver->setDefaults( + [ + 'phrase' => null, + 'extra_criteria' => null, + ] + ); + } + + /** + * @param array $parameters + */ + protected function doGetQuery(array $parameters): Query + { + $parameters['filter']['siteaccess_aware'] = false; + + return parent::doGetQuery($parameters); + } + + /** + * @param array $parameters + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + protected function getQueryFilter(array $parameters): Criterion + { + $criteria = [ + new Criterion\IsUserEnabled(), + ]; + + if (!empty($parameters['extra_criteria'])) { + $criteria[] = $parameters['extra_criteria']; + } + + if (!empty($parameters['phrase'])) { + $phrase = $this->cleanSearchPhrase($parameters['phrase']); + $criteria[] = new Criterion\LogicalOr( + [ + new Criterion\ContentName('*' . $phrase . '*'), + // Used with EQ operator and without wildcards due to hashing email in solr and elasticsearch + new Criterion\UserEmail($phrase, Criterion\Operator::EQ), + ] + ); + } + + return new Criterion\LogicalAnd($criteria); + } + + public static function getName(): string + { + return 'IbexaAdminUi:User'; + } + + private function cleanSearchPhrase(string $phrase): string + { + $sanitizedPhrase = preg_replace(self::USER_SEARCH_PHRASE_PATTERN, '', $phrase); + if (null === $sanitizedPhrase) { + throw new RuntimeException('Could not sanitize search phrase.'); + } + + return $sanitizedPhrase; + } +} diff --git a/tests/integration/REST/GetUsersWithPermissionInfoTest.php b/tests/integration/REST/GetUsersWithPermissionInfoTest.php new file mode 100644 index 0000000000..38a469f668 --- /dev/null +++ b/tests/integration/REST/GetUsersWithPermissionInfoTest.php @@ -0,0 +1,220 @@ + 'application/json', + 'X-Siteaccess' => 'admin', + ]; + private const EDITOR_USER_GROUP_REMOTE_ID = '3c160cca19fb135f83bd02d911f04db2'; + private const USER_REGISTRATION_REMOTE_ID = '5f7f0bdb3381d6a461d8c29ff53d908f'; + private const MEDIA_CONTENT_ITEM_ID = 41; + private const MEDIA_LOCATION_ID = 43; + private const MODULE_CONTENT = 'content'; + private const FUNCTION_EDIT = 'edit'; + private const FUNCTION_READ = 'read'; + + private UserService $userService; + + private RoleService $roleService; + + protected function setUp(): void + { + parent::setUp(); + + $ibexaTestCore = $this->getIbexaTestCore(); + $this->userService = $ibexaTestCore->getUserService(); + $this->roleService = $ibexaTestCore->getRoleService(); + + $this->createUsers(); + } + + /** + * @dataProvider provideDataForTestGetUsersWithPermissionsEndpoint + * + * @param array $queryParameters + */ + public function testGetUsersWithPermissionsEndpoint( + int $contentId, + string $module, + string $function, + array $queryParameters, + string $expectedResponse + ): void { + $uri = $this->getUri($module, $function, $queryParameters); + $this->client->request('GET', $uri, [], [], self::HEADERS); + + $response = $this->client->getResponse(); + + self::assertSame(200, $response->getStatusCode()); + + $content = $response->getContent(); + if (false === $content) { + throw new LogicException('Missing response content'); + } + + $fixedResponse = $this->doReplaceResponse($content); + + self::assertSame($expectedResponse, $fixedResponse); + } + + /** + * @return iterable, + * string, + * }> + */ + public function provideDataForTestGetUsersWithPermissionsEndpoint(): iterable + { + yield 'Check content-read for content item 41' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_READ, + ['contentId' => 41], + '{"access":[{"id":"__FIXED_ID__","name":"Administrator User","email":"admin@link.invalid"},{"id":"__FIXED_ID__","name":"John Doe","email":"john@link.invalid"},{"id":"__FIXED_ID__","name":"Josh Bar","email":"joshua@link.invalid"}],"noAccess":[{"id":"__FIXED_ID__","name":"Anonymous User","email":"anonymous@link.invalid"},{"id":"__FIXED_ID__","name":"Guest Guest","email":"guest@link.invalid"}]}', + ]; + + yield 'Check content-read for content item 41 and location 51' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_READ, + [ + 'contentId' => 41, + 'locationId' => 51, + ], + '{"access":[{"id":"__FIXED_ID__","name":"Administrator User","email":"admin@link.invalid"},{"id":"__FIXED_ID__","name":"John Doe","email":"john@link.invalid"},{"id":"__FIXED_ID__","name":"Josh Bar","email":"joshua@link.invalid"}],"noAccess":[{"id":"__FIXED_ID__","name":"Anonymous User","email":"anonymous@link.invalid"},{"id":"__FIXED_ID__","name":"Guest Guest","email":"guest@link.invalid"}]}', + ]; + + yield 'Check content-read for phrase=undef*' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_READ, + [ + 'contentId' => 41, + 'phrase' => 'undef*', + ], + '{"access":[],"noAccess":[]}', + ]; + + yield 'Check content-edit for content item 2 and phrase=jo' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_EDIT, + [ + 'contentId' => 41, + 'phrase' => 'jo*', + ], + '{"access":[{"id":"__FIXED_ID__","name":"John Doe","email":"john@link.invalid"},{"id":"__FIXED_ID__","name":"Josh Bar","email":"joshua@link.invalid"}],"noAccess":[]}', + ]; + + yield 'Check content-edit for content item 41 and phrase=bar*' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_EDIT, + [ + 'contentId' => 41, + 'phrase' => 'bar*', + ], + '{"access":[{"id":"__FIXED_ID__","name":"Josh Bar","email":"joshua@link.invalid"}],"noAccess":[]}', + ]; + + yield 'Check content-edit for content item 41 and location 43 and phrase=joshua@link.invalid' => [ + self::MEDIA_CONTENT_ITEM_ID, + self::MODULE_CONTENT, + self::FUNCTION_EDIT, + [ + 'phrase' => 'joshua@link.invalid', + 'contentId' => 41, + 'locationId' => self::MEDIA_LOCATION_ID, + ], + '{"access":[{"id":"__FIXED_ID__","name":"Josh Bar","email":"joshua@link.invalid"}],"noAccess":[]}', + ]; + } + + private function createUsers(): void + { + $this->getIbexaTestCore()->setAdministratorUser(); + + $role = $this->roleService->loadRoleByIdentifier('Editor'); + $editorGroup = $this->userService->loadUserGroupByRemoteId(self::EDITOR_USER_GROUP_REMOTE_ID); + + $user1CreateStruct = $this->createUserCreateStruct('john', 'John', 'Doe', 'john@link.invalid'); + $user1 = $this->userService->createUser($user1CreateStruct, [$editorGroup]); + $this->roleService->assignRoleToUser($role, $user1); + + $user2CreateStruct = $this->createUserCreateStruct('josh', 'Josh', 'Bar', 'joshua@link.invalid'); + $user2 = $this->userService->createUser($user2CreateStruct, [$editorGroup]); + $this->roleService->assignRoleToUser($role, $user2); + + // Guest user should not be visible on the list + $guestCreateStruct = $this->createUserCreateStruct('guest', 'Guest', 'Guest', 'guest@link.invalid'); + $groupGuest = $this->userService->loadUserGroupByRemoteId(self::USER_REGISTRATION_REMOTE_ID); + $guest = $this->userService->createUser($guestCreateStruct, [$groupGuest]); + + $roleMember = $this->roleService->loadRoleByIdentifier('Member'); + $this->roleService->assignRoleToUser($roleMember, $guest); + } + + private function createUserCreateStruct( + string $login, + string $firstName, + string $lastName, + string $email + ): UserCreateStruct { + $userCreateStruct = $this->userService->newUserCreateStruct( + $login, + $email, + $login, + 'eng-GB' + ); + + $userCreateStruct->setField('first_name', $firstName); + $userCreateStruct->setField('last_name', $lastName); + + return $userCreateStruct; + } + + /** + * @param array $queryParameters + */ + private static function getUri( + string $module, + string $function, + array $queryParameters = [] + ): string { + return sprintf( + self::ENDPOINT_URL, + $module, + $function, + http_build_query($queryParameters), + ); + } + + private function doReplaceResponse(string $jsonResponse): string + { + $fixedResponse = preg_replace('~"id":\d+~', '"id":"__FIXED_ID__"', $jsonResponse); + if (null === $fixedResponse) { + throw new LogicException('Failed to replace JSON response.'); + } + + return $fixedResponse; + } +} diff --git a/tests/integration/Resources/ibexa.yaml b/tests/integration/Resources/ibexa.yaml index 08f5404385..35da846656 100644 --- a/tests/integration/Resources/ibexa.yaml +++ b/tests/integration/Resources/ibexa.yaml @@ -1,3 +1,6 @@ +parameters: + ibexa.admin_ui.permission_check_context.content.user_content_type_identifiers: [user] + ibexa: system: default: diff --git a/tests/integration/Resources/routing.yaml b/tests/integration/Resources/routing.yaml index 83a0143bc3..7c425026d6 100644 --- a/tests/integration/Resources/routing.yaml +++ b/tests/integration/Resources/routing.yaml @@ -1,3 +1,6 @@ +ibexa.admin_ui: + resource: '@IbexaAdminUiBundle/Resources/config/routing.yaml' + ibexa.admin_ui.rest: resource: '@IbexaAdminUiBundle/Resources/config/routing_rest.yaml' prefix: '%ibexa.rest.path_prefix%'