From 49aa8bbaada53ec737548f8d3bd89c9b2b51d1c8 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 15 Oct 2024 18:23:44 +0200 Subject: [PATCH 01/58] FEATURE: Content Repository Privileges Related: #3732 --- ...trineDbalContentGraphProjectionFactory.php | 2 +- ...actory.php => FakeAuthProviderFactory.php} | 12 +- .../Settings.ContentRepositoryRegistry.yaml | 4 +- .../Classes/CommandHandlingDependencies.php | 8 +- .../Classes/ContentRepository.php | 19 ++- .../Classes/ContentRepositoryReadModel.php | 5 +- .../Factory/ContentRepositoryFactory.php | 9 +- .../Factory/ProjectionFactoryDependencies.php | 2 + .../Auth/AuthProviderInterface.php | 20 +++ .../Classes/SharedModel/Auth/Privilege.php | 38 +++++ .../SharedModel/Auth/StaticAuthProvider.php | 36 +++++ .../SharedModel/{User => Auth}/UserId.php | 2 +- .../Auth/WorkspacePrivilegeType.php | 23 +++ .../SharedModel/User/StaticUserIdProvider.php | 23 --- .../User/UserIdProviderInterface.php | 13 -- .../Bootstrap/CRTestSuiteRuntimeVariables.php | 6 +- .../Bootstrap/Helpers/FakeAuthProvider.php | 37 +++++ .../Bootstrap/Helpers/FakeUserIdProvider.php | 23 --- .../Classes/ContentRepositoryRegistry.php | 18 +-- .../AuthProviderFactoryInterface.php} | 10 +- .../StaticAuthProviderFactory.php | 20 +++ .../StaticUserIdProviderFactory.php | 20 --- .../Configuration/Settings.yaml | 4 +- .../ContentRepositoryAuthProvider.php | 147 ++++++++++++++++++ .../ContentRepositoryAuthProviderFactory.php | 35 +++++ .../Domain/Service/WorkspaceService.php | 9 +- .../Controller/AbstractServiceController.php | 10 -- .../Classes/UserIdProvider/UserIdProvider.php | 29 ---- .../UserIdProvider/UserIdProviderFactory.php | 34 ---- .../Settings.ContentRepositoryRegistry.yaml | 4 +- 30 files changed, 426 insertions(+), 196 deletions(-) rename Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/{FakeUserIdProviderFactory.php => FakeAuthProviderFactory.php} (52%) create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php rename Neos.ContentRepository.Core/Classes/SharedModel/{User => Auth}/UserId.php (96%) create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php create mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php delete mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php rename Neos.ContentRepositoryRegistry/Classes/Factory/{UserIdProvider/UserIdProviderFactoryInterface.php => AuthProvider/AuthProviderFactoryInterface.php} (52%) create mode 100644 Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php delete mode 100644 Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php create mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php create mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php delete mode 100644 Neos.Neos/Classes/UserIdProvider/UserIdProvider.php delete mode 100644 Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php index 1b750c114a4..6a0644842a9 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php @@ -58,7 +58,7 @@ public function build( ), $tableNames, $dimensionSpacePointsRepository, - new ContentRepositoryReadModel($contentRepositoryReadModelAdapter) + new ContentRepositoryReadModel($contentRepositoryReadModelAdapter), ); } } diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php similarity index 52% rename from Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php rename to Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php index 6a0a5c7a408..df59e7f80c8 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php @@ -5,17 +5,17 @@ namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeUserIdProvider; -use Neos\ContentRepositoryRegistry\Factory\UserIdProvider\UserIdProviderFactoryInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; +use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; +use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; -final class FakeUserIdProviderFactory implements UserIdProviderFactoryInterface +final class FakeAuthProviderFactory implements AuthProviderFactoryInterface { /** * @param array $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface + public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface { - return new FakeUserIdProvider(); + return new FakeAuthProvider(); } } diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml index 53eafcc4977..74387df65ff 100644 --- a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml @@ -2,8 +2,8 @@ Neos: ContentRepositoryRegistry: presets: default: - userIdProvider: - factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeUserIdProviderFactory' + authProvider: + factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeAuthProviderFactory' clock: factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeClockFactory' nodeTypeManager: diff --git a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php index 1c778159fcd..12d7b67eabe 100644 --- a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php +++ b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php @@ -38,8 +38,10 @@ final class CommandHandlingDependencies */ private array $overriddenContentGraphInstances = []; - public function __construct(private readonly ContentRepository $contentRepository) - { + public function __construct( + private readonly ContentRepository $contentRepository, + private readonly ContentRepositoryReadModel $contentRepositoryReadModel, + ) { } public function handle(CommandInterface $command): CommandResult @@ -84,7 +86,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter return $this->overriddenContentGraphInstances[$workspaceName->value]; } - return $this->contentRepository->getContentGraph($workspaceName); + return $this->contentRepositoryReadModel->getContentGraphByWorkspaceName($workspaceName); } /** diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 54e81fb63a3..f2e50762a18 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -35,10 +35,11 @@ use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; use Neos\ContentRepository\Core\Projection\ProjectionStatuses; use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams; @@ -85,10 +86,10 @@ public function __construct( private readonly NodeTypeManager $nodeTypeManager, private readonly InterDimensionalVariationGraph $variationGraph, private readonly ContentDimensionSourceInterface $contentDimensionSource, - private readonly UserIdProviderInterface $userIdProvider, + private readonly AuthProviderInterface $authProvider, private readonly ClockInterface $clock, ) { - $this->commandHandlingDependencies = new CommandHandlingDependencies($this); + $this->commandHandlingDependencies = new CommandHandlingDependencies($this, $this->getContentRepositoryReadModel()); } /** @@ -99,12 +100,16 @@ public function __construct( */ public function handle(CommandInterface $command): CommandResult { + $privilege = $this->authProvider->getCommandPrivilege($command); + if (!$privilege->granted) { + throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->message), 1729086686); + } // the commands only calculate which events they want to have published, but do not do the // publishing themselves $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies); // TODO meaningful exception message - $initiatingUserId = $this->userIdProvider->getUserId(); + $initiatingUserId = $this->authProvider->getUserId(); $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM); // Add "initiatingUserId" and "initiatingTimestamp" metadata to all events. @@ -269,6 +274,12 @@ public function findContentStreams(): ContentStreams */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { + $privilege = $this->authProvider->getWorkspacePrivilege($workspaceName, WorkspacePrivilegeType::READ_NODES); + if (!$privilege->granted) { + throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760); + // TODO more specific exception + //throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName); } diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php index 95584a07f76..a9fd7d0eeef 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php @@ -16,6 +16,9 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; +use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; @@ -35,7 +38,7 @@ final class ContentRepositoryReadModel implements ProjectionStateInterface { public function __construct( - private readonly ContentRepositoryReadModelAdapterInterface $adapter + private readonly ContentRepositoryReadModelAdapterInterface $adapter, ) { } diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index bba7d784579..8dc6e517c88 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -31,7 +31,7 @@ use Neos\ContentRepository\Core\Projection\ProjectionCatchUpTriggerInterface; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; +use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\EventStore\EventStoreInterface; use Psr\Clock\ClockInterface; use Symfony\Component\Serializer\Serializer; @@ -54,7 +54,7 @@ public function __construct( Serializer $propertySerializer, ProjectionsAndCatchUpHooksFactory $projectionsAndCatchUpHooksFactory, private readonly ProjectionCatchUpTriggerInterface $projectionCatchUpTrigger, - private readonly UserIdProviderInterface $userIdProvider, + private readonly AuthProviderInterface $authProvider, private readonly ClockInterface $clock, ) { $contentDimensionZookeeper = new ContentDimensionZookeeper($contentDimensionSource); @@ -70,7 +70,8 @@ public function __construct( $contentDimensionSource, $contentDimensionZookeeper, $interDimensionalVariationGraph, - new PropertyConverter($propertySerializer) + new PropertyConverter($propertySerializer), + $this->authProvider, ); $this->projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies); } @@ -99,7 +100,7 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->nodeTypeManager, $this->projectionFactoryDependencies->interDimensionalVariationGraph, $this->projectionFactoryDependencies->contentDimensionSource, - $this->userIdProvider, + $this->authProvider, $this->clock, ); } diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php index 9bb2f0cc31f..36b614ba623 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php @@ -20,6 +20,7 @@ use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; +use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\EventStore\EventStoreInterface; @@ -37,6 +38,7 @@ public function __construct( public ContentDimensionZookeeper $contentDimensionZookeeper, public InterDimensionalVariationGraph $interDimensionalVariationGraph, public PropertyConverter $propertyConverter, + public AuthProviderInterface $authProvider, ) { } } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php new file mode 100644 index 00000000000..b2d03733050 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php @@ -0,0 +1,20 @@ +userId; + } + + public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege + { + return Privilege::granted(); + } + + public function getCommandPrivilege(CommandInterface $command): Privilege + { + return Privilege::granted(); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php similarity index 96% rename from Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php rename to Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php index 86f78e31a21..80228a031d0 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\User; +namespace Neos\ContentRepository\Core\SharedModel\Auth; use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php new file mode 100644 index 00000000000..c08d6aa4d46 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php @@ -0,0 +1,23 @@ +userId; - } -} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php deleted file mode 100644 index 8530a34e30d..00000000000 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -buildPropertySerializer($contentRepositoryId, $contentRepositorySettings), $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings), $this->buildProjectionCatchUpTrigger($contentRepositoryId, $contentRepositorySettings), - $this->buildUserIdProvider($contentRepositoryId, $contentRepositorySettings), + $this->buildAuthProvider($contentRepositoryId, $contentRepositorySettings), $clock ); } catch (\Exception $exception) { @@ -275,14 +275,14 @@ private function buildProjectionCatchUpTrigger(ContentRepositoryId $contentRepos } /** @param array $contentRepositorySettings */ - private function buildUserIdProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): UserIdProviderInterface + private function buildAuthProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderInterface { - isset($contentRepositorySettings['userIdProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have userIdProvider.factoryObjectName configured.', $contentRepositoryId->value); - $userIdProviderFactory = $this->objectManager->get($contentRepositorySettings['userIdProvider']['factoryObjectName']); - if (!$userIdProviderFactory instanceof UserIdProviderFactoryInterface) { - throw InvalidConfigurationException::fromMessage('userIdProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, UserIdProviderFactoryInterface::class, get_debug_type($userIdProviderFactory)); + isset($contentRepositorySettings['authProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have authProvider.factoryObjectName configured.', $contentRepositoryId->value); + $authProviderFactory = $this->objectManager->get($contentRepositorySettings['authProvider']['factoryObjectName']); + if (!$authProviderFactory instanceof AuthProviderFactoryInterface) { + throw InvalidConfigurationException::fromMessage('authProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, AuthProviderFactoryInterface::class, get_debug_type($authProviderFactory)); } - return $userIdProviderFactory->build($contentRepositoryId, $contentRepositorySettings['userIdProvider']['options'] ?? []); + return $authProviderFactory->build($contentRepositoryId, $contentRepositorySettings['authProvider']['options'] ?? []); } /** @param array $contentRepositorySettings */ diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php similarity index 52% rename from Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php rename to Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php index a6145c7e8dc..9aebd57e688 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php @@ -1,15 +1,17 @@ $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface; + public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface; } diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php new file mode 100644 index 00000000000..4cc8b9a7ebd --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php @@ -0,0 +1,20 @@ + $options */ + public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface + { + return new StaticAuthProvider(UserId::forSystemUser()); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php deleted file mode 100644 index 563bc6b19a9..00000000000 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php +++ /dev/null @@ -1,20 +0,0 @@ - $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new StaticUserIdProvider(UserId::forSystemUser()); - } -} diff --git a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml index 44e28699641..460706caaf8 100644 --- a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml +++ b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml @@ -34,8 +34,8 @@ Neos: projectionCatchUpTrigger: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\ProjectionCatchUpTrigger\SubprocessProjectionCatchUpTriggerFactory - userIdProvider: - factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory + authProvider: + factoryObjectName: Neos\ContentRepositoryRegistry\Factory\AuthProvider\StaticAuthProviderFactory clock: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php new file mode 100644 index 00000000000..b154f482211 --- /dev/null +++ b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -0,0 +1,147 @@ +userService->getCurrentUser(); + if ($user === null) { + return UserId::forSystemUser(); + } + return UserId::fromString($user->getId()->value); + } + + public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted(); + } + $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName); + if ($workspacePermissions === null) { + return Privilege::denied('No user is authenticated'); + } + return match ($privilegeType) { + WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User has no read permission for workspace "%s"', $workspaceName->value)), + }; + } + + public function getCommandPrivilege(CommandInterface $command): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted(); + } + if ($command instanceof CreateWorkspace) { + $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName); + if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) { + return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value)); + } + return Privilege::granted(); + } + list($privilege, $workspaceName) = match ($command::class) { + AddDimensionShineThrough::class, + ChangeNodeAggregateName::class, + ChangeNodeAggregateType::class, + CopyNodesRecursively::class, + CreateNodeAggregateWithNode::class, + CreateNodeAggregateWithNodeAndSerializedProperties::class, + CreateNodeVariant::class, + CreateRootNodeAggregateWithNode::class, + DisableNodeAggregate::class, + DiscardIndividualNodesFromWorkspace::class, + DiscardWorkspace::class, + EnableNodeAggregate::class, + MoveDimensionSpacePoint::class, + MoveNodeAggregate::class, + PublishIndividualNodesFromWorkspace::class, + PublishWorkspace::class, + RebaseWorkspace::class, + RemoveNodeAggregate::class, + SetNodeProperties::class, + SetNodeReferences::class, + SetSerializedNodeProperties::class, + SetSerializedNodeReferences::class, + TagSubtree::class, + UntagSubtree::class, + UpdateRootNodeAggregateDimensions::class => ['write', $command->workspaceName], + ChangeBaseWorkspace::class, + CreateRootWorkspace::class, + CreateWorkspace::class, + DeleteWorkspace::class => ['manage', $command->workspaceName], + default => [null, null], + }; + if ($privilege === null) { + return Privilege::granted(); + } + $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName); + if ($workspacePermissions === null) { + return Privilege::denied(sprintf('No user is authenticated to %s workspace "%s" because no user is authenticated', $privilege, $workspaceName->value)); + } + $privilegeGranted = $privilege === 'write' ? $workspacePermissions->write : $workspacePermissions->manage; + return $privilegeGranted ? Privilege::granted() : Privilege::denied(sprintf('User has no %s permission for workspace "%s"', $privilege, $workspaceName->value)); + } + + private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions + { + $user = $this->userService->getCurrentUser(); + if ($user === null) { + return null; + } + return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + } +} diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php new file mode 100644 index 00000000000..63f84ae439e --- /dev/null +++ b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -0,0 +1,35 @@ + $options + */ + public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider + { + return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 0ada7877998..e64c5acba84 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -27,6 +27,7 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Flow\Security\Exception\NoSuchRoleException; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; @@ -44,6 +45,8 @@ /** * Central authority to interact with Content Repository Workspaces within Neos * + * TODO evaluate permissions for workspace changes + * * @api */ #[Flow\Scope('singleton')] @@ -56,6 +59,7 @@ public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, private readonly UserService $userService, private readonly Connection $dbal, + private readonly SecurityContext $securityContext, ) { } @@ -83,6 +87,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { + // TODO check workspace permissions -> $this->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceName, $this->userService->getCurrentUser()); $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'title' => $newWorkspaceTitle->value, ]); @@ -173,14 +178,14 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con return; } $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); - $this->createPersonalWorkspace( + $this->securityContext->withoutAuthorizationChecks(fn () => $this->createPersonalWorkspace( $contentRepositoryId, $workspaceName, WorkspaceTitle::fromString($user->getLabel()), WorkspaceDescription::empty(), WorkspaceName::forLive(), $user->getId(), - ); + )); } /** diff --git a/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php b/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php index a212f1ac725..aca0d7a9327 100644 --- a/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php +++ b/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Service\Controller; use GuzzleHttp\Psr7\Response; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\Flow\Annotations as Flow; use Neos\Flow\Exception as FlowException; use Neos\Flow\Log\ThrowableStorageInterface; @@ -157,13 +156,4 @@ protected function convertException(\Throwable $exception): array } return $exceptionData; } - - protected function getCurrentUserIdentifier(): ?UserId - { - $user = $this->domainUserService->getCurrentUser(); - - return $user - ? UserId::fromString($this->persistenceManager->getIdentifierByObject($user)) - : null; - } } diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php deleted file mode 100644 index b81c97e8c5a..00000000000 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php +++ /dev/null @@ -1,29 +0,0 @@ -userService->getCurrentUser(); - if ($user === null) { - return UserId::forSystemUser(); - } - return UserId::fromString($user->getId()->value); - } -} diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php b/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php deleted file mode 100644 index 388dc6f19f3..00000000000 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new UserIdProvider($this->userService); - } -} diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml index a3ae6ab53c0..c088a067cbe 100644 --- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml @@ -3,8 +3,8 @@ Neos: presets: 'default': - userIdProvider: - factoryObjectName: Neos\Neos\UserIdProvider\UserIdProviderFactory + authProvider: + factoryObjectName: Neos\Neos\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory projections: 'Neos.Neos:DocumentUriPathProjection': From 4a9361072f76bd414b6ec31fc1a39b6424d50c44 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 17 Oct 2024 17:36:31 +0200 Subject: [PATCH 02/58] WIP: FEATURE: Content Repository Privileges --- .../Classes/ContentRepository.php | 9 ++ .../RebasableToOtherWorkspaceInterface.php | 2 + .../Command/AddDimensionShineThrough.php | 5 + .../Command/MoveDimensionSpacePoint.php | 5 + ...gregateWithNodeAndSerializedProperties.php | 5 + .../Command/DisableNodeAggregate.php | 5 + .../Command/EnableNodeAggregate.php | 5 + .../Command/CopyNodesRecursively.php | 5 + .../Command/SetSerializedNodeProperties.php | 5 + .../NodeMove/Command/MoveNodeAggregate.php | 5 + .../Command/SetSerializedNodeReferences.php | 5 + .../Command/RemoveNodeAggregate.php | 5 + .../Command/ChangeNodeAggregateName.php | 5 + .../Command/ChangeNodeAggregateType.php | 5 + .../Command/CreateNodeVariant.php | 5 + .../CreateRootNodeAggregateWithNode.php | 5 + .../UpdateRootNodeAggregateDimensions.php | 5 + .../SubtreeTagging/Command/TagSubtree.php | 5 + .../SubtreeTagging/Command/UntagSubtree.php | 5 + .../ContentGraph/VisibilityConstraints.php | 4 +- .../Auth/AuthProviderInterface.php | 3 + .../SharedModel/Auth/StaticAuthProvider.php | 6 + .../Bootstrap/CRTestSuiteRuntimeVariables.php | 2 +- .../Features/Bootstrap/CRTestSuiteTrait.php | 2 +- .../Bootstrap/Helpers/FakeAuthProvider.php | 6 + .../ContentRepositoryAuthProvider.php | 147 ------------------ .../Controller/Frontend/NodeController.php | 17 +- .../Cache/NeosFusionContextSerializer.php | 7 +- .../Privilege/SubtreeTagPrivilege.php | 40 +++++ .../Privilege/SubtreeTagPrivilegeSubject.php | 29 ++++ .../ContentRepositoryAuthProvider.php | 126 +++++++++++++++ .../ContentRepositoryAuthProviderFactory.php | 3 +- .../NodeAddressToNodeConverter.php | 9 +- .../Classes/View/FusionExceptionView.php | 2 +- .../Settings.ContentRepositoryRegistry.yaml | 2 +- 35 files changed, 317 insertions(+), 184 deletions(-) delete mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php create mode 100644 Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php rename Neos.Neos/Classes/{ => Security}/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php (89%) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index f2e50762a18..7ba16616c99 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -18,6 +18,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\CommandHandler\CommandResult; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\DecoratedEvent; use Neos\ContentRepository\Core\EventStore\EventInterface; @@ -30,6 +31,7 @@ use Neos\ContentRepository\Core\Projection\CatchUp; use Neos\ContentRepository\Core\Projection\CatchUpOptions; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; @@ -283,6 +285,13 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName); } + public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface + { + $contentGraph = $this->getContentGraph($workspaceName); + $visibilityConstraints = $this->authProvider->getVisibilityConstraints($workspaceName); + return $contentGraph->getSubgraph($dimensionSpacePoint, $visibilityConstraints); + } + public function getNodeTypeManager(): NodeTypeManager { return $this->nodeTypeManager; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php index 4d2d5094818..844f12ae402 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php @@ -31,6 +31,8 @@ public function createCopyForWorkspace( WorkspaceName $targetWorkspaceName, ): CommandInterface; + public function getWorkspaceName(): WorkspaceName; + /** * called during deserialization from metadata * @param array $array diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php index 936505d278d..e3143635b15 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php @@ -84,6 +84,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php index 498ba261e49..e6ae20eb2b5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php @@ -80,6 +80,11 @@ public function createCopyForWorkspace( ); } + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php index f295cd6dabe..6f1f44c2531 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php @@ -176,4 +176,9 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php index 454b7c2ccb2..527e19ae21c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php @@ -99,4 +99,9 @@ public function createCopyForWorkspace( $this->nodeVariantSelectionStrategy ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php index de0ad11d57d..41d70615b29 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php @@ -99,4 +99,9 @@ public function createCopyForWorkspace( $this->nodeVariantSelectionStrategy ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php index 52355fd5e4a..20ca7998a06 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php @@ -183,4 +183,9 @@ public function createCopyForWorkspace( $this->nodeAggregateIdMapping ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php index ca589337e85..edae34fbc7f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php @@ -119,4 +119,9 @@ public function createCopyForWorkspace( $this->propertiesToUnset, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php index 3a756163488..ca2fe213734 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php @@ -131,4 +131,9 @@ public function createCopyForWorkspace( $this->newSucceedingSiblingNodeAggregateId ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php index 3f635af08e0..6ddcdbb6c93 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php @@ -107,4 +107,9 @@ public function createCopyForWorkspace( $this->references, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php index 085af255b8c..654edc63dd5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php @@ -119,4 +119,9 @@ public function createCopyForWorkspace( $this->removalAttachmentPoint, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php index 38d1f195ed6..5df67f01181 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php @@ -93,4 +93,9 @@ public function createCopyForWorkspace( $this->newNodeName, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php index e979e4d25c1..ede35595343 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php @@ -118,4 +118,9 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php index 001f9bd66e9..a8224f79dbc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php @@ -97,4 +97,9 @@ public function createCopyForWorkspace( $this->targetOrigin, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php index 9bbb1f320cc..b2fe51dc5c6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php @@ -135,4 +135,9 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php index 302c05ed895..489c0d96ca5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php @@ -78,4 +78,9 @@ public function createCopyForWorkspace( $this->nodeAggregateId, ); } + + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php index 71fb012b0cc..21e9ad2cb21 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php @@ -89,6 +89,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php index cca49333c95..7aa78d270aa 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php @@ -90,6 +90,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } + public function getWorkspaceName(): WorkspaceName + { + return $this->workspaceName; + } + public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php index 7462e28a4e1..7271e3634a3 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php @@ -29,7 +29,7 @@ /** * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query */ - private function __construct( + public function __construct( public SubtreeTags $tagConstraints, ) { } @@ -48,7 +48,7 @@ public static function withoutRestrictions(): self return new self(SubtreeTags::createEmpty()); } - public static function frontend(): VisibilityConstraints + public static function default(): VisibilityConstraints { return new self(SubtreeTags::fromStrings('disabled')); } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php index b2d03733050..b01147cf727 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\Core\SharedModel\Auth; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -16,5 +17,7 @@ public function getUserId(): UserId; public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege; + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints; + public function getCommandPrivilege(CommandInterface $command): Privilege; } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php index 66ad3102805..4b49b9bcea3 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\Core\SharedModel\Auth; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** @@ -24,6 +25,11 @@ public function getUserId(): UserId return $this->userId; } + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return VisibilityConstraints::default(); + } + public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege { return Privilege::granted(); diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php index 1bdc2a5be01..66b1e76d28d 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php @@ -118,7 +118,7 @@ public function visibilityConstraintsAreSetTo(string $restrictionType): void { $this->currentVisibilityConstraints = match ($restrictionType) { 'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(), - 'frontend' => VisibilityConstraints::frontend(), + 'frontend' => VisibilityConstraints::default(), default => throw new \InvalidArgumentException('Visibility constraint "' . $restrictionType . '" not supported.'), }; } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php index 6dbe16daf40..b067f059ae2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -93,7 +93,7 @@ public function beforeEventSourcedScenarioDispatcher(BeforeScenarioScope $scope) $this->contentRepositories = []; } $this->currentContentRepository = null; - $this->currentVisibilityConstraints = VisibilityConstraints::frontend(); + $this->currentVisibilityConstraints = VisibilityConstraints::default(); $this->currentDimensionSpacePoint = null; $this->currentRootNodeAggregateId = null; $this->currentWorkspaceName = null; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php index 977e82ac1ec..605c17e0957 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Auth\Privilege; use Neos\ContentRepository\Core\SharedModel\Auth\UserId; use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; @@ -25,6 +26,11 @@ public function getUserId(): UserId return self::$userId ?? UserId::forSystemUser(); } + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return VisibilityConstraints::withoutRestrictions(); + } + public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege { return Privilege::granted(); diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php deleted file mode 100644 index b154f482211..00000000000 --- a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ /dev/null @@ -1,147 +0,0 @@ -userService->getCurrentUser(); - if ($user === null) { - return UserId::forSystemUser(); - } - return UserId::fromString($user->getId()->value); - } - - public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege - { - if ($this->securityContext->areAuthorizationChecksDisabled()) { - return Privilege::granted(); - } - $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName); - if ($workspacePermissions === null) { - return Privilege::denied('No user is authenticated'); - } - return match ($privilegeType) { - WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User has no read permission for workspace "%s"', $workspaceName->value)), - }; - } - - public function getCommandPrivilege(CommandInterface $command): Privilege - { - if ($this->securityContext->areAuthorizationChecksDisabled()) { - return Privilege::granted(); - } - if ($command instanceof CreateWorkspace) { - $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName); - if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) { - return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value)); - } - return Privilege::granted(); - } - list($privilege, $workspaceName) = match ($command::class) { - AddDimensionShineThrough::class, - ChangeNodeAggregateName::class, - ChangeNodeAggregateType::class, - CopyNodesRecursively::class, - CreateNodeAggregateWithNode::class, - CreateNodeAggregateWithNodeAndSerializedProperties::class, - CreateNodeVariant::class, - CreateRootNodeAggregateWithNode::class, - DisableNodeAggregate::class, - DiscardIndividualNodesFromWorkspace::class, - DiscardWorkspace::class, - EnableNodeAggregate::class, - MoveDimensionSpacePoint::class, - MoveNodeAggregate::class, - PublishIndividualNodesFromWorkspace::class, - PublishWorkspace::class, - RebaseWorkspace::class, - RemoveNodeAggregate::class, - SetNodeProperties::class, - SetNodeReferences::class, - SetSerializedNodeProperties::class, - SetSerializedNodeReferences::class, - TagSubtree::class, - UntagSubtree::class, - UpdateRootNodeAggregateDimensions::class => ['write', $command->workspaceName], - ChangeBaseWorkspace::class, - CreateRootWorkspace::class, - CreateWorkspace::class, - DeleteWorkspace::class => ['manage', $command->workspaceName], - default => [null, null], - }; - if ($privilege === null) { - return Privilege::granted(); - } - $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName); - if ($workspacePermissions === null) { - return Privilege::denied(sprintf('No user is authenticated to %s workspace "%s" because no user is authenticated', $privilege, $workspaceName->value)); - } - $privilegeGranted = $privilege === 'write' ? $workspacePermissions->write : $workspacePermissions->manage; - return $privilegeGranted ? Privilege::granted() : Privilege::denied(sprintf('User has no %s permission for workspace "%s"', $privilege, $workspaceName->value)); - } - - private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions - { - $user = $this->userService->getCurrentUser(); - if ($user === null) { - return null; - } - return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); - } -} diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index a426c24328f..36fba3aacfe 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -20,7 +20,6 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; @@ -125,21 +124,12 @@ public function previewAction(string $node): void { // @todo add $renderingModeName as parameter and append it for successive links again as get parameter to node uris $renderingMode = $this->renderingModeService->findByCurrentUser(); - - $visibilityConstraints = VisibilityConstraints::frontend(); - if ($this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.GeneralAccess')) { - $visibilityConstraints = VisibilityConstraints::withoutRestrictions(); - } - $siteDetectionResult = SiteDetectionResult::fromRequest($this->request->getHttpRequest()); $contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId); $nodeAddress = NodeAddress::fromJsonString($node); - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $visibilityConstraints - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); @@ -204,10 +194,7 @@ public function showAction(string $node): void } $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - VisibilityConstraints::frontend() - ); + $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); diff --git a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php index ac47b1e3172..cf9165eb0fa 100644 --- a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php +++ b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php @@ -73,12 +73,7 @@ private function tryDeserializeNode(array $serializedNode): ?Node $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); try { - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $nodeAddress->workspaceName->isLive() - ? VisibilityConstraints::frontend() - : VisibilityConstraints::withoutRestrictions() - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); } catch (WorkspaceDoesNotExist $exception) { // in case the workspace was deleted the rendering should probably not come to this very point // still if it does we fail silently diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php new file mode 100644 index 00000000000..2b3d1f157d0 --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php @@ -0,0 +1,40 @@ +userService->getCurrentUser(); + if ($user === null) { + return UserId::forSystemUser(); + } + return UserId::fromString($user->getId()->value); + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return VisibilityConstraints::default(); + } + $restrictedSubtreeTags = [SubtreeTag::disabled()]; + try { + /** @var array $subtreeTagPrivileges */ + $subtreeTagPrivileges = $this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class); + } catch (\Exception $e) { + throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e); + } + foreach ($subtreeTagPrivileges as $privilege) { + if (!$privilege->isGranted()) { + $restrictedSubtreeTags[] = SubtreeTag::fromString($privilege->getParsedMatcher()); + } + } + return new VisibilityConstraints( + SubtreeTags::fromArray($restrictedSubtreeTags) + ); + } + + public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted(); + } + $user = $this->userService->getCurrentUser(); + if ($user === null) { + return Privilege::denied('No user is authenticated'); + } + $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + return match ($privilegeType) { + WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)), + }; + } + + public function getCommandPrivilege(CommandInterface $command): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted(); + } + // TODO handle: + // ChangeBaseWorkspace + // CreateRootWorkspace + // DeleteWorkspace + // DiscardIndividualNodesFromWorkspace + // DiscardWorkspace + // PublishWorkspace + // PublishIndividualNodesFromWorkspace + // RebaseWorkspace + if ($command instanceof CreateWorkspace) { + $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName); + if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) { + return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value)); + } + return Privilege::granted(); + } + if (!$command instanceof RebasableToOtherWorkspaceInterface) { + return Privilege::granted(); + } + $user = $this->userService->getCurrentUser(); + if ($user === null) { + return Privilege::denied('No user is authenticated'); + } + $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $command->getWorkspaceName(), $user); + return $workspacePermissions->write ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no write permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $command->getWorkspaceName()->value)); + } + + private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions + { + $user = $this->userService->getCurrentUser(); + if ($user === null) { + return null; + } + return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + } +} diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php similarity index 89% rename from Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php rename to Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index 63f84ae439e..d1535c50275 100644 --- a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Neos\Neos\ContentRepositoryAuthProvider; +namespace Neos\Neos\Security\ContentRepositoryAuthProvider; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\Flow\Annotations as Flow; diff --git a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php index c8744bb5eda..4157629043d 100644 --- a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php +++ b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php @@ -15,7 +15,6 @@ */ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; @@ -59,13 +58,7 @@ public function convertFrom( ) { $nodeAddress = NodeAddress::fromJsonString($source); $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName) - ->getSubgraph( - $nodeAddress->dimensionSpacePoint, - $nodeAddress->workspaceName->isLive() - ? VisibilityConstraints::frontend() - : VisibilityConstraints::withoutRestrictions() - ); + $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); return $subgraph->findNodeById($nodeAddress->aggregateId); } diff --git a/Neos.Neos/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php index fd6814acf20..564304dbda0 100644 --- a/Neos.Neos/Classes/View/FusionExceptionView.php +++ b/Neos.Neos/Classes/View/FusionExceptionView.php @@ -123,7 +123,7 @@ public function render(): ResponseInterface|StreamInterface $site, WorkspaceName::forLive(), $dimensionSpacePoint, - VisibilityConstraints::frontend() + VisibilityConstraints::default() ); } catch (WorkspaceDoesNotExist | \RuntimeException) { return $this->renderErrorWelcomeScreen(); diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml index c088a067cbe..ea085721623 100644 --- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml @@ -4,7 +4,7 @@ Neos: 'default': authProvider: - factoryObjectName: Neos\Neos\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory + factoryObjectName: Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory projections: 'Neos.Neos:DocumentUriPathProjection': From cf1273c006daeed0f754675e05f64be1e2e26259 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 17 Oct 2024 18:15:47 +0200 Subject: [PATCH 03/58] Fix ContentRepositoryAuthProviderFactory --- .../ContentRepositoryAuthProviderFactory.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index d1535c50275..d708cb18ac8 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -7,6 +7,8 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Security\Context as SecurityContext; +use Neos\Flow\Security\Policy\PolicyService; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspaceService; @@ -16,11 +18,13 @@ * @api */ #[Flow\Scope('singleton')] -final class ContentRepositoryAuthProviderFactory implements AuthProviderFactoryInterface +final readonly class ContentRepositoryAuthProviderFactory implements AuthProviderFactoryInterface { public function __construct( - private readonly UserService $userService, - private readonly WorkspaceService $workspaceService, + private UserService $userService, + private WorkspaceService $workspaceService, + private SecurityContext $securityContext, + private PolicyService $policyService, ) { } @@ -29,6 +33,6 @@ public function __construct( */ public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider { - return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService); + return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService, $this->securityContext, $this->policyService); } } From b1fe6c68cca50bfb0d0cf06464712723c497dcff Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 18 Oct 2024 13:11:10 +0200 Subject: [PATCH 04/58] Simplify `AuthProviderInterface::getWorkspacePrivilege()` --- .../Classes/ContentRepository.php | 2 +- .../Auth/AuthProviderInterface.php | 2 +- .../SharedModel/Auth/StaticAuthProvider.php | 2 +- .../Auth/WorkspacePrivilegeType.php | 23 ------------------- .../Bootstrap/Helpers/FakeAuthProvider.php | 2 +- .../ContentRepositoryAuthProvider.php | 9 ++++---- 6 files changed, 9 insertions(+), 31 deletions(-) delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 77dce980c42..b6d5448e188 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -249,7 +249,7 @@ public function resetProjectionState(string $projectionClassName): void */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { - $privilege = $this->authProvider->getWorkspacePrivilege($workspaceName, WorkspacePrivilegeType::READ_NODES); + $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName); if (!$privilege->granted) { // TODO more specific exception throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760); diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php index b01147cf727..98b488bd420 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php @@ -15,7 +15,7 @@ interface AuthProviderInterface { public function getUserId(): UserId; - public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege; + public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege; public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php index 4b49b9bcea3..b7b8f5a3b86 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php @@ -30,7 +30,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili return VisibilityConstraints::default(); } - public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege + public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege { return Privilege::granted(); } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php deleted file mode 100644 index c08d6aa4d46..00000000000 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php +++ /dev/null @@ -1,23 +0,0 @@ -securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted(); @@ -78,9 +78,7 @@ public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePri return Privilege::denied('No user is authenticated'); } $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); - return match ($privilegeType) { - WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)), - }; + return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)); } public function getCommandPrivilege(CommandInterface $command): Privilege @@ -104,6 +102,9 @@ public function getCommandPrivilege(CommandInterface $command): Privilege } return Privilege::granted(); } + // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all + // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the + // code if we were to add new commands in the future if (!$command instanceof RebasableToOtherWorkspaceInterface) { return Privilege::granted(); } From 3dbf15cfb880b825b6a2a0d25433363d28573d11 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 18 Oct 2024 13:11:38 +0200 Subject: [PATCH 05/58] Always grant read access to `live` workspace --- .../ContentRepositoryAuthProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 2666e9f768b..cc9e59012d0 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -75,7 +75,7 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName) } $user = $this->userService->getCurrentUser(); if ($user === null) { - return Privilege::denied('No user is authenticated'); + return $workspaceName->isLive() ? Privilege::granted() : Privilege::denied('No user is authenticated'); } $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)); From ea2a9d5706f9e86f4f5595f1e70c964d6be80a39 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 18 Oct 2024 13:13:04 +0200 Subject: [PATCH 06/58] Support CR specific subtree tag privileges ```yaml privilegeTargets: 'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege': 'Some.Package:FooInAllContentRepositories': matcher: 'foo' 'Some.Package:BarInDefaultContentRepository': matcher: 'default:bar' ``` --- .../ContentRepositoryAuthProvider.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index cc9e59012d0..1123cf07e64 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -59,9 +59,17 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e); } foreach ($subtreeTagPrivileges as $privilege) { - if (!$privilege->isGranted()) { - $restrictedSubtreeTags[] = SubtreeTag::fromString($privilege->getParsedMatcher()); + if ($privilege->isGranted()) { + continue; } + $subtreeTag = $privilege->getParsedMatcher(); + if (str_contains($subtreeTag, ':')) { + [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); + if ($this->contentRepositoryId->value !== $contentRepositoryId) { + continue; + } + } + $restrictedSubtreeTags[] = SubtreeTag::fromString($subtreeTag); } return new VisibilityConstraints( SubtreeTags::fromArray($restrictedSubtreeTags) From 6bf2e73222ecb1263298afbc695d37026d0005ee Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 18 Oct 2024 15:47:36 +0200 Subject: [PATCH 07/58] Rename `AuthProviderInterface::getUserId()` to `getAuthenticatedUserId()` and make return type nullable --- Neos.ContentRepository.Core/Classes/ContentRepository.php | 4 ++-- .../Classes/SharedModel/Auth/AuthProviderInterface.php | 2 +- .../Classes/SharedModel/Auth/StaticAuthProvider.php | 2 +- .../Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php | 4 ++-- .../ContentRepositoryAuthProvider.php | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index b6d5448e188..7f2b6ab77c0 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -38,7 +38,7 @@ use Neos\ContentRepository\Core\Projection\ProjectionStatuses; use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface; use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; +use Neos\ContentRepository\Core\SharedModel\Auth\UserId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; @@ -111,7 +111,7 @@ public function handle(CommandInterface $command): CommandResult $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies); // TODO meaningful exception message - $initiatingUserId = $this->authProvider->getUserId(); + $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser(); $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM); // Add "initiatingUserId" and "initiatingTimestamp" metadata to all events. diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php index 98b488bd420..84890012d9d 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php @@ -13,7 +13,7 @@ */ interface AuthProviderInterface { - public function getUserId(): UserId; + public function getAuthenticatedUserId(): ?UserId; public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php index b7b8f5a3b86..893c6967573 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php @@ -20,7 +20,7 @@ public function __construct( ) { } - public function getUserId(): UserId + public function getAuthenticatedUserId(): UserId { return $this->userId; } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php index e056ae14d54..163da1395ad 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php @@ -21,9 +21,9 @@ public static function setUserId(UserId $userId): void self::$userId = $userId; } - public function getUserId(): UserId + public function getAuthenticatedUserId(): ?UserId { - return self::$userId ?? UserId::forSystemUser(); + return self::$userId ?? null; } public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 1123cf07e64..7b073144476 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -37,11 +37,11 @@ public function __construct( ) { } - public function getUserId(): UserId + public function getAuthenticatedUserId(): ?UserId { $user = $this->userService->getCurrentUser(); if ($user === null) { - return UserId::forSystemUser(); + return null; } return UserId::fromString($user->getId()->value); } From bf57af6d351550860be19f28b8318889fc9d2836 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 19 Oct 2024 13:16:45 +0200 Subject: [PATCH 08/58] Extract permission evaluation from `ContentRepositoryAuthProvider` to new `ContentRepositoryAuthorizationService` singleton --- .../src/Domain/Repository/ContentSubgraph.php | 15 ++ .../Repository/ContentSubhypergraph.php | 15 ++ .../Classes/ContentRepository.php | 4 +- .../Classes/ContentRepositoryReadModel.php | 3 - .../ContentGraph/ContentSubgraphInterface.php | 2 + .../ContentGraph/VisibilityConstraints.php | 5 + .../Classes/SharedModel/Auth/Privilege.php | 10 +- .../SharedModel/Auth/StaticAuthProvider.php | 4 +- .../Bootstrap/Helpers/FakeAuthProvider.php | 5 +- .../Classes/Service/EventMigrationService.php | 15 +- .../ContentSubgraphWithRuntimeCaches.php | 5 + .../Classes/Controller/UsageController.php | 9 +- .../Classes/Command/CrCommandController.php | 1 + .../Command/WorkspaceCommandController.php | 40 +++--- .../Controller/Frontend/NodeController.php | 4 + .../Domain/Model/WorkspacePermissions.php | 48 +++++-- .../Classes/Domain/Model/WorkspaceRole.php | 10 +- .../Domain/Model/WorkspaceRoleAssignment.php | 10 +- .../Domain/Model/WorkspaceRoleAssignments.php | 3 +- .../Domain/Model/WorkspaceRoleSubject.php | 35 +++-- .../Domain/Model/WorkspaceRoleSubjects.php | 50 +++++++ .../Domain/Service/WorkspaceService.php | 135 +++++++++--------- .../ContentRepositoryAuthorizationService.php | 127 ++++++++++++++++ .../Privilege/SubtreeTagPrivilege.php | 37 ++++- .../Privilege/SubtreeTagPrivilegeSubject.php | 6 +- .../ContentRepositoryAuthProvider.php | 124 ++++++++-------- .../ContentRepositoryAuthProviderFactory.php | 8 +- Neos.Neos/Configuration/Policy.yaml | 11 ++ .../Bootstrap/WorkspaceServiceTrait.php | 23 ++- .../Controller/WorkspaceController.php | 10 +- 30 files changed, 549 insertions(+), 225 deletions(-) create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php create mode 100644 Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index 810a095069f..72b74631867 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -122,6 +122,21 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->visibilityConstraints; } + public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self + { + return new self( + $this->contentRepositoryId, + $this->workspaceName, + $this->contentStreamId, + $this->dimensionSpacePoint, + $newVisibilityConstraints, + $this->dbal, + $this->nodeFactory, + $this->nodeTypeManager, + $this->nodeQueryBuilder->tableNames, + ); + } + public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes { $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter); diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php index 8b312166d2a..994a880dae7 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php @@ -106,6 +106,21 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->visibilityConstraints; } + public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self + { + return new self( + $this->contentRepositoryId, + $this->contentStreamId, + $this->workspaceName, + $this->dimensionSpacePoint, + $newVisibilityConstraints, + $this->dbal, + $this->nodeFactory, + $this->nodeTypeManager, + $this->tableNamePrefix, + ); + } + public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { $query = HypergraphQuery::create($this->contentStreamId, $this->tableNamePrefix); diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 7f2b6ab77c0..ad566c64655 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -104,7 +104,7 @@ public function handle(CommandInterface $command): CommandResult { $privilege = $this->authProvider->getCommandPrivilege($command); if (!$privilege->granted) { - throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->message), 1729086686); + throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->reason), 1729086686); } // the commands only calculate which events they want to have published, but do not do the // publishing themselves @@ -252,7 +252,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName); if (!$privilege->granted) { // TODO more specific exception - throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760); + throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->reason ?? ''), 1729014760); } return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName); } diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php index 631d827e624..26be912f0cd 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php @@ -16,9 +16,6 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; -use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php index c47c0771932..d2df7ce2dd7 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php @@ -57,6 +57,8 @@ public function getDimensionSpacePoint(): DimensionSpacePoint; public function getVisibilityConstraints(): VisibilityConstraints; + public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self; + /** * Find a single node by its aggregate id * diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php index 7271e3634a3..598b9945a15 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php @@ -53,6 +53,11 @@ public static function default(): VisibilityConstraints return new self(SubtreeTags::fromStrings('disabled')); } + public function withAddedSubtreeTag(SubtreeTag $subtreeTag): self + { + return new self($this->tagConstraints->merge(SubtreeTags::fromArray([$subtreeTag]))); + } + /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php index e7746a7dfe4..cfe8798273b 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php @@ -22,17 +22,17 @@ { private function __construct( public bool $granted, - public ?string $message, + public string $reason, ) { } - public static function granted(): self + public static function granted(string $reason): self { - return new self(true, null); + return new self(true, $reason); } - public static function denied(string $message): self + public static function denied(string $reason): self { - return new self(false, $message); + return new self(false, $reason); } } diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php index 893c6967573..a44a4e61db1 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php @@ -32,11 +32,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege { - return Privilege::granted(); + return Privilege::granted(self::class . ' always grants privileges'); } public function getCommandPrivilege(CommandInterface $command): Privilege { - return Privilege::granted(); + return Privilege::granted(self::class . ' always grants privileges'); } } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php index 163da1395ad..c2cb6b8a84d 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php @@ -9,7 +9,6 @@ use Neos\ContentRepository\Core\SharedModel\Auth\Privilege; use Neos\ContentRepository\Core\SharedModel\Auth\UserId; use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; final class FakeAuthProvider implements AuthProviderInterface @@ -33,11 +32,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege { - return Privilege::granted(); + return Privilege::granted(self::class . ' always grants privileges'); } public function getCommandPrivilege(CommandInterface $command): Privilege { - return Privilege::granted(); + return Privilege::granted(self::class . ' always grants privileges'); } } diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 9cd7d315f80..a747c1611ba 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -589,27 +589,32 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): } catch (UniqueConstraintViolationException) { $outputFn(' Metadata already exists'); } - $roleAssignment = []; + $roleAssignments = []; if ($workspaceName->isLive()) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:LivePublisher', 'role' => WorkspaceRole::COLLABORATOR->value, ]; + $roleAssignments[] = [ + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, + 'subject' => 'Neos.Neos:Everybody', + 'role' => WorkspaceRole::VIEWER->value, + ]; } elseif ($isInternalWorkspace) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, 'subject' => 'Neos.Neos:AbstractEditor', 'role' => WorkspaceRole::COLLABORATOR->value, ]; } elseif ($isPrivateWorkspace) { - $roleAssignment = [ + $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::USER->value, 'subject' => $workspaceOwner, 'role' => WorkspaceRole::COLLABORATOR->value, ]; } - if ($roleAssignment !== []) { + foreach ($roleAssignments as $roleAssignment) { try { $this->connection->insert('neos_neos_workspace_role', [ 'content_repository_id' => $this->contentRepositoryId->value, diff --git a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php index c26bcef38d1..f9f88c2e164 100644 --- a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php +++ b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php @@ -77,6 +77,11 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->wrappedContentSubgraph->getVisibilityConstraints(); } + public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self + { + return new self($this->wrappedContentSubgraph->withVisibilityConstraints($newVisibilityConstraints), $this->subgraphCachePool); + } + public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes { if (!self::isFilterEmpty($filter)) { diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 4e75aa55aa1..5d11138f12c 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -25,6 +25,7 @@ use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Service\UserService; use Neos\Neos\AssetUsage\Dto\AssetUsageReference; @@ -65,6 +66,12 @@ class UsageController extends ActionController */ protected $workspaceService; + /** + * @Flow\Inject + * @var ContentRepositoryAuthorizationService + */ + protected $contentRepositoryAuthorizationService; + /** * Get Related Nodes for an asset * @@ -103,7 +110,7 @@ public function relatedNodesAction(AssetInterface $asset) ); $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null; - $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser( + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser( $currentContentRepositoryId, $usage->getWorkspaceName(), $currentUser diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php index a6c2f0606ad..12fc64ac987 100644 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ b/Neos.Neos/Classes/Command/CrCommandController.php @@ -122,6 +122,7 @@ public function importCommand(string $path, string $contentRepository = 'default // set the live-workspace title to (implicitly) create the metadata record for this workspace $this->workspaceService->setWorkspaceTitle($contentRepositoryId, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace')); $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:Everybody', WorkspaceRole::VIEWER)); $this->outputLine('Done'); } diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 2fdd87931c2..d4d9f2688ef 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -255,6 +255,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription, * * Without explicit workspace roles, only administrators can change the corresponding workspace. * With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: + * - viewer: Can read from the workspace * - collaborator: Can read from and write to the workspace * - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) * @@ -284,30 +285,21 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), }; $workspaceRole = match ($role) { + 'viewer' => WorkspaceRole::VIEWER, 'collaborator' => WorkspaceRole::COLLABORATOR, 'manager' => WorkspaceRole::MANAGER, - default => throw new \InvalidArgumentException(sprintf('role must be "collaborator" or "manager", given "%s"', $role), 1728398880), + default => throw new \InvalidArgumentException(sprintf('role must be "viewer", "collaborator" or "manager", given "%s"', $role), 1728398880), }; - if ($subjectType === WorkspaceRoleSubjectType::USER) { - $neosUser = $this->userService->getUser($subject); - if ($neosUser === null) { - $this->outputLine('The user "%s" specified as subject does not exist', [$subject]); - $this->quit(1); - } - $roleSubject = WorkspaceRoleSubject::fromString($neosUser->getId()->value); - } else { - $roleSubject = WorkspaceRoleSubject::fromString($subject); - } + $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject); $this->workspaceService->assignWorkspaceRole( $contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::create( - $subjectType, $roleSubject, $workspaceRole ) ); - $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]); + $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject, $workspaceName->value]); } /** @@ -331,11 +323,10 @@ public function unassignRoleCommand(string $workspace, string $subject, string $ 'user' => WorkspaceRoleSubjectType::USER, default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), }; - $roleSubject = WorkspaceRoleSubject::fromString($subject); + $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject); $this->workspaceService->unassignWorkspaceRole( $contentRepositoryId, $workspaceName, - $subjectType, $roleSubject, ); $this->outputLine('Removed role assignment from subject "%s" for workspace "%s"', [$roleSubject->value, $workspaceName->value]); @@ -524,7 +515,7 @@ public function showCommand(string $workspace, string $contentRepository = 'defa return; } $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [ - $assignment->subjectType->value, + $assignment->subject->type->value, $assignment->subject->value, $assignment->role->value, ], iterator_to_array($workspaceRoleAssignments)), [ @@ -533,4 +524,21 @@ public function showCommand(string $workspace, string $contentRepository = 'defa 'Role', ]); } + + // ----------------------- + + private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject + { + if ($subjectType === WorkspaceRoleSubjectType::USER) { + $neosUser = $this->userService->getUser($usernameOrRoleIdentifier); + if ($neosUser === null) { + $this->outputLine('The user "%s" specified as subject does not exist', [$usernameOrRoleIdentifier]); + $this->quit(1); + } + $roleSubject = WorkspaceRoleSubject::createForUser($neosUser->getId()); + } else { + $roleSubject = WorkspaceRoleSubject::createForGroup($usernameOrRoleIdentifier); + } + return $roleSubject; + } } diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index 36fba3aacfe..9d5756d5fba 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -14,12 +14,14 @@ namespace Neos\Neos\Controller\Frontend; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindSubtreeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree; +use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; @@ -195,6 +197,8 @@ public function showAction(string $node): void $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); + // todo document + $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled())); $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index faf543259cf..cdb2461f6bb 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -5,9 +5,10 @@ namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** - * Calculated permissions a specific user has on a workspace + * Evaluated permissions a specific user has on a workspace, usually evaluated by the {@see ContentRepositoryAuthorizationService} * * - read: Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it @@ -22,29 +23,48 @@ * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()} */ - public static function create( - bool $read, - bool $write, - bool $manage, - ): self { - return new self($read, $write, $manage); + private function __construct( + public bool $read, + public bool $write, + public bool $manage, + private string $reason, + ) { } /** * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) + * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()} */ - private function __construct( - public bool $read, - public bool $write, - public bool $manage, - ) { + public static function create( + bool $read, + bool $write, + bool $manage, + string $reason, + ): self { + return new self($read, $write, $manage, $reason); + } + + public static function all(string $reason): self + { + return new self(true, true, true, $reason); + } + + public static function manage(string $reason): self + { + return new self(false, false, true, $reason); + } + + public static function none(string $reason): self + { + return new self(false, false, false, $reason); } - public static function all(): self + public function getReason(): string { - return new self(true, true, true); + return $this->reason; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index 898c961f5a0..a36269e22b8 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -12,6 +12,11 @@ */ enum WorkspaceRole : string { + /** + * Can read from the workspace + */ + case VIEWER = 'VIEWER'; + /** * Can read from and write to the workspace */ @@ -30,8 +35,9 @@ public function isAtLeast(self $role): bool private function specificity(): int { return match ($this) { - self::COLLABORATOR => 1, - self::MANAGER => 2, + self::VIEWER => 1, + self::COLLABORATOR => 2, + self::MANAGER => 3, }; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php index fd7d5a7896f..f8206c8d2ce 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -15,25 +15,22 @@ final readonly class WorkspaceRoleAssignment { private function __construct( - public WorkspaceRoleSubjectType $subjectType, public WorkspaceRoleSubject $subject, public WorkspaceRole $role, ) { } public static function create( - WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject, WorkspaceRole $role, ): self { - return new self($subjectType, $subject, $role); + return new self($subject, $role); } public static function createForUser(UserId $userId, WorkspaceRole $role): self { return new self( - WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($userId->value), + WorkspaceRoleSubject::createForUser($userId), $role ); } @@ -41,8 +38,7 @@ public static function createForUser(UserId $userId, WorkspaceRole $role): self public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role): self { return new self( - WorkspaceRoleSubjectType::GROUP, - WorkspaceRoleSubject::fromString($flowRoleIdentifier), + WorkspaceRoleSubject::createForGroup($flowRoleIdentifier), $role ); } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php index 82dc1eb4a3f..a63eb23b899 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php @@ -5,7 +5,6 @@ namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; -use Traversable; /** * A set of {@see WorkspaceRoleAssignment} instances @@ -39,7 +38,7 @@ public function isEmpty(): bool return $this->assignments === []; } - public function getIterator(): Traversable + public function getIterator(): \Traversable { yield from $this->assignments; } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php index fb80329b09d..c9255bcfe5f 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -12,28 +12,47 @@ * @api */ #[Flow\Proxy(false)] -final readonly class WorkspaceRoleSubject implements \JsonSerializable +final readonly class WorkspaceRoleSubject { - public function __construct( - public string $value + private function __construct( + public WorkspaceRoleSubjectType $type, + public string $value, ) { if (preg_match('/^[\p{L}\p{P}\d .]{1,200}$/u', $this->value) !== 1) { throw new \InvalidArgumentException(sprintf('"%s" is not a valid workspace role subject.', $value), 1728384932); } } - public static function fromString(string $value): self + public static function createForUser(UserId $userId): self { - return new self($value); + return new self( + WorkspaceRoleSubjectType::USER, + $userId->value, + ); } - public function jsonSerialize(): string + public static function createForGroup(string $flowRoleIdentifier): self { - return $this->value; + return new self( + WorkspaceRoleSubjectType::GROUP, + $flowRoleIdentifier, + ); + } + + public static function create( + WorkspaceRoleSubjectType $type, + string $value, + ): self { + return new self($type, $value); } public function equals(self $other): bool { - return $this->value === $other->value; + return $this->type === $other->type && $this->value === $other->value; + } + + public function __toString(): string + { + return "{$this->type->value}: {$this->value}"; } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php new file mode 100644 index 00000000000..5af2a3b2b36 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php @@ -0,0 +1,50 @@ + + * @api + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceRoleSubjects implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $subjects; + + private function __construct(WorkspaceRoleSubject ...$subjects) + { + $this->subjects = $subjects; + } + + /** + * @param array $subjects + */ + public static function fromArray(array $subjects): self + { + return new self(...$subjects); + } + + public function isEmpty(): bool + { + return $this->subjects === []; + } + + public function getIterator(): \Traversable + { + yield from $this->subjects; + } + + public function count(): int + { + return count($this->subjects); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 1431181f52e..4e9b2e892e8 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -28,19 +28,19 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Exception\NoSuchRoleException; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceMetadata; -use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjects; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** * Central authority to interact with Content Repository Workspaces within Neos @@ -57,7 +57,6 @@ final class WorkspaceService public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly UserService $userService, private readonly Connection $dbal, private readonly SecurityContext $securityContext, ) { @@ -87,7 +86,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - // TODO check workspace permissions -> $this->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceName, $this->userService->getCurrentUser()); + // TODO check workspace permissions $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'title' => $newWorkspaceTitle->value, ]); @@ -98,6 +97,7 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { + // TODO check workspace permissions $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'description' => $newWorkspaceDescription->value, ]); @@ -201,7 +201,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => $assignment->subjectType->value, + 'subject_type' => $assignment->subject->type->value, 'subject' => $assignment->subject->value, 'role' => $assignment->role->value, ]); @@ -217,14 +217,14 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo * * @see self::assignWorkspaceRole() */ - public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject): void + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); try { $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject_type' => $subjectType->value, + 'subject_type' => $subject->type->value, 'subject' => $subject->value, ]); } catch (DbalException $e) { @@ -238,7 +238,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, /** * Get all role assignments for the specified workspace * - * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used! */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { @@ -262,37 +262,73 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito } return WorkspaceRoleAssignments::fromArray( array_map(static fn (array $row) => WorkspaceRoleAssignment::create( - WorkspaceRoleSubjectType::from($row['subject_type']), - WorkspaceRoleSubject::fromString($row['subject']), + WorkspaceRoleSubject::create( + WorkspaceRoleSubjectType::from($row['subject_type']), + $row['subject'], + ), WorkspaceRole::from($row['role']), ), $rows) ); } /** - * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} + * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName + * + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used! */ - public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole { - try { - $userRoles = array_keys($this->userService->getAllRoles($user)); - } catch (NoSuchRoleException $e) { - throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e); + $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<type === WorkspaceRoleSubjectType::GROUP) { + $groupSubjectValues[] = $subject->value; + } else { + $userSubjectValues[] = $subject->value; + } } - $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspaceMetadata !== null && $workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { - return WorkspacePermissions::all(); + try { + $role = $this->dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, + 'userSubjectValues' => $userSubjectValues, + 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, + 'groupSubjectValues' => $groupSubjectValues, + ], [ + 'userSubjectValues' => ArrayParameterType::STRING, + 'groupSubjectValues' => ArrayParameterType::STRING, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e); } - $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), $userRoles); - $userIsAdministrator = in_array('Neos.Neos:Administrator', $userRoles, true); - if ($userWorkspaceRole === null) { - return WorkspacePermissions::create(false, false, $userIsAdministrator); + if ($role === false) { + return null; } - return WorkspacePermissions::create( - read: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - ); + return WorkspaceRole::from($role); } /** @@ -437,49 +473,6 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } - /** - * @param array $userRoles - */ - private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): ?WorkspaceRole - { - $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; - $query = <<dbal->fetchOne($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, - 'userId' => $userId->value, - 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, - 'groupSubjects' => $userRoles, - ], [ - 'groupSubjects' => ArrayParameterType::STRING, - ]); - if ($role === false) { - return null; - } - return WorkspaceRole::from($role); - } - private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace { $workspace = $this->contentRepositoryRegistry diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php new file mode 100644 index 00000000000..3d056cae5a3 --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -0,0 +1,127 @@ +workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); + if ($userWorkspaceRole === null) { + return WorkspacePermissions::none("Anonymous user has no explicit role for workspace '{$workspaceName->value}'"); + } + return WorkspacePermissions::create( + read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), + write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + reason: "Anonymous user has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'", + ); + } + + /** + * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} + */ + public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); + if ($workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { + return WorkspacePermissions::all("User '{$user->getLabel()}' (id: {$user->getId()->value} is the owner of workspace '{$workspaceName->value}'"); + } + $userRoles = $this->rolesForUser($user); + $userIsAdministrator = array_key_exists(self::FLOW_ROLE_ADMINISTRATOR, $userRoles); + $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles)); + $subjects[] = WorkspaceRoleSubject::createForUser($user->getId()); + $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); + if ($userWorkspaceRole === null) { + if ($userIsAdministrator) { + return WorkspacePermissions::manage("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' but is an Administrator"); + } + return WorkspacePermissions::none("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' and is no Administrator"); + } + return WorkspacePermissions::create( + read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), + write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + reason: "User '{$user->getLabel()}' (id: '{$user->getId()->value}') has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'" . ($userIsAdministrator ? ' and is an Administrator' : ' and is no Administrator'), + ); + } + + public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints + { + $roles = array_map($this->policyService->getRole(...), [self::FLOW_ROLE_EVERYBODY, self::FLOW_ROLE_ANONYMOUS]); + return $this->visibilityConstraintsForRoles($contentRepositoryId, $roles); + } + + public function getVisibilityConstraintsForUser(ContentRepositoryId $contentRepositoryId, User $user): VisibilityConstraints + { + $userRoles = $this->rolesForUser($user); + return $this->visibilityConstraintsForRoles($contentRepositoryId, $userRoles); + } + + /** + * @param array $roles + */ + private function visibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints + { + $restrictedSubtreeTags = []; + /** @var SubtreeTagPrivilege $privilege */ + foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) { + if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) { + $restrictedSubtreeTags[] = $privilege->getSubtreeTag(); + } + } + return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags)); + } + + /** + * @return array + */ + private function rolesForUser(User $user): array + { + try { + $userRoles = $this->userService->getAllRoles($user); + } catch (NoSuchRoleException $e) { + throw new \RuntimeException("Failed to determine roles for user '{$user->getLabel()}' (id: '{$user->getId()->value}'), check your package dependencies: {$e->getMessage()}", 1727084881, $e); + } + return $userRoles; + } +} diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php index 2b3d1f157d0..da4eb47357c 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php @@ -14,6 +14,8 @@ namespace Neos\Neos\Security\Authorization\Privilege; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException; @@ -23,6 +25,22 @@ */ class SubtreeTagPrivilege extends AbstractPrivilege { + private SubtreeTag|null $subtreeTagRuntimeCache = null; + private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null; + + private function initialize(): void + { + if ($this->subtreeTagRuntimeCache !== null) { + return; + } + $subtreeTag = $this->getParsedMatcher(); + if (str_contains($subtreeTag, ':')) { + [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); + $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId); + } + $this->subtreeTagRuntimeCache = SubtreeTag::fromString($subtreeTag); + } + /** * Returns true, if this privilege covers the given subject * @@ -35,6 +53,23 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool if (!$subject instanceof SubtreeTagPrivilegeSubject) { throw new InvalidPrivilegeTypeException(sprintf('Privileges of type "%s" only support subjects of type "%s" but we got a subject of type: "%s".', self::class, SubtreeTagPrivilegeSubject::class, get_class($subject)), 1729173985); } - return false; + $contentRepositoryId = $this->getContentRepositoryId(); + if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) { + return false; + } + return $subject->subTreeTag->equals($this->getSubtreeTag()); + } + + public function getSubtreeTag(): SubtreeTag + { + $this->initialize(); + assert($this->subtreeTagRuntimeCache !== null); + return $this->subtreeTagRuntimeCache; + } + + public function getContentRepositoryId(): ?ContentRepositoryId + { + $this->initialize(); + return $this->contentRepositoryIdRuntimeCache; } } diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php index df2cd33c5b6..a08a0a090aa 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php @@ -14,6 +14,8 @@ namespace Neos\Neos\Security\Authorization\Privilege; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; /** @@ -22,8 +24,8 @@ final readonly class SubtreeTagPrivilegeSubject implements PrivilegeSubjectInterface { public function __construct( - public string $subTreeTag, - public string|null $contentRepository = null, + public SubtreeTag $subTreeTag, + public ContentRepositoryId|null $contentRepositoryId = null, ) { } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 7b073144476..6179994e815 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -6,34 +6,39 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; +use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\Auth\Privilege; use Neos\ContentRepository\Core\SharedModel\Auth\UserId; -use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Policy\PolicyService; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Service\WorkspaceService; -use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** * @api */ final class ContentRepositoryAuthProvider implements AuthProviderInterface { + private const WORKSPACE_PERMISSION_WRITE = 'write'; + private const WORKSPACE_PERMISSION_MANAGE = 'manage'; + public function __construct( private readonly ContentRepositoryId $contentRepositoryId, private readonly UserService $userService, - private readonly WorkspaceService $workspaceService, + private readonly ContentRepositoryAuthorizationService $authorizationService, private readonly SecurityContext $securityContext, - private readonly PolicyService $policyService, ) { } @@ -51,85 +56,82 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili if ($this->securityContext->areAuthorizationChecksDisabled()) { return VisibilityConstraints::default(); } - $restrictedSubtreeTags = [SubtreeTag::disabled()]; - try { - /** @var array $subtreeTagPrivileges */ - $subtreeTagPrivileges = $this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class); - } catch (\Exception $e) { - throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e); - } - foreach ($subtreeTagPrivileges as $privilege) { - if ($privilege->isGranted()) { - continue; - } - $subtreeTag = $privilege->getParsedMatcher(); - if (str_contains($subtreeTag, ':')) { - [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); - if ($this->contentRepositoryId->value !== $contentRepositoryId) { - continue; - } - } - $restrictedSubtreeTags[] = SubtreeTag::fromString($subtreeTag); + $user = $this->userService->getCurrentUser(); + if ($user === null) { + return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId); } - return new VisibilityConstraints( - SubtreeTags::fromArray($restrictedSubtreeTags) - ); + return $this->authorizationService->getVisibilityConstraintsForUser($this->contentRepositoryId, $user); } public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege { if ($this->securityContext->areAuthorizationChecksDisabled()) { - return Privilege::granted(); + return Privilege::granted('Authorization checks are disabled'); } $user = $this->userService->getCurrentUser(); if ($user === null) { - return $workspaceName->isLive() ? Privilege::granted() : Privilege::denied('No user is authenticated'); + $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); + } else { + $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); } - $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); - return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)); + return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason()); } public function getCommandPrivilege(CommandInterface $command): Privilege { if ($this->securityContext->areAuthorizationChecksDisabled()) { - return Privilege::granted(); - } - // TODO handle: - // ChangeBaseWorkspace - // CreateRootWorkspace - // DeleteWorkspace - // DiscardIndividualNodesFromWorkspace - // DiscardWorkspace - // PublishWorkspace - // PublishIndividualNodesFromWorkspace - // RebaseWorkspace - if ($command instanceof CreateWorkspace) { - $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName); - if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) { - return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value)); - } - return Privilege::granted(); + return Privilege::granted('Authorization checks are disabled'); } + // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the // code if we were to add new commands in the future - if (!$command instanceof RebasableToOtherWorkspaceInterface) { - return Privilege::granted(); + if ($command instanceof RebasableToOtherWorkspaceInterface) { + return $this->requireWorkspacePermission($command->getWorkspaceName(), self::WORKSPACE_PERMISSION_WRITE); } - $user = $this->userService->getCurrentUser(); - if ($user === null) { - return Privilege::denied('No user is authenticated'); + + if ($command instanceof CreateRootWorkspace) { + return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks'); + } + + if ($command instanceof ChangeBaseWorkspace) { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName); + if (!$workspacePermissions->manage) { + return Privilege::denied("Missing 'manage' permissions for workspace '{$command->workspaceName->value}': {$workspacePermissions->getReason()}"); + } + $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName); + if (!$baseWorkspacePermissions->write) { + return Privilege::denied("Missing 'write' permissions for base workspace '{$command->baseWorkspaceName->value}': {$baseWorkspacePermissions->getReason()}"); + } + return Privilege::granted("User has 'manage' permissions for workspace '{$command->workspaceName->value}' and 'write' permissions for base workspace '{$command->baseWorkspaceName->value}'"); } - $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $command->getWorkspaceName(), $user); - return $workspacePermissions->write ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no write permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $command->getWorkspaceName()->value)); + return match ($command::class) { + CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE), + DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE), + DiscardWorkspace::class, + DiscardIndividualNodesFromWorkspace::class, + PublishWorkspace::class, + PublishIndividualNodesFromWorkspace::class, + RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE), + default => Privilege::granted('Command not restricted'), + }; } - private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions + private function requireWorkspacePermission(WorkspaceName $workspaceName, string $permission): Privilege + { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); + if (!$workspacePermissions->{$permission}) { + return Privilege::denied("Missing '{$permission}' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + } + return Privilege::granted("User has '{$permission}' permissions for workspace '{$workspaceName->value}'"); + } + + private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions { $user = $this->userService->getCurrentUser(); if ($user === null) { - return null; + return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); } - return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + return $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index d708cb18ac8..54bfc97294c 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -8,9 +8,8 @@ use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Policy\PolicyService; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** * Implementation of the {@see AuthProviderFactoryInterface} in order to provide authentication and authorization for Content Repositories @@ -22,9 +21,8 @@ { public function __construct( private UserService $userService, - private WorkspaceService $workspaceService, + private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService, private SecurityContext $securityContext, - private PolicyService $policyService, ) { } @@ -33,6 +31,6 @@ public function __construct( */ public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider { - return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService, $this->securityContext, $this->policyService); + return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryAuthorizationService, $this->securityContext); } } diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index 8ccf44e7ce0..1cd85ab83c2 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -142,6 +142,12 @@ privilegeTargets: label: General access to the dimensions module matcher: 'administration/dimensions' + + 'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege': + + 'Neos.Neos:ContentRepository.ReadDisabledNodes': + matcher: 'disabled' + roles: 'Neos.Flow:Everybody': @@ -229,6 +235,11 @@ roles: privilegeTarget: 'Neos.Neos:Backend.Module.Management' permission: GRANT + - + privilegeTarget: 'Neos.Neos:ContentRepository.ReadDisabledNodes' + permission: GRANT + + 'Neos.Neos:RestrictedEditor': label: Restricted Editor diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 06916378f00..8106b291e13 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -27,6 +27,7 @@ use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use PHPUnit\Framework\Assert; /** @@ -165,16 +166,15 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table /** * @When the role :role is assigned to workspace :workspaceName for group :groupName - * @When the role :role is assigned to workspace :workspaceName for user :username + * @When the role :role is assigned to workspace :workspaceName for user :userId */ - public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void + public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void { $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceRoleAssignment::create( - $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), WorkspaceRole::from($role) ) )); @@ -182,15 +182,14 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string /** * @When the role for group :groupName is unassigned from workspace :workspaceName - * @When the role for user :username is unassigned from workspace :workspaceName + * @When the role for user :userId is unassigned from workspace :workspaceName */ - public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void + public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void { $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), )); } @@ -201,7 +200,7 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName { $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [ - 'Subject type' => $assignment->subjectType->value, + 'Subject type' => $assignment->subject->type->value, 'Subject' => $assignment->subject->value, 'Role' => $assignment->role->value, ], iterator_to_array($workspaceAssignments)); @@ -214,12 +213,12 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void { $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser( + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), $user, ); - Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter((array)$permissions)))); + Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions))))); } /** @@ -228,7 +227,7 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void { $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser( + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), $user, diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index dac01978f21..72cd21ac82b 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -62,6 +62,7 @@ use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\PendingChangesProjection\ChangeFinder; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; use Neos\Workspace\Ui\ViewModel\PendingChanges; use Neos\Workspace\Ui\ViewModel\WorkspaceListItem; @@ -103,6 +104,9 @@ class WorkspaceController extends AbstractModuleController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * Display a list of unpublished content */ @@ -135,7 +139,7 @@ public function indexAction(): void $allWorkspaces = $contentRepository->findWorkspaces(); foreach ($allWorkspaces as $workspace) { $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); if (!$permissions->read) { continue; } @@ -174,7 +178,7 @@ public function showAction(WorkspaceName $workspace): void $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName); assert($baseWorkspace !== null); $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); - $baseWorkspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, @@ -1030,7 +1034,7 @@ protected function prepareBaseWorkspaceOptions( if ($user === null) { continue; } - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); if (!$permissions->manage) { continue; } From b79e0bae53d2c0e66841ca596025358d0e204d64 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 22 Oct 2024 18:34:59 +0200 Subject: [PATCH 09/58] Move auth related classes to `Feature/Security` and add dedicated `AccessDenied` exception --- .../Behavior/FakeAuthProviderFactory.php | 2 +- .../Classes/ContentRepository.php | 13 ++++--- .../Factory/ContentRepositoryFactory.php | 2 +- .../Factory/ProjectionFactoryDependencies.php | 2 +- .../Security}/AuthProviderInterface.php | 4 ++- .../Security/Dto}/Privilege.php | 2 +- .../Auth => Feature/Security/Dto}/UserId.php | 2 +- .../Security/Exception/AccessDenied.php | 34 +++++++++++++++++++ .../Security}/StaticAuthProvider.php | 4 ++- .../Bootstrap/CRTestSuiteRuntimeVariables.php | 7 ++-- .../Bootstrap/Helpers/FakeAuthProvider.php | 6 ++-- .../Classes/ContentRepositoryRegistry.php | 4 +-- .../AuthProviderFactoryInterface.php | 2 +- .../StaticAuthProviderFactory.php | 6 ++-- .../Controller/Frontend/NodeController.php | 6 +++- .../ContentRepositoryAuthProvider.php | 6 ++-- 16 files changed, 73 insertions(+), 29 deletions(-) rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security}/AuthProviderInterface.php (78%) rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security/Dto}/Privilege.php (92%) rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security/Dto}/UserId.php (96%) create mode 100644 Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security}/StaticAuthProvider.php (86%) diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php index df59e7f80c8..f018a5ac958 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php @@ -4,8 +4,8 @@ namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index bbc21fa9c81..a73d7a42aa3 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -26,6 +26,9 @@ use Neos\ContentRepository\Core\EventStore\Events; use Neos\ContentRepository\Core\EventStore\EventsToPublish; use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUp; use Neos\ContentRepository\Core\Projection\CatchUpOptions; @@ -36,8 +39,6 @@ use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; use Neos\ContentRepository\Core\Projection\ProjectionStatuses; use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\UserId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; @@ -97,12 +98,13 @@ public function __construct( * The only API to send commands (mutation intentions) to the system. * * @param CommandInterface $command + * @throws AccessDenied */ public function handle(CommandInterface $command): void { $privilege = $this->authProvider->getCommandPrivilege($command); if (!$privilege->granted) { - throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->reason), 1729086686); + throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason); } // the commands only calculate which events they want to have published, but do not do the // publishing themselves @@ -253,19 +255,20 @@ public function resetProjectionState(string $projectionClassName): void /** * @throws WorkspaceDoesNotExist if the workspace does not exist + * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface}) */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName); if (!$privilege->granted) { - // TODO more specific exception - throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->reason ?? ''), 1729014760); + throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason); } return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName); } /** * @throws WorkspaceDoesNotExist if the workspace does not exist + * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface}) */ public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface { diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index 333b7e0a472..a942fe9d155 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -21,6 +21,7 @@ use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\EventNormalizer; use Neos\ContentRepository\Core\EventStore\EventPersister; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Feature\ContentStreamCommandHandler; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\DimensionSpaceCommandHandler; use Neos\ContentRepository\Core\Feature\NodeAggregateCommandHandler; @@ -30,7 +31,6 @@ use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\EventStore\EventStoreInterface; use Psr\Clock\ClockInterface; use Symfony\Component\Serializer\Serializer; diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php index 36b614ba623..0b78e572cc4 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php @@ -18,9 +18,9 @@ use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\EventNormalizer; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\EventStore\EventStoreInterface; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php similarity index 78% rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php index 84890012d9d..2d2bca17bfc 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\Auth; +namespace Neos\ContentRepository\Core\Feature\Security; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php similarity index 92% rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php index cfe8798273b..0fcef1552fe 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\Auth; +namespace Neos\ContentRepository\Core\Feature\Security\Dto; /** * A privilege that is returned by the {@see AuthProviderInterface} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php similarity index 96% rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php index 80228a031d0..b43e9b5feb1 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\Auth; +namespace Neos\ContentRepository\Core\Feature\Security\Dto; use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory; diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php new file mode 100644 index 00000000000..21528122e84 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php @@ -0,0 +1,34 @@ +value, $reason), 1729014760); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php similarity index 86% rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php index a44a4e61db1..5c5b72e1223 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Neos\ContentRepository\Core\SharedModel\Auth; +namespace Neos\ContentRepository\Core\Feature\Security; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php index 66b1e76d28d..15706af6806 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php @@ -14,9 +14,10 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap; -use Neos\ContentRepository\Core\ContentRepositoryReadModel; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\ContentRepositoryReadModel; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; @@ -24,11 +25,9 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; -use Neos\ContentRepository\Core\SharedModel\Auth\UserId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; +use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock; /** * The node creation trait for behavioral tests diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php index c2cb6b8a84d..ac7def3d8d0 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php @@ -5,10 +5,10 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Auth\Privilege; -use Neos\ContentRepository\Core\SharedModel\Auth\UserId; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; final class FakeAuthProvider implements AuthProviderInterface diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index 3e901aa404e..95e5c1887ab 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -10,6 +10,7 @@ use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface; use Neos\ContentRepository\Core\Factory\ProjectionsAndCatchUpHooksFactory; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; @@ -17,14 +18,13 @@ use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; use Neos\ContentRepositoryRegistry\Exception\ContentRepositoryNotFoundException; use Neos\ContentRepositoryRegistry\Exception\InvalidConfigurationException; +use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\Clock\ClockFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\ContentDimensionSource\ContentDimensionSourceFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\EventStore\EventStoreFactoryInterface; use Neos\ContentRepositoryRegistry\Factory\NodeTypeManager\NodeTypeManagerFactoryInterface; -use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\ContentSubgraphWithRuntimeCaches; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\SubgraphCachePool; use Neos\EventStore\EventStoreInterface; diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php index 9aebd57e688..809efdf65db 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php @@ -4,8 +4,8 @@ namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; /** * @api diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php index 4cc8b9a7ebd..870f2e5ace2 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php @@ -2,10 +2,10 @@ declare(strict_types=1); namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; +use Neos\ContentRepository\Core\Feature\Security\StaticAuthProvider; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\Auth\StaticAuthProvider; -use Neos\ContentRepository\Core\SharedModel\Auth\UserId; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; /** * @api diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index 9d5756d5fba..53d98b82c94 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -197,8 +197,12 @@ public function showAction(string $node): void $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); - // todo document + + // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to + // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. + // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled())); + $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 6179994e815..293ff100e32 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -6,6 +6,9 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; @@ -16,9 +19,6 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface; -use Neos\ContentRepository\Core\SharedModel\Auth\Privilege; -use Neos\ContentRepository\Core\SharedModel\Auth\UserId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Security\Context as SecurityContext; From d58fb9809f9f6446b1bc1a209a21b20ff258a90b Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 22 Oct 2024 19:27:24 +0200 Subject: [PATCH 10/58] `VisibilityConstraints` not only for Neos users --- .../src/Domain/Repository/ContentSubgraph.php | 15 --------------- .../Domain/Repository/ContentSubhypergraph.php | 15 --------------- .../ContentGraph/ContentSubgraphInterface.php | 2 -- .../ContentSubgraphWithRuntimeCaches.php | 5 ----- .../Controller/Frontend/NodeController.php | 12 ++++++++---- .../ContentRepositoryAuthorizationService.php | 14 +------------- .../ContentRepositoryAuthProvider.php | 6 +----- 7 files changed, 10 insertions(+), 59 deletions(-) diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php index 72b74631867..810a095069f 100644 --- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php +++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php @@ -122,21 +122,6 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->visibilityConstraints; } - public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self - { - return new self( - $this->contentRepositoryId, - $this->workspaceName, - $this->contentStreamId, - $this->dimensionSpacePoint, - $newVisibilityConstraints, - $this->dbal, - $this->nodeFactory, - $this->nodeTypeManager, - $this->nodeQueryBuilder->tableNames, - ); - } - public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes { $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter); diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php index 994a880dae7..8b312166d2a 100644 --- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php +++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php @@ -106,21 +106,6 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->visibilityConstraints; } - public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self - { - return new self( - $this->contentRepositoryId, - $this->contentStreamId, - $this->workspaceName, - $this->dimensionSpacePoint, - $newVisibilityConstraints, - $this->dbal, - $this->nodeFactory, - $this->nodeTypeManager, - $this->tableNamePrefix, - ); - } - public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node { $query = HypergraphQuery::create($this->contentStreamId, $this->tableNamePrefix); diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php index d2df7ce2dd7..c47c0771932 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php @@ -57,8 +57,6 @@ public function getDimensionSpacePoint(): DimensionSpacePoint; public function getVisibilityConstraints(): VisibilityConstraints; - public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self; - /** * Find a single node by its aggregate id * diff --git a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php index f9f88c2e164..c26bcef38d1 100644 --- a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php +++ b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php @@ -77,11 +77,6 @@ public function getVisibilityConstraints(): VisibilityConstraints return $this->wrappedContentSubgraph->getVisibilityConstraints(); } - public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self - { - return new self($this->wrappedContentSubgraph->withVisibilityConstraints($newVisibilityConstraints), $this->subgraphCachePool); - } - public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes { if (!self::isFilterEmpty($filter)) { diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index 53d98b82c94..e96a0ea4fd7 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -36,6 +36,7 @@ use Neos\Flow\Session\SessionInterface; use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Model\RenderingMode; +use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\RenderingModeService; use Neos\Neos\FrontendRouting\Exception\InvalidShortcutException; @@ -43,6 +44,7 @@ use Neos\Neos\FrontendRouting\NodeShortcutResolver; use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; use Neos\Neos\View\FusionView; @@ -111,6 +113,9 @@ class NodeController extends ActionController #[Flow\Inject] protected NodeUriBuilderFactory $nodeUriBuilderFactory; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * @param string $node * @throws NodeNotFoundException @@ -189,19 +194,18 @@ public function previewAction(string $node): void public function showAction(string $node): void { $nodeAddress = NodeAddress::fromJsonString($node); - unset($node); if (!$nodeAddress->workspaceName->isLive()) { throw new NodeNotFoundException('The requested node isn\'t accessible to the current user', 1430218623); } $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint); - // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated - $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled())); + $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForRoles($contentRepository->id, $this->securityContext->getRoles()); + $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled()); + $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints); $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 3d056cae5a3..f37a03a33b3 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -85,22 +85,10 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos ); } - public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints - { - $roles = array_map($this->policyService->getRole(...), [self::FLOW_ROLE_EVERYBODY, self::FLOW_ROLE_ANONYMOUS]); - return $this->visibilityConstraintsForRoles($contentRepositoryId, $roles); - } - - public function getVisibilityConstraintsForUser(ContentRepositoryId $contentRepositoryId, User $user): VisibilityConstraints - { - $userRoles = $this->rolesForUser($user); - return $this->visibilityConstraintsForRoles($contentRepositoryId, $userRoles); - } - /** * @param array $roles */ - private function visibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints + public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints { $restrictedSubtreeTags = []; /** @var SubtreeTagPrivilege $privilege */ diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 293ff100e32..bd8b020c877 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -56,11 +56,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili if ($this->securityContext->areAuthorizationChecksDisabled()) { return VisibilityConstraints::default(); } - $user = $this->userService->getCurrentUser(); - if ($user === null) { - return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId); - } - return $this->authorizationService->getVisibilityConstraintsForUser($this->contentRepositoryId, $user); + return $this->authorizationService->getVisibilityConstraintsForRoles($this->contentRepositoryId, $this->securityContext->getRoles()); } public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege From cab0bd6bca905000b35b9e16d80eefcda1d8c5c6 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 22 Oct 2024 20:05:35 +0200 Subject: [PATCH 11/58] Tweaks --- .../Classes/ContentRepository.php | 1 - .../Domain/Service/WorkspaceService.php | 20 +++++++++---------- .../Controller/WorkspaceController.php | 10 ++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index b2ccf9f21ee..e3eb1b81dec 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -112,7 +112,6 @@ public function handle(CommandInterface $command): void // publishing themselves $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies); - // TODO meaningful exception message $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser(); $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 37d3b09e1ad..5d31865c781 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -27,7 +27,6 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -45,20 +44,17 @@ /** * Central authority to interact with Content Repository Workspaces within Neos * - * TODO evaluate permissions for workspace changes - * * @api */ #[Flow\Scope('singleton')] -final class WorkspaceService +final readonly class WorkspaceService { private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata'; private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role'; public function __construct( - private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly Connection $dbal, - private readonly SecurityContext $securityContext, + private ContentRepositoryRegistry $contentRepositoryRegistry, + private Connection $dbal, ) { } @@ -83,10 +79,11 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W /** * Update/set title metadata for the specified workspace + * + * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - // TODO check workspace permissions $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'title' => $newWorkspaceTitle->value, ]); @@ -94,10 +91,11 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work /** * Update/set description metadata for the specified workspace + * + * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { - // TODO check workspace permissions $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ 'description' => $newWorkspaceDescription->value, ]); @@ -178,14 +176,14 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con return; } $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); - $this->securityContext->withoutAuthorizationChecks(fn () => $this->createPersonalWorkspace( + $this->createPersonalWorkspace( $contentRepositoryId, $workspaceName, WorkspaceTitle::fromString($user->getLabel()), WorkspaceDescription::empty(), WorkspaceName::forLive(), $user->getId(), - )); + ); } /** diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 494b6900c8e..ea63307c93a 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -43,6 +43,7 @@ use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; use Neos\Flow\Security\Context; +use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Neos\Controller\Module\AbstractModuleController; @@ -288,6 +289,15 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $user = $this->userService->getCurrentUser(); + if ($user === null) { + throw new AccessDeniedException('No user is authenticated', 1729620262); + } + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspaceName, $user); + if (!$workspacePermissions->manage) { + throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297); + } + if ($title->value === '') { $title = WorkspaceTitle::fromString($workspaceName->value); } From 7ef315c4ac0a99fa38b1563edd9a3c3bbfc841cd Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 23 Oct 2024 11:31:53 +0200 Subject: [PATCH 12/58] Fix doc comments for `workspace:assignRole` command --- Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index d4d9f2688ef..de14714430f 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -269,7 +269,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription, * * @param string $workspace Name of the workspace, for example "some-workspace" * @param string $subject The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user - * @param string $role Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + * @param string $role Role to assign, either 'viewer', 'collaborator' or 'manager' – a viewer can only read from the workspace, a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself * @param string $contentRepository Identifier of the content repository. (Default: 'default') * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user * @throws StopCommandException From 35ff7db46de4ecabbcdfffc6ef3f0eb4fa7261bb Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 23 Oct 2024 11:45:55 +0200 Subject: [PATCH 13/58] Determine privileges based on the (authenticated) `Account` ..instead of the Neos `User`. Reasoning: - A Neos `User` can have multiple accounts assigned - It makes sense to evaluate all authenticated roles e.g. for frontend authentication) --- .../Classes/Controller/UsageController.php | 18 +++- .../Controller/Frontend/NodeController.php | 7 +- .../Domain/Service/WorkspaceService.php | 4 +- .../ContentRepositoryAuthorizationService.php | 93 ++++++++++++++----- .../ContentRepositoryAuthProvider.php | 18 ++-- .../Controller/WorkspaceController.php | 33 +++---- 6 files changed, 118 insertions(+), 55 deletions(-) diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 5d11138f12c..a62d36b6298 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -19,6 +19,7 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Service\AssetService; use Neos\Neos\Domain\Repository\SiteRepository; @@ -66,6 +67,12 @@ class UsageController extends ActionController */ protected $workspaceService; + /** + * @Flow\Inject + * @var SecurityContext + */ + protected $securityContext; + /** * @Flow\Inject * @var ContentRepositoryAuthorizationService @@ -110,11 +117,12 @@ public function relatedNodesAction(AssetInterface $asset) ); $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null; - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser( - $currentContentRepositoryId, - $usage->getWorkspaceName(), - $currentUser - ); + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount !== null) { + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($currentContentRepositoryId, $usage->getWorkspaceName(), $authenticatedAccount); + } else { + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($currentContentRepositoryId, $usage->getWorkspaceName()); + } $workspace = $contentRepository->findWorkspaceByName($usage->getWorkspaceName()); diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index e96a0ea4fd7..db146184bdb 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -203,7 +203,12 @@ public function showAction(string $node): void // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated - $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForRoles($contentRepository->id, $this->securityContext->getRoles()); + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount !== null) { + $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount); + } else { + $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id); + } $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled()); $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 5d31865c781..6cc3ecfe4ac 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -236,7 +236,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, /** * Get all role assignments for the specified workspace * - * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used! */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { @@ -272,7 +272,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito /** * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName * - * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used! */ public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole { diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index f37a03a33b3..6bb13616c79 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -9,8 +9,8 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Security\Account; use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; -use Neos\Flow\Security\Exception\NoSuchRoleException; use Neos\Flow\Security\Policy\PolicyService; use Neos\Flow\Security\Policy\Role; use Neos\Neos\Domain\Model\User; @@ -18,10 +18,10 @@ use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjects; -use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege; use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject; +use Neos\Party\Domain\Service\PartyService; /** * @api @@ -31,65 +31,77 @@ { private const FLOW_ROLE_EVERYBODY = 'Neos.Flow:Everybody'; private const FLOW_ROLE_ANONYMOUS = 'Neos.Flow:Anonymous'; - private const FLOW_ROLE_ADMINISTRATOR = 'Neos.Neos:Administrator'; + private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser'; + private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator'; public function __construct( - private UserService $userService, + private PartyService $partyService, private WorkspaceService $workspaceService, private PolicyService $policyService, private PrivilegeManagerInterface $privilegeManager, ) { } + /** + * Determines the {@see WorkspacePermissions} an anonymous user has for the specified workspace (aka "public access") + */ public function getWorkspacePermissionsForAnonymousUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspacePermissions { $subjects = [WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_EVERYBODY), WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_ANONYMOUS)]; $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { - return WorkspacePermissions::none("Anonymous user has no explicit role for workspace '{$workspaceName->value}'"); + return WorkspacePermissions::none(sprintf('Anonymous user has no explicit role for workspace "%s"', $workspaceName->value)); } return WorkspacePermissions::create( read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - reason: "Anonymous user has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'", + reason: sprintf('Anonymous user has role "%s" for workspace "%s"', $userWorkspaceRole->value, $workspaceName->value), ); } /** - * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} + * Determines the {@see WorkspacePermissions} the given user has for the specified workspace */ - public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, Account $account): WorkspacePermissions { $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { - return WorkspacePermissions::all("User '{$user->getLabel()}' (id: {$user->getId()->value} is the owner of workspace '{$workspaceName->value}'"); + $neosUser = $this->neosUserFromAccount($account); + if ($workspaceMetadata->ownerUserId !== null && $neosUser !== null && $neosUser->getId()->equals($workspaceMetadata->ownerUserId)) { + return WorkspacePermissions::all(sprintf('User "%s" (id: %s is the owner of workspace "%s"', $neosUser->getLabel(), $neosUser->getId()->value, $workspaceName->value)); } - $userRoles = $this->rolesForUser($user); - $userIsAdministrator = array_key_exists(self::FLOW_ROLE_ADMINISTRATOR, $userRoles); + $userRoles = $this->expandAccountRoles($account); + $userIsAdministrator = array_key_exists(self::FLOW_ROLE_NEOS_ADMINISTRATOR, $userRoles); $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles)); - $subjects[] = WorkspaceRoleSubject::createForUser($user->getId()); + + if ($neosUser !== null) { + $subjects[] = WorkspaceRoleSubject::createForUser($neosUser->getId()); + } $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { if ($userIsAdministrator) { - return WorkspacePermissions::manage("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' but is an Administrator"); + return WorkspacePermissions::manage(sprintf('Account "%s" is a Neos Administrator without explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value)); } - return WorkspacePermissions::none("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' and is no Administrator"); + return WorkspacePermissions::none(sprintf('Account "%s" is no Neos Administrator and has no explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value)); } return WorkspacePermissions::create( read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - reason: "User '{$user->getLabel()}' (id: '{$user->getId()->value}') has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'" . ($userIsAdministrator ? ' and is an Administrator' : ' and is no Administrator'), + reason: sprintf('Account "%s" is %s Neos Administrator and has role "%s" for workspace "%s"', $account->getAccountIdentifier(), $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value), ); } /** - * @param array $roles + * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access") */ - public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints + public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints { + $roles = [ + self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), + self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS), + ]; $restrictedSubtreeTags = []; /** @var SubtreeTagPrivilege $privilege */ foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) { @@ -100,16 +112,49 @@ public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRep return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags)); } + /** + * Determines the default {@see VisibilityConstraints} for the specified account + */ + public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints + { + $roles = $this->expandAccountRoles($account); + $restrictedSubtreeTags = []; + /** @var SubtreeTagPrivilege $privilege */ + foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) { + if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) { + $restrictedSubtreeTags[] = $privilege->getSubtreeTag(); + } + } + return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags)); + } + + // ------------------------------ + /** * @return array */ - private function rolesForUser(User $user): array + private function expandAccountRoles(Account $account): array { - try { - $userRoles = $this->userService->getAllRoles($user); - } catch (NoSuchRoleException $e) { - throw new \RuntimeException("Failed to determine roles for user '{$user->getLabel()}' (id: '{$user->getId()->value}'), check your package dependencies: {$e->getMessage()}", 1727084881, $e); + $roles = [ + self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), + self::FLOW_ROLE_AUTHENTICATED_USER => $this->policyService->getRole(self::FLOW_ROLE_AUTHENTICATED_USER), + ]; + foreach ($account->getRoles() as $currentRole) { + if (!array_key_exists($currentRole->getIdentifier(), $roles)) { + $roles[$currentRole->getIdentifier()] = $currentRole; + } + foreach ($currentRole->getAllParentRoles() as $currentParentRole) { + if (!array_key_exists($currentParentRole->getIdentifier(), $roles)) { + $roles[$currentParentRole->getIdentifier()] = $currentParentRole; + } + } } - return $userRoles; + return $roles; + } + + private function neosUserFromAccount(Account $account): ?User + { + $user = $this->partyService->getAssignedPartyOfAccount($account); + return $user instanceof User ? $user : null; } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index bd8b020c877..60f73360eb2 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -56,7 +56,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili if ($this->securityContext->areAuthorizationChecksDisabled()) { return VisibilityConstraints::default(); } - return $this->authorizationService->getVisibilityConstraintsForRoles($this->contentRepositoryId, $this->securityContext->getRoles()); + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount) { + return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount); + } + return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId); } public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege @@ -64,11 +68,11 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName) if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); } - $user = $this->userService->getCurrentUser(); - if ($user === null) { + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); } else { - $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); } return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason()); } @@ -124,10 +128,10 @@ private function requireWorkspacePermission(WorkspaceName $workspaceName, string private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions { - $user = $this->userService->getCurrentUser(); - if ($user === null) { + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); } - return $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user); + return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); } } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index ea63307c93a..e3a2276cc9a 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -113,9 +113,9 @@ class WorkspaceController extends AbstractModuleController */ public function indexAction(): void { - $currentUser = $this->userService->getCurrentUser(); - if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1718308216); + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { + throw new AccessDeniedException('No user authenticated', 1718308216); } $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); @@ -140,7 +140,7 @@ public function indexAction(): void $allWorkspaces = $contentRepository->findWorkspaces(); foreach ($allWorkspaces as $workspace) { $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $workspace->workspaceName, $authenticatedAccount); if (!$permissions->read) { continue; } @@ -160,9 +160,9 @@ classification: $workspaceMetadata->classification->name, public function showAction(WorkspaceName $workspace): void { - $currentUser = $this->userService->getCurrentUser(); - if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1720371024); + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { + throw new AccessDeniedException('No user authenticated', 1720371024); } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -179,7 +179,7 @@ public function showAction(WorkspaceName $workspace): void $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName); assert($baseWorkspace !== null); $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); - $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $baseWorkspace->workspaceName, $authenticatedAccount); } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, @@ -208,7 +208,7 @@ public function createAction( ): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1718303756); + throw new AccessDeniedException('No user authenticated', 1718303756); } $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value); try { @@ -289,11 +289,11 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $user = $this->userService->getCurrentUser(); - if ($user === null) { + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { throw new AccessDeniedException('No user is authenticated', 1729620262); } - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspaceName, $user); + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspaceName, $authenticatedAccount); if (!$workspacePermissions->manage) { throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297); } @@ -1023,7 +1023,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { - $user = $this->userService->getCurrentUser(); + $authenticatedAccount = $this->securityContext->getAccount(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); foreach ($workspaces as $workspace) { @@ -1039,10 +1039,11 @@ protected function prepareBaseWorkspaceOptions( if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - if ($user === null) { - continue; + if ($authenticatedAccount !== null) { + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspace->workspaceName, $authenticatedAccount); + } else { + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($contentRepository->id, $workspace->workspaceName); } - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); if (!$permissions->manage) { continue; } From 65a0ab5bd6f5722cf22a107e0cfce770142a039f Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 23 Oct 2024 12:45:35 +0200 Subject: [PATCH 14/58] Adjust behat tests to account based ACL --- .../Features/Bootstrap/WorkspaceServiceTrait.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 8106b291e13..416b6d4a8d3 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -213,10 +213,13 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void { $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser( + Assert::assertNotNull($user); + $account = $user->getAccounts()->first(); + Assert::assertNotNull($account); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $account, ); Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions))))); } @@ -227,10 +230,13 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void { $user = $this->getObject(UserService::class)->getUser($username); - $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser( + Assert::assertNotNull($user); + $account = $user->getAccounts()->first(); + Assert::assertNotNull($account); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $account, ); Assert::assertFalse($permissions->read); Assert::assertFalse($permissions->write); From c0536d305bfacc318183243f7a60aa1a12d11b21 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 24 Oct 2024 16:30:07 +0200 Subject: [PATCH 15/58] Tweak `AuthProvideInterface` method names and add doc comments --- .../Classes/ContentRepository.php | 7 +++++-- .../Classes/Feature/Security/AuthProviderInterface.php | 6 ++++-- .../Classes/Feature/Security/StaticAuthProvider.php | 4 ++-- .../Projection/ContentGraph/ContentGraphInterface.php | 3 ++- .../Features/Bootstrap/Helpers/FakeAuthProvider.php | 4 ++-- Neos.Neos/Classes/Controller/Frontend/NodeController.php | 7 ++++--- .../ContentRepositoryAuthorizationService.php | 2 ++ .../ContentRepositoryAuthProvider.php | 9 ++++++--- Neos.Neos/Configuration/Policy.yaml | 2 ++ 9 files changed, 29 insertions(+), 15 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index e3eb1b81dec..e698ba424f7 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -104,7 +104,7 @@ public function __construct( */ public function handle(CommandInterface $command): void { - $privilege = $this->authProvider->getCommandPrivilege($command); + $privilege = $this->authProvider->canExecuteCommand($command); if (!$privilege->granted) { throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason); } @@ -266,7 +266,7 @@ public function resetProjectionState(string $projectionClassName): void */ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface { - $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName); + $privilege = $this->authProvider->canReadNodesFromWorkspace($workspaceName); if (!$privilege->granted) { throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason); } @@ -278,6 +278,9 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter } /** + * Main API to retrieve a content subgraph, taking VisibilityConstraints of the current user + * into account ({@see AuthProviderInterface::getVisibilityConstraints()}) + * * @throws WorkspaceDoesNotExist if the workspace does not exist * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface}) */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php index 2d2bca17bfc..e6f8524e3e1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php @@ -11,15 +11,17 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** + * Provides authorization decisions for the current user, for one Content Repository. + * * @internal except for CR factory implementations */ interface AuthProviderInterface { public function getAuthenticatedUserId(): ?UserId; - public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege; + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege; public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints; - public function getCommandPrivilege(CommandInterface $command): Privilege; + public function canExecuteCommand(CommandInterface $command): Privilege; } diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php index 5c5b72e1223..2d44bcf63fe 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php @@ -32,12 +32,12 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili return VisibilityConstraints::default(); } - public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege { return Privilege::granted(self::class . ' always grants privileges'); } - public function getCommandPrivilege(CommandInterface $command): Privilege + public function canExecuteCommand(CommandInterface $command): Privilege { return Privilege::granted(self::class . ' always grants privileges'); } diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php index e8cac17eb67..c3de5f221d9 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php @@ -14,6 +14,7 @@ namespace Neos\ContentRepository\Core\Projection\ContentGraph; +use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; @@ -50,7 +51,7 @@ public function getContentRepositoryId(): ContentRepositoryId; public function getWorkspaceName(): WorkspaceName; /** - * @api main API method of ContentGraph + * @api You most likely want to use {@see ContentRepository::getContentSubgraph()} because it automatically determines VisibilityConstraints for the current user. */ public function getSubgraph( DimensionSpacePoint $dimensionSpacePoint, diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php index ac7def3d8d0..916e98ffee3 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php @@ -30,12 +30,12 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili return VisibilityConstraints::withoutRestrictions(); } - public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege { return Privilege::granted(self::class . ' always grants privileges'); } - public function getCommandPrivilege(CommandInterface $command): Privilege + public function canExecuteCommand(CommandInterface $command): Privilege { return Privilege::granted(self::class . ' always grants privileges'); } diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index db146184bdb..7191c4e496f 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -200,15 +200,16 @@ public function showAction(string $node): void } $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to - // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. - // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated $authenticatedAccount = $this->securityContext->getAccount(); if ($authenticatedAccount !== null) { $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount); } else { $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id); } + // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to + // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. + // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated, + // to ensure that disabled nodes are NEVER shown recursively. $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled()); $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints); diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 6bb13616c79..58717299d95 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -24,6 +24,8 @@ use Neos\Party\Domain\Service\PartyService; /** + * Central point which does ContentRepository authorization decisions within Neos. + * * @api */ #[Flow\Scope('singleton')] diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 60f73360eb2..980f645caac 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -27,7 +27,10 @@ use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** - * @api + * Implementation of Content Repository {@see AuthProviderInterface} which ties the authorization + * to Neos. + * + * @internal use {@see ContentRepositoryAuthorizationService} to ask for specific authorization decisions */ final class ContentRepositoryAuthProvider implements AuthProviderInterface { @@ -63,7 +66,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId); } - public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege { if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); @@ -77,7 +80,7 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName) return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason()); } - public function getCommandPrivilege(CommandInterface $command): Privilege + public function canExecuteCommand(CommandInterface $command): Privilege { if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index 1cd85ab83c2..32fed1bd759 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -146,6 +146,8 @@ privilegeTargets: 'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege': 'Neos.Neos:ContentRepository.ReadDisabledNodes': + # !!! matcher payload in this case is a ContentRepository SubtreeTag, + # i.e. nodes with ths specified tag are only read if the user has the corresponding privilegeTarget assigned. matcher: 'disabled' roles: From 38bd6f3c969592f493d3c07e2e89c674bba58d5d Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 24 Oct 2024 16:30:36 +0200 Subject: [PATCH 16/58] Ignore disabled authorization checks when evaluating visibility constraints --- .../ContentRepositoryAuthProvider.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 980f645caac..67ece65438a 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -56,9 +56,6 @@ public function getAuthenticatedUserId(): ?UserId public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints { - if ($this->securityContext->areAuthorizationChecksDisabled()) { - return VisibilityConstraints::default(); - } $authenticatedAccount = $this->securityContext->getAccount(); if ($authenticatedAccount) { return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount); From fb4a3e889a94e39683fe05af7e656cef587a5887 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 26 Oct 2024 13:20:34 +0200 Subject: [PATCH 17/58] FEATURE: EditNodePrivilege! --- .../Classes/Domain/Model/NodePermissions.php | 60 +++++++++++ .../ContentRepositoryAuthorizationService.php | 100 ++++++++++++++---- ...p => AbstractSubtreeTagBasedPrivilege.php} | 19 ++-- .../Privilege/EditNodePrivilege.php | 28 +++++ .../Privilege/ReadNodePrivilege.php | 28 +++++ .../Privilege/SubtreeTagPrivilegeSubject.php | 14 ++- .../ContentRepositoryAuthProvider.php | 30 ++++++ Neos.Neos/Configuration/Policy.yaml | 2 +- 8 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 Neos.Neos/Classes/Domain/Model/NodePermissions.php rename Neos.Neos/Classes/Security/Authorization/Privilege/{SubtreeTagPrivilege.php => AbstractSubtreeTagBasedPrivilege.php} (78%) create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php new file mode 100644 index 00000000000..760300720b2 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -0,0 +1,60 @@ +reason; + } +} diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 58717299d95..b6ebc7a8994 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -5,21 +5,26 @@ namespace Neos\Neos\Security\Authorization; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Account; use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; use Neos\Flow\Security\Policy\PolicyService; use Neos\Flow\Security\Policy\Role; +use Neos\Neos\Domain\Model\NodePermissions; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjects; use Neos\Neos\Domain\Service\WorkspaceService; -use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege; +use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege; +use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject; use Neos\Party\Domain\Service\PartyService; @@ -36,10 +41,10 @@ private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser'; private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator'; - public function __construct( private PartyService $partyService, private WorkspaceService $workspaceService, + private ContentRepositoryRegistry $contentRepositoryRegistry, private PolicyService $policyService, private PrivilegeManagerInterface $privilegeManager, ) { @@ -95,23 +100,25 @@ public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRe ); } + public function getNodePermissionsForAnonymousUser(Node|NodeAddress $node): NodePermissions + { + $roles = $this->rolesOfAnonymousUser(); + return $this->nodePermissionsForRoles($node, $roles); + } + + public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $account): NodePermissions + { + $roles = $this->expandAccountRoles($account); + return $this->nodePermissionsForRoles($node, $roles); + } + /** * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access") */ public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints { - $roles = [ - self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), - self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS), - ]; - $restrictedSubtreeTags = []; - /** @var SubtreeTagPrivilege $privilege */ - foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) { - if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) { - $restrictedSubtreeTags[] = $privilege->getSubtreeTag(); - } - } - return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags)); + $roles = $this->rolesOfAnonymousUser(); + return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles)); } /** @@ -120,18 +127,22 @@ public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $co public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints { $roles = $this->expandAccountRoles($account); - $restrictedSubtreeTags = []; - /** @var SubtreeTagPrivilege $privilege */ - foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) { - if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) { - $restrictedSubtreeTags[] = $privilege->getSubtreeTag(); - } - } - return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags)); + return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles)); } // ------------------------------ + /** + * @return array + */ + private function rolesOfAnonymousUser(): array + { + return [ + self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), + self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS), + ]; + } + /** * @return array */ @@ -154,9 +165,54 @@ private function expandAccountRoles(Account $account): array return $roles; } + /** + * @param array $roles + */ + private function restrictedSubtreeTagsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags + { + $restrictedSubtreeTags = SubtreeTags::createEmpty(); + /** @var ReadNodePrivilege $privilege */ + foreach ($this->policyService->getAllPrivilegesByType(ReadNodePrivilege::class) as $privilege) { + if (!$this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTags(), $contentRepositoryId))) { + $restrictedSubtreeTags = $restrictedSubtreeTags->merge($privilege->getSubtreeTags()); + } + } + return $restrictedSubtreeTags; + } + private function neosUserFromAccount(Account $account): ?User { $user = $this->partyService->getAssignedPartyOfAccount($account); return $user instanceof User ? $user : null; } + + /** + * @param array $roles + */ + private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles): NodePermissions + { + if ($node instanceof NodeAddress) { + $converted = $this->nodeForNodeAddress($node); + if ($converted === null) { + return NodePermissions::none(sprintf('Node "%s" not found in Content Repository "%s"', $node->aggregateId->value, $node->contentRepositoryId->value)); + } + $node = $converted; + } + $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId); + $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason); + $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason); + return NodePermissions::create( + read: $readGranted, + edit: $writeGranted, + reason: $readReason . "\n" . $writeReason, + ); + } + + private function nodeForNodeAddress(NodeAddress $nodeAddress): ?Node + { + return $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId) + ->getContentGraph($nodeAddress->workspaceName) + ->getSubgraph($nodeAddress->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) + ->findNodeById($nodeAddress->aggregateId); + } } diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php similarity index 78% rename from Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php rename to Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php index da4eb47357c..8aaea9f1d35 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -15,6 +15,7 @@ namespace Neos\Neos\Security\Authorization\Privilege; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; @@ -23,14 +24,15 @@ /** * TODO docs */ -class SubtreeTagPrivilege extends AbstractPrivilege +abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege { - private SubtreeTag|null $subtreeTagRuntimeCache = null; + private bool $initialized = false; + private SubtreeTags|null $subtreeTagsRuntimeCache = null; private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null; private function initialize(): void { - if ($this->subtreeTagRuntimeCache !== null) { + if ($this->initialized) { return; } $subtreeTag = $this->getParsedMatcher(); @@ -38,7 +40,8 @@ private function initialize(): void [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId); } - $this->subtreeTagRuntimeCache = SubtreeTag::fromString($subtreeTag); + $this->subtreeTagsRuntimeCache = SubtreeTags::fromStrings($subtreeTag); + $this->initialized = true; } /** @@ -57,14 +60,14 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) { return false; } - return $subject->subTreeTag->equals($this->getSubtreeTag()); + return !$this->getSubtreeTags()->intersection($subject->subTreeTags)->isEmpty(); } - public function getSubtreeTag(): SubtreeTag + public function getSubtreeTags(): SubtreeTags { $this->initialize(); - assert($this->subtreeTagRuntimeCache !== null); - return $this->subtreeTagRuntimeCache; + assert($this->subtreeTagsRuntimeCache !== null); + return $this->subtreeTagsRuntimeCache; } public function getContentRepositoryId(): ?ContentRepositoryId diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php new file mode 100644 index 00000000000..52d94c0516b --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php @@ -0,0 +1,28 @@ +subTreeTags->count() > 1 ? 's' : '') . ' "' . implode('", "', $this->subTreeTags->toStringArray()) . '"'; + if ($this->contentRepositoryId !== null) { + $label .= ' in Content Repository "' . $this->contentRepositoryId->value . '"'; + } + return $label; + } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 67ece65438a..c1fc1710a1a 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -5,10 +5,14 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; +use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; @@ -20,8 +24,11 @@ use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Security\Context as SecurityContext; +use Neos\Neos\Domain\Model\NodePermissions; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; @@ -82,6 +89,20 @@ public function canExecuteCommand(CommandInterface $command): Privilege if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); } + if ($command instanceof SetNodeProperties) { + $nodePermissions = $this->getNodePermissionsForCurrentUser( + NodeAddress::create( + $this->contentRepositoryId, + $command->workspaceName, + $command->originDimensionSpacePoint->toDimensionSpacePoint(), + $command->nodeAggregateId, + ) + ); + if (!$nodePermissions->edit) { + return Privilege::denied($nodePermissions->getReason()); + } + return $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE); + } // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the @@ -134,4 +155,13 @@ private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceN } return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); } + + private function getNodePermissionsForCurrentUser(NodeAddress $nodeAddress): NodePermissions + { + $authenticatedAccount = $this->securityContext->getAccount(); + if ($authenticatedAccount === null) { + return $this->authorizationService->getNodePermissionsForAnonymousUser($nodeAddress); + } + return $this->authorizationService->getNodePermissionsForAccount($nodeAddress, $authenticatedAccount); + } } diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index 32fed1bd759..c441d522255 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -143,7 +143,7 @@ privilegeTargets: matcher: 'administration/dimensions' - 'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege': + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': 'Neos.Neos:ContentRepository.ReadDisabledNodes': # !!! matcher payload in this case is a ContentRepository SubtreeTag, From c05e53d94a77fb997d23011b667eec901babe4b9 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Sun, 27 Oct 2024 19:01:15 +0100 Subject: [PATCH 18/58] TASK: Dont expose `$visibilityConstraints` in `SiteNodeUtility` --- .../Domain/Service/SiteNodeUtility.php | 27 +++++-------------- .../Classes/View/FusionExceptionView.php | 3 +-- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php index 262d24e4461..bbcfbee80cd 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php +++ b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php @@ -17,19 +17,15 @@ use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Repository\SiteRepository; -use Neos\Neos\Utility\NodeTypeWithFallbackProvider; #[Flow\Scope('singleton')] final class SiteNodeUtility { - use NodeTypeWithFallbackProvider; - public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry ) { @@ -44,8 +40,7 @@ public function __construct( * $siteNode = $this->siteNodeUtility->findSiteNodeBySite( * $site, * WorkspaceName::forLive(), - * DimensionSpacePoint::createWithoutDimensions(), - * VisibilityConstraints::frontend() + * DimensionSpacePoint::createWithoutDimensions() * ); * ``` * @@ -54,26 +49,18 @@ public function __construct( public function findSiteNodeBySite( Site $site, WorkspaceName $workspaceName, - DimensionSpacePoint $dimensionSpacePoint, - VisibilityConstraints $visibilityConstraints + DimensionSpacePoint $dimensionSpacePoint ): Node { $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId); - $contentGraph = $contentRepository->getContentGraph($workspaceName); - $subgraph = $contentGraph->getSubgraph( - $dimensionSpacePoint, - $visibilityConstraints, - ); + $subgraph = $contentRepository->getContentSubgraph($workspaceName, $dimensionSpacePoint); - $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType( - NodeTypeNameFactory::forSites() - ); - if (!$rootNodeAggregate) { + $rootNode = $subgraph->findRootNodeByType(NodeTypeNameFactory::forSites()); + + if (!$rootNode) { throw new \RuntimeException(sprintf('No sites root node found in content repository "%s", while fetching site node "%s"', $contentRepository->id->value, $site->getNodeName()), 1719046570); } - $rootNode = $rootNodeAggregate->getNodeByCoveredDimensionSpacePoint($dimensionSpacePoint); - $siteNode = $subgraph->findNodeByPath( $site->getNodeName()->toNodeName(), $rootNode->aggregateId @@ -83,7 +70,7 @@ public function findSiteNodeBySite( throw new \RuntimeException(sprintf('No site node found for site "%s"', $site->getNodeName()), 1697140379); } - if (!$this->getNodeType($siteNode)->isOfType(NodeTypeNameFactory::NAME_SITE)) { + if (!$contentRepository->getNodeTypeManager()->getNodeType($siteNode->nodeTypeName)?->isOfType(NodeTypeNameFactory::NAME_SITE)) { throw new \RuntimeException(sprintf( 'The site node "%s" (type: "%s") must be of type "%s"', $siteNode->aggregateId->value, diff --git a/Neos.Neos/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php index 041ec02cb67..abdd8fd7d95 100644 --- a/Neos.Neos/Classes/View/FusionExceptionView.php +++ b/Neos.Neos/Classes/View/FusionExceptionView.php @@ -122,8 +122,7 @@ public function render(): ResponseInterface|StreamInterface $currentSiteNode = $this->siteNodeUtility->findSiteNodeBySite( $site, WorkspaceName::forLive(), - $arbitraryRootDimensionSpacePoint, - VisibilityConstraints::default() + $arbitraryRootDimensionSpacePoint ); } catch (WorkspaceDoesNotExist | \RuntimeException) { return $this->renderErrorWelcomeScreen(); From 0bf97e2779edc863178ac1882a0c6328fa5a62b5 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Sun, 27 Oct 2024 19:03:04 +0100 Subject: [PATCH 19/58] TASK: Use current user visibility constraints for data source controller --- .../Service/Controller/DataSourceController.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Neos.Neos/Classes/Service/Controller/DataSourceController.php b/Neos.Neos/Classes/Service/Controller/DataSourceController.php index db29b994b7b..265d151c766 100644 --- a/Neos.Neos/Classes/Service/Controller/DataSourceController.php +++ b/Neos.Neos/Classes/Service/Controller/DataSourceController.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Service\Controller; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; @@ -68,12 +67,12 @@ public function indexAction($dataSourceIdentifier, string $node = null): void unset($arguments['dataSourceIdentifier']); unset($arguments['node']); - $values = $dataSource->getData($this->deserializeNodeFromLegacyAddress($node), $arguments); + $values = $dataSource->getData($this->deserializeNodeFromNodeAddress($node), $arguments); $this->view->assign('value', $values); } - private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAddress): ?Node + private function deserializeNodeFromNodeAddress(?string $stringFormattedNodeAddress): ?Node { if (!$stringFormattedNodeAddress) { return null; @@ -82,10 +81,8 @@ private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAd $nodeAddress = NodeAddress::fromJsonString($stringFormattedNodeAddress); $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - return $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - )->findNodeById($nodeAddress->aggregateId); + return $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint) + ->findNodeById($nodeAddress->aggregateId); } /** From 14c9f84fd0b899d84ab17dc837d356f7a5494581 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 29 Oct 2024 14:51:52 +0100 Subject: [PATCH 20/58] Introduce named `VisibilityConstraints` constructor --- .../Projection/ContentGraph/VisibilityConstraints.php | 10 +++++++++- .../ContentRepositoryAuthorizationService.php | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php index 598b9945a15..f1c4f3b93ae 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php @@ -29,11 +29,19 @@ /** * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query */ - public function __construct( + private function __construct( public SubtreeTags $tagConstraints, ) { } + /** + * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query + */ + public static function fromTagConstraints(SubtreeTags $tagConstraints): self + { + return new self($tagConstraints); + } + public function getHash(): string { return md5(implode('|', $this->tagConstraints->toStringArray())); diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index b6ebc7a8994..12bc6b5e0bb 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -118,7 +118,7 @@ public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $ac public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints { $roles = $this->rolesOfAnonymousUser(); - return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles)); + return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles)); } /** @@ -127,7 +127,7 @@ public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $co public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints { $roles = $this->expandAccountRoles($account); - return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles)); + return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles)); } // ------------------------------ @@ -168,7 +168,7 @@ private function expandAccountRoles(Account $account): array /** * @param array $roles */ - private function restrictedSubtreeTagsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags + private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags { $restrictedSubtreeTags = SubtreeTags::createEmpty(); /** @var ReadNodePrivilege $privilege */ From ac70e83fd8d9889858586526c87e7aa595c4bb2c Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 29 Oct 2024 14:53:10 +0100 Subject: [PATCH 21/58] Adjust Behat tests to refer to Visibility constraints "default" instead of "frontend" --- ...bleNodeAggregate_WithoutDimensions.feature | 2 +- ...isableNodeAggregate_WithDimensions.feature | 20 ++++++------- ...bleNodeAggregate_WithoutDimensions.feature | 6 ++-- ...EnableNodeAggregate_WithDimensions.feature | 30 +++++++++---------- ...ithDisabledAncestor_WithDimensions.feature | 2 +- ...09-CreateNodeVariantOfDisabledNode.feature | 2 +- ...WithDisabledNodesWithoutDimensions.feature | 2 +- .../AddDimensionShineThrough.feature | 6 ++-- .../Migration/MoveDimensionSpacePoint.feature | 6 ++-- .../RemoveNodeAggregateAfterDisabling.feature | 2 +- .../Bootstrap/CRTestSuiteRuntimeVariables.php | 4 +-- 11 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature index 67181a191e0..5f847014041 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature @@ -109,7 +109,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature index 356eccb0f7d..9574d8303d1 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature @@ -121,7 +121,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -149,7 +149,7 @@ Feature: Disable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -196,7 +196,7 @@ Feature: Disable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -225,7 +225,7 @@ Feature: Disable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -254,7 +254,7 @@ Feature: Disable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -372,7 +372,7 @@ Feature: Disable a node aggregate And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -400,7 +400,7 @@ Feature: Disable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -429,7 +429,7 @@ Feature: Disable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -458,7 +458,7 @@ Feature: Disable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -487,7 +487,7 @@ Feature: Disable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature index 6a77ec9a4d5..63f7fe64cf7 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature @@ -69,7 +69,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -144,7 +144,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [{}] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -219,7 +219,7 @@ Feature: Enable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature index 49c6a27f297..34ff1cdcbd0 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature @@ -144,7 +144,7 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -194,7 +194,7 @@ Feature: Enable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -224,7 +224,7 @@ Feature: Enable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -275,7 +275,7 @@ Feature: Enable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -326,7 +326,7 @@ Feature: Enable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -439,7 +439,7 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -489,7 +489,7 @@ Feature: Enable a node aggregate # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -539,7 +539,7 @@ Feature: Enable a node aggregate # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -590,7 +590,7 @@ Feature: Enable a node aggregate # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -641,7 +641,7 @@ Feature: Enable a node aggregate # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{} And I expect this node to have the following child nodes: | Name | NodeDiscriminator | @@ -717,25 +717,25 @@ Feature: Enable a node aggregate And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"} And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"} - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the generalization When I am in dimension space point {"language":"mul"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the virtual specialization When I am in dimension space point {"language":"gsw"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the real specialization When I am in dimension space point {"language":"ltz"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node # Tests for the peer variant When I am in dimension space point {"language":"en"} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature index 0ae1ae2cdae..c3b0ef4813e 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature @@ -35,7 +35,7 @@ Feature: Creation of nodes underneath disabled nodes | nodeAggregateId | "the-great-nodini" | | sourceOrigin | {"language":"mul"} | | targetOrigin | {"language":"ltz"} | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Scenario: Create a new node with parent disabled with strategy allSpecializations Given the command DisableNodeAggregate is executed with payload: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature index be26f2395e0..be6c01a921c 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature @@ -24,7 +24,7 @@ Feature: Variation of hidden nodes | Key | Value | | nodeAggregateId | "lady-eleonode-rootford" | | nodeTypeName | "Neos.ContentRepository:Root" | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Scenario: Specialize a node where the specialization target is enabled Given I am in dimension space point {"language":"de"} diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature index 780ef1d28ad..de97506aa49 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature @@ -75,7 +75,7 @@ Feature: On forking a content stream, hidden nodes should be correctly copied as | 1 | the-great-nodini | | 2 | nodingers-cat | - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node user-cs-identifier;lady-eleonode-rootford;{} And I expect this node to have no child nodes And the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be: diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature index 3f6180cc362..8387081b091 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature @@ -142,7 +142,7 @@ Feature: Add Dimension Specialization Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # we change the dimension configuration When I change the content dimensions in content repository "default" to: @@ -166,14 +166,14 @@ Feature: Add Dimension Specialization Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # The visibility edges were modified When I am in workspace "migration-workspace" and dimension space point {"language": "ch"} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" When I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 0 errors diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature index f9a70788f8b..53dfbb58e86 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature @@ -94,7 +94,7 @@ Feature: Move dimension space point Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # we change the dimension configuration When I change the content dimensions in content repository "default" to: @@ -118,14 +118,14 @@ Feature: Move dimension space point Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" # The visibility edges were modified When I am in workspace "migration-workspace" and dimension space point {"language": "de_DE"} Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node When VisibilityConstraints are set to "withoutRestrictions" Then I expect a node identified by migration-cs;sir-david-nodenborough;{"language": "de_DE"} to exist in the content graph - When VisibilityConstraints are set to "frontend" + When VisibilityConstraints are set to "default" When I run integrity violation detection Then I expect the integrity violation detection result to contain exactly 0 errors diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature index d583afa77fa..a6d60d05c84 100644 --- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature +++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature @@ -71,7 +71,7 @@ Feature: Disable a node aggregate And I expect this node aggregate to disable dimension space points [] When I am in workspace "live" and dimension space point {} - And VisibilityConstraints are set to "frontend" + And VisibilityConstraints are set to "default" Then the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be: | Level | nodeAggregateId | | 0 | lady-eleonode-rootford | diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php index 31a28356a93..b6ca95a29b6 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php @@ -111,13 +111,13 @@ public function iAmInWorkspaceAndDimensionSpacePoint(string $workspaceName, stri } /** - * @When /^VisibilityConstraints are set to "(withoutRestrictions|frontend)"$/ + * @When /^VisibilityConstraints are set to "(withoutRestrictions|default)"$/ */ public function visibilityConstraintsAreSetTo(string $restrictionType): void { $this->currentVisibilityConstraints = match ($restrictionType) { 'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(), - 'frontend' => VisibilityConstraints::default(), + 'default' => VisibilityConstraints::default(), default => throw new \InvalidArgumentException('Visibility constraint "' . $restrictionType . '" not supported.'), }; } From abca5bbce0c375c1e4a3400dd97b087a568e7233 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 29 Oct 2024 15:11:53 +0100 Subject: [PATCH 22/58] Mark `NodePermissions` and `WorkspacePermissions` internal --- Neos.Neos/Classes/Domain/Model/NodePermissions.php | 2 +- Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php index 760300720b2..5a2ff8733a8 100644 --- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -13,7 +13,7 @@ * - read: Permission to read the node and its properties and references * - edit: Permission to change the node * - * @api + * @internal */ #[Flow\Proxy(false)] final readonly class NodePermissions diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index cdb2461f6bb..421ed38c2cb 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -14,7 +14,7 @@ * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) * - * @api + * @internal */ #[Flow\Proxy(false)] final readonly class WorkspacePermissions From 1a4629701eab78ec9a7c58da5863e65479f16ae4 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 29 Oct 2024 15:12:22 +0100 Subject: [PATCH 23/58] Deduplicate workspace role specificity determination --- Neos.Neos/Classes/Domain/Model/WorkspaceRole.php | 2 +- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php index a36269e22b8..0b487fb03f9 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -32,7 +32,7 @@ public function isAtLeast(self $role): bool return $this->specificity() >= $role->specificity(); } - private function specificity(): int + public function specificity(): int { return match ($this) { self::VIEWER => 1, diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 6cc3ecfe4ac..3f7523c94e1 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -277,6 +277,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole { $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; + $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases())); $query = << Date: Wed, 30 Oct 2024 12:56:25 +0100 Subject: [PATCH 24/58] Tweak resolution of current node in `ContentRepositoryAuthProvider` --- .../ContentRepositoryAuthorizationService.php | 24 ++--------- .../ContentRepositoryAuthProvider.php | 41 +++++++++++-------- .../ContentRepositoryAuthProviderFactory.php | 4 +- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 12bc6b5e0bb..2f6cc32cf1f 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -8,9 +8,7 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Account; use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; @@ -44,7 +42,6 @@ public function __construct( private PartyService $partyService, private WorkspaceService $workspaceService, - private ContentRepositoryRegistry $contentRepositoryRegistry, private PolicyService $policyService, private PrivilegeManagerInterface $privilegeManager, ) { @@ -100,13 +97,13 @@ public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRe ); } - public function getNodePermissionsForAnonymousUser(Node|NodeAddress $node): NodePermissions + public function getNodePermissionsForAnonymousUser(Node $node): NodePermissions { $roles = $this->rolesOfAnonymousUser(); return $this->nodePermissionsForRoles($node, $roles); } - public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $account): NodePermissions + public function getNodePermissionsForAccount(Node $node, Account $account): NodePermissions { $roles = $this->expandAccountRoles($account); return $this->nodePermissionsForRoles($node, $roles); @@ -189,15 +186,8 @@ private function neosUserFromAccount(Account $account): ?User /** * @param array $roles */ - private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles): NodePermissions + private function nodePermissionsForRoles(Node $node, array $roles): NodePermissions { - if ($node instanceof NodeAddress) { - $converted = $this->nodeForNodeAddress($node); - if ($converted === null) { - return NodePermissions::none(sprintf('Node "%s" not found in Content Repository "%s"', $node->aggregateId->value, $node->contentRepositoryId->value)); - } - $node = $converted; - } $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId); $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason); $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason); @@ -207,12 +197,4 @@ private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles): reason: $readReason . "\n" . $writeReason, ); } - - private function nodeForNodeAddress(NodeAddress $nodeAddress): ?Node - { - return $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId) - ->getContentGraph($nodeAddress->workspaceName) - ->getSubgraph($nodeAddress->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) - ->findNodeById($nodeAddress->aggregateId); - } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 75e1bd1ad3e..fc624112a4f 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -6,6 +6,7 @@ use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; @@ -22,9 +23,11 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; +use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Security\Context as SecurityContext; @@ -39,16 +42,17 @@ * * @internal use {@see ContentRepositoryAuthorizationService} to ask for specific authorization decisions */ -final class ContentRepositoryAuthProvider implements AuthProviderInterface +final readonly class ContentRepositoryAuthProvider implements AuthProviderInterface { private const WORKSPACE_PERMISSION_WRITE = 'write'; private const WORKSPACE_PERMISSION_MANAGE = 'manage'; public function __construct( - private readonly ContentRepositoryId $contentRepositoryId, - private readonly UserService $userService, - private readonly ContentRepositoryAuthorizationService $authorizationService, - private readonly SecurityContext $securityContext, + private ContentRepositoryId $contentRepositoryId, + private UserService $userService, + private ContentRepositoryRegistry $contentRepositoryRegistry, + private ContentRepositoryAuthorizationService $authorizationService, + private SecurityContext $securityContext, ) { } @@ -90,14 +94,11 @@ public function canExecuteCommand(CommandInterface $command): Privilege return Privilege::granted('Authorization checks are disabled'); } if ($command instanceof SetNodeProperties) { - $nodePermissions = $this->getNodePermissionsForCurrentUser( - NodeAddress::create( - $this->contentRepositoryId, - $command->workspaceName, - $command->originDimensionSpacePoint->toDimensionSpacePoint(), - $command->nodeAggregateId, - ) - ); + $node = $this->getNode($command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId); + if ($node === null) { + return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $command->nodeAggregateId->value, $command->workspaceName->value)); + } + $nodePermissions = $this->getNodePermissionsForCurrentUser($node); if (!$nodePermissions->edit) { return Privilege::denied($nodePermissions->getReason()); } @@ -156,12 +157,20 @@ private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceN return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); } - private function getNodePermissionsForCurrentUser(NodeAddress $nodeAddress): NodePermissions + private function getNodePermissionsForCurrentUser(Node $node): NodePermissions { $authenticatedAccount = $this->securityContext->getAccount(); if ($authenticatedAccount === null) { - return $this->authorizationService->getNodePermissionsForAnonymousUser($nodeAddress); + return $this->authorizationService->getNodePermissionsForAnonymousUser($node); } - return $this->authorizationService->getNodePermissionsForAccount($nodeAddress, $authenticatedAccount); + return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount); + } + + private function getNode(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint, NodeAggregateId $nodeAggregateId): ?Node + { + return $this->contentRepositoryRegistry->get($this->contentRepositoryId) + ->getContentGraph($workspaceName) + ->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) + ->findNodeById($nodeAggregateId); } } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index 54bfc97294c..54db08a2c36 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -5,6 +5,7 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; @@ -21,6 +22,7 @@ { public function __construct( private UserService $userService, + private ContentRepositoryRegistry $contentRepositoryRegistry, private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService, private SecurityContext $securityContext, ) { @@ -31,6 +33,6 @@ public function __construct( */ public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider { - return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryAuthorizationService, $this->securityContext); + return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryRegistry, $this->contentRepositoryAuthorizationService, $this->securityContext); } } From 7311ca9b35ff729f4c79c0fd33949fef74e4051d Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 30 Oct 2024 13:43:44 +0100 Subject: [PATCH 25/58] Revert `RebasableToOtherWorkspaceInterface` extension and check against concrete command classes --- .../RebasableToOtherWorkspaceInterface.php | 2 - .../Command/AddDimensionShineThrough.php | 5 - .../Command/MoveDimensionSpacePoint.php | 5 - ...gregateWithNodeAndSerializedProperties.php | 5 - .../Command/DisableNodeAggregate.php | 5 - .../Command/EnableNodeAggregate.php | 5 - .../Command/CopyNodesRecursively.php | 5 - .../Command/SetSerializedNodeProperties.php | 5 - .../NodeMove/Command/MoveNodeAggregate.php | 5 - .../Command/SetSerializedNodeReferences.php | 5 - .../Command/RemoveNodeAggregate.php | 5 - .../Command/ChangeNodeAggregateName.php | 5 - .../Command/ChangeNodeAggregateType.php | 5 - .../Command/CreateNodeVariant.php | 5 - .../CreateRootNodeAggregateWithNode.php | 5 - .../UpdateRootNodeAggregateDimensions.php | 5 - .../SubtreeTagging/Command/TagSubtree.php | 5 - .../SubtreeTagging/Command/UntagSubtree.php | 5 - .../ContentRepositoryAuthProvider.php | 91 +++++++++++++------ 19 files changed, 61 insertions(+), 117 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php index 84c0540e372..c7b3d5cbc59 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php @@ -31,8 +31,6 @@ public function createCopyForWorkspace( WorkspaceName $targetWorkspaceName, ): self; - public function getWorkspaceName(): WorkspaceName; - /** * called during deserialization from metadata * @param array $array diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php index e3143635b15..936505d278d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php @@ -84,11 +84,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } - /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php index e6ae20eb2b5..498ba261e49 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php +++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php @@ -80,11 +80,6 @@ public function createCopyForWorkspace( ); } - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } - /** * @return array */ diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php index fa3cac5be87..79f5412eaa5 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php @@ -176,9 +176,4 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php index bb153eb5f7e..f92f938df9c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php @@ -99,9 +99,4 @@ public function createCopyForWorkspace( $this->nodeVariantSelectionStrategy ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php index 41d70615b29..de0ad11d57d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php @@ -99,9 +99,4 @@ public function createCopyForWorkspace( $this->nodeVariantSelectionStrategy ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php index 2f9be09c884..ba31a07c601 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php @@ -183,9 +183,4 @@ public function createCopyForWorkspace( $this->nodeAggregateIdMapping ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php index 666b2fd2faa..a2d684f6035 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php @@ -119,9 +119,4 @@ public function createCopyForWorkspace( $this->propertiesToUnset, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php index ca2fe213734..3a756163488 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php @@ -131,9 +131,4 @@ public function createCopyForWorkspace( $this->newSucceedingSiblingNodeAggregateId ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php index 3b20699994a..1edf8409040 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php @@ -107,9 +107,4 @@ public function createCopyForWorkspace( $this->references, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php index 654edc63dd5..085af255b8c 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php @@ -119,9 +119,4 @@ public function createCopyForWorkspace( $this->removalAttachmentPoint, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php index 5df67f01181..38d1f195ed6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php @@ -93,9 +93,4 @@ public function createCopyForWorkspace( $this->newNodeName, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php index ede35595343..e979e4d25c1 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php @@ -118,9 +118,4 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php index 0aa0f248109..54873f489dc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php +++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php @@ -97,9 +97,4 @@ public function createCopyForWorkspace( $this->targetOrigin, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php index b2fe51dc5c6..9bbb1f320cc 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php @@ -135,9 +135,4 @@ public function createCopyForWorkspace( $this->tetheredDescendantNodeAggregateIds ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php index 489c0d96ca5..302c05ed895 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php +++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php @@ -78,9 +78,4 @@ public function createCopyForWorkspace( $this->nodeAggregateId, ); } - - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } } diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php index b77d199e155..d4d37c8b8cb 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php @@ -89,11 +89,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } - public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php index 7c2009b55d5..1ae9b4624a2 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php @@ -90,11 +90,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self ); } - public function getWorkspaceName(): WorkspaceName - { - return $this->workspaceName; - } - public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool { return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index fc624112a4f..c12a78ba41d 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -5,15 +5,31 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; +use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; +use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; +use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; +use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; +use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; +use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; +use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; +use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; +use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; +use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode; +use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions; use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege; use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; @@ -93,48 +109,71 @@ public function canExecuteCommand(CommandInterface $command): Privilege if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); } - if ($command instanceof SetNodeProperties) { - $node = $this->getNode($command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId); + + /** @var NodeAddress|null $nodeThatRequiresEditPrivilege */ + $nodeThatRequiresEditPrivilege = match ($command::class) { + CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId), + CreateNodeAggregateWithNode::class, + CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId), + CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId), + DisableNodeAggregate::class, + EnableNodeAggregate::class, + RemoveNodeAggregate::class, + TagSubtree::class, + UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId), + MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId), + SetNodeProperties::class, + SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId), + SetNodeReferences::class, + SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId), + default => null, + }; + if ($nodeThatRequiresEditPrivilege !== null) { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName); + if ($workspacePermissions->write) { + return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason())); + } + $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId) + ->getContentGraph($nodeThatRequiresEditPrivilege->workspaceName) + ->getSubgraph($nodeThatRequiresEditPrivilege->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) + ->findNodeById($nodeThatRequiresEditPrivilege->aggregateId); if ($node === null) { - return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $command->nodeAggregateId->value, $command->workspaceName->value)); + return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value)); } $nodePermissions = $this->getNodePermissionsForCurrentUser($node); if (!$nodePermissions->edit) { - return Privilege::denied($nodePermissions->getReason()); + return Privilege::denied(sprintf('No edit permissions for node "%s" in workspace "%s": %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); } - return $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE); + return Privilege::granted(sprintf('Edit permissions for node "%s" in workspace "%s" granted: %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); } - - // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all - // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the - // code if we were to add new commands in the future - if ($command instanceof RebasableToOtherWorkspaceInterface) { - return $this->requireWorkspacePermission($command->getWorkspaceName(), self::WORKSPACE_PERMISSION_WRITE); // @phpstan-ignore-line - } - if ($command instanceof CreateRootWorkspace) { return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks'); } - if ($command instanceof ChangeBaseWorkspace) { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName); if (!$workspacePermissions->manage) { - return Privilege::denied("Missing 'manage' permissions for workspace '{$command->workspaceName->value}': {$workspacePermissions->getReason()}"); + return Privilege::denied(sprintf('Missing "manage" permissions for workspace "%s": %s', $command->workspaceName->value, $workspacePermissions->getReason())); } $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName); - if (!$baseWorkspacePermissions->write) { - return Privilege::denied("Missing 'write' permissions for base workspace '{$command->baseWorkspaceName->value}': {$baseWorkspacePermissions->getReason()}"); + if (!$baseWorkspacePermissions->read) { + return Privilege::denied(sprintf('Missing "read" permissions for base workspace "%s": %s', $command->baseWorkspaceName->value, $baseWorkspacePermissions->getReason())); } - return Privilege::granted("User has 'manage' permissions for workspace '{$command->workspaceName->value}' and 'write' permissions for base workspace '{$command->baseWorkspaceName->value}'"); + return Privilege::granted(sprintf('User has "manage" permissions for workspace "%s" and "read" permissions for base workspace "%s"', $command->workspaceName->value, $command->baseWorkspaceName->value)); } return match ($command::class) { - CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE), - DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE), + AddDimensionShineThrough::class, + ChangeNodeAggregateName::class, + ChangeNodeAggregateType::class, + CreateRootNodeAggregateWithNode::class, + MoveDimensionSpacePoint::class, + UpdateRootNodeAggregateDimensions::class, DiscardWorkspace::class, DiscardIndividualNodesFromWorkspace::class, PublishWorkspace::class, PublishIndividualNodesFromWorkspace::class, RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE), + CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE), + DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE), default => Privilege::granted('Command not restricted'), }; } @@ -165,12 +204,4 @@ private function getNodePermissionsForCurrentUser(Node $node): NodePermissions } return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount); } - - private function getNode(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint, NodeAggregateId $nodeAggregateId): ?Node - { - return $this->contentRepositoryRegistry->get($this->contentRepositoryId) - ->getContentGraph($workspaceName) - ->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) - ->findNodeById($nodeAggregateId); - } } From 5d17a70946937f35a9b1358f551db070f92b59e2 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 30 Oct 2024 16:46:54 +0100 Subject: [PATCH 26/58] Fix workspace permissions check --- .../ContentRepositoryAuthProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index c12a78ba41d..d2ed098a3ed 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -130,7 +130,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege }; if ($nodeThatRequiresEditPrivilege !== null) { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName); - if ($workspacePermissions->write) { + if (!$workspacePermissions->write) { return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason())); } $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId) From 3c6ab69eee4599b1e985ee8c966adbcd237485da Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 5 Nov 2024 14:37:11 +0100 Subject: [PATCH 27/58] Remove `WorkspaceRoleSubject::__toString()` because it led to [debates](https://github.com/neos/neos-development-collection/pull/5298#discussion_r1818140741) --- Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php index c9255bcfe5f..c53388bb23c 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -50,9 +50,4 @@ public function equals(self $other): bool { return $this->type === $other->type && $this->value === $other->value; } - - public function __toString(): string - { - return "{$this->type->value}: {$this->value}"; - } } From 0571abf6fec480ef08ae9352cf1875c8dfe3f91e Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 5 Nov 2024 14:55:56 +0100 Subject: [PATCH 28/58] Split `ContentRepositoryAuthProvider::requireWorkspaceWritePermission()` into two methods --- .../ContentRepositoryAuthProvider.php | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index d2ed098a3ed..147a6136f9a 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -60,9 +60,6 @@ */ final readonly class ContentRepositoryAuthProvider implements AuthProviderInterface { - private const WORKSPACE_PERMISSION_WRITE = 'write'; - private const WORKSPACE_PERMISSION_MANAGE = 'manage'; - public function __construct( private ContentRepositoryId $contentRepositoryId, private UserService $userService, @@ -171,20 +168,29 @@ public function canExecuteCommand(CommandInterface $command): Privilege DiscardIndividualNodesFromWorkspace::class, PublishWorkspace::class, PublishIndividualNodesFromWorkspace::class, - RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE), - CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE), - DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE), + RebaseWorkspace::class => $this->requireWorkspaceWritePermission($command->workspaceName), + CreateWorkspace::class => $this->requireWorkspaceWritePermission($command->baseWorkspaceName), + DeleteWorkspace::class => $this->requireWorkspaceManagePermission($command->workspaceName), default => Privilege::granted('Command not restricted'), }; } - private function requireWorkspacePermission(WorkspaceName $workspaceName, string $permission): Privilege + private function requireWorkspaceWritePermission(WorkspaceName $workspaceName): Privilege + { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); + if (!$workspacePermissions->write) { + return Privilege::denied("Missing 'write' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + } + return Privilege::granted("User has 'write' permissions for workspace '{$workspaceName->value}'"); + } + + private function requireWorkspaceManagePermission(WorkspaceName $workspaceName): Privilege { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); - if (!$workspacePermissions->{$permission}) { - return Privilege::denied("Missing '{$permission}' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + if (!$workspacePermissions->manage) { + return Privilege::denied("Missing 'manage' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); } - return Privilege::granted("User has '{$permission}' permissions for workspace '{$workspaceName->value}'"); + return Privilege::granted("User has 'manage' permissions for workspace '{$workspaceName->value}'"); } private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions From b4e0c7829b61bc962986a898fcc10d929d0632a6 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 5 Nov 2024 17:58:22 +0100 Subject: [PATCH 29/58] Tweak AuthProvider wiring --- .../TestSuite/Behavior/FakeAuthProviderFactory.php | 6 ++---- .../Classes/Factory/ContentRepositoryFactory.php | 7 ++++--- .../Classes/Factory/ProjectionFactoryDependencies.php | 2 -- .../Classes/ContentRepositoryRegistry.php | 6 +++--- .../AuthProvider/AuthProviderFactoryInterface.php | 4 ++-- .../AuthProvider/StaticAuthProviderFactory.php | 4 ++-- .../ContentRepositoryAuthProvider.php | 9 +++------ .../ContentRepositoryAuthProviderFactory.php | 11 ++++------- 8 files changed, 20 insertions(+), 29 deletions(-) diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php index f018a5ac958..f239fb410d7 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php @@ -5,16 +5,14 @@ namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior; use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; final class FakeAuthProviderFactory implements AuthProviderFactoryInterface { - /** - * @param array $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface + public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface { return new FakeAuthProvider(); } diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index a55d94fe826..8517f51c1c3 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -32,6 +32,7 @@ use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\EventStore\EventStoreInterface; use Psr\Clock\ClockInterface; use Symfony\Component\Serializer\Serializer; @@ -53,7 +54,7 @@ public function __construct( ContentDimensionSourceInterface $contentDimensionSource, Serializer $propertySerializer, ProjectionsAndCatchUpHooksFactory $projectionsAndCatchUpHooksFactory, - private readonly AuthProviderInterface $authProvider, + private readonly AuthProviderFactoryInterface $authProviderFactory, private readonly ClockInterface $clock, ) { $contentDimensionZookeeper = new ContentDimensionZookeeper($contentDimensionSource); @@ -70,7 +71,6 @@ public function __construct( $contentDimensionZookeeper, $interDimensionalVariationGraph, new PropertyConverter($propertySerializer), - $this->authProvider, ); $this->projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies); } @@ -128,6 +128,7 @@ public function getOrBuild(): ContentRepository ) ); + $authProvider = $this->authProviderFactory->build($this->contentRepositoryId, $contentGraphReadModel); return $this->contentRepository = new ContentRepository( $this->contentRepositoryId, $publicCommandBus, @@ -138,7 +139,7 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->nodeTypeManager, $this->projectionFactoryDependencies->interDimensionalVariationGraph, $this->projectionFactoryDependencies->contentDimensionSource, - $this->authProvider, + $authProvider, $this->clock, $contentGraphReadModel ); diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php index 0b78e572cc4..9bb2f0cc31f 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php @@ -18,7 +18,6 @@ use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph; use Neos\ContentRepository\Core\EventStore\EventNormalizer; -use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; @@ -38,7 +37,6 @@ public function __construct( public ContentDimensionZookeeper $contentDimensionZookeeper, public InterDimensionalVariationGraph $interDimensionalVariationGraph, public PropertyConverter $propertyConverter, - public AuthProviderInterface $authProvider, ) { } } diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index b5e952f4270..75b0b2ca151 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -175,7 +175,7 @@ private function buildFactory(ContentRepositoryId $contentRepositoryId): Content $this->buildContentDimensionSource($contentRepositoryId, $contentRepositorySettings), $this->buildPropertySerializer($contentRepositoryId, $contentRepositorySettings), $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings), - $this->buildAuthProvider($contentRepositoryId, $contentRepositorySettings), + $this->buildAuthProviderFactory($contentRepositoryId, $contentRepositorySettings), $clock ); } catch (\Exception $exception) { @@ -293,14 +293,14 @@ private function registerCatchupHookForProjection(mixed $projectionOptions, Proj } /** @param array $contentRepositorySettings */ - private function buildAuthProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderInterface + private function buildAuthProviderFactory(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderFactoryInterface { isset($contentRepositorySettings['authProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have authProvider.factoryObjectName configured.', $contentRepositoryId->value); $authProviderFactory = $this->objectManager->get($contentRepositorySettings['authProvider']['factoryObjectName']); if (!$authProviderFactory instanceof AuthProviderFactoryInterface) { throw InvalidConfigurationException::fromMessage('authProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, AuthProviderFactoryInterface::class, get_debug_type($authProviderFactory)); } - return $authProviderFactory->build($contentRepositoryId, $contentRepositorySettings['authProvider']['options'] ?? []); + return $authProviderFactory; } /** @param array $contentRepositorySettings */ diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php index 809efdf65db..5b8593b7954 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php @@ -5,6 +5,7 @@ namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider; use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; /** @@ -12,6 +13,5 @@ */ interface AuthProviderFactoryInterface { - /** @param array $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface; + public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface; } diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php index 870f2e5ace2..e8ff314c9a2 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php @@ -5,6 +5,7 @@ use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\ContentRepository\Core\Feature\Security\StaticAuthProvider; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; /** @@ -12,8 +13,7 @@ */ final class StaticAuthProviderFactory implements AuthProviderFactoryInterface { - /** @param array $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface + public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface { return new StaticAuthProvider(UserId::forSystemUser()); } diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 147a6136f9a..c4bae9036a4 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -5,8 +5,6 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; use Neos\ContentRepository\Core\CommandHandler\CommandInterface; -use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; -use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; @@ -39,13 +37,12 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\NodePermissions; use Neos\Neos\Domain\Model\WorkspacePermissions; @@ -63,7 +60,7 @@ public function __construct( private ContentRepositoryId $contentRepositoryId, private UserService $userService, - private ContentRepositoryRegistry $contentRepositoryRegistry, + private ContentGraphReadModelInterface $contentGraphReadModel, private ContentRepositoryAuthorizationService $authorizationService, private SecurityContext $securityContext, ) { @@ -130,7 +127,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege if (!$workspacePermissions->write) { return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason())); } - $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId) + $node = $this->contentGraphReadModel ->getContentGraph($nodeThatRequiresEditPrivilege->workspaceName) ->getSubgraph($nodeThatRequiresEditPrivilege->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()) ->findNodeById($nodeThatRequiresEditPrivilege->aggregateId); diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index 54db08a2c36..cf3d2fb9ac5 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -4,8 +4,9 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; @@ -22,17 +23,13 @@ { public function __construct( private UserService $userService, - private ContentRepositoryRegistry $contentRepositoryRegistry, private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService, private SecurityContext $securityContext, ) { } - /** - * @param array $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider + public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): ContentRepositoryAuthProvider { - return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryRegistry, $this->contentRepositoryAuthorizationService, $this->securityContext); + return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $contentGraphReadModel, $this->contentRepositoryAuthorizationService, $this->securityContext); } } From 61848a2202cc2a83606230f288c1dab12718c700 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 11:19:42 +0100 Subject: [PATCH 30/58] Make `Privilege::reason` private to be in sync with `WorkspacePermissions` --- Neos.ContentRepository.Core/Classes/ContentRepository.php | 4 ++-- .../Classes/Feature/Security/Dto/Privilege.php | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php index 90005f03169..3d584e056b7 100644 --- a/Neos.ContentRepository.Core/Classes/ContentRepository.php +++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php @@ -101,7 +101,7 @@ public function handle(CommandInterface $command): void { $privilege = $this->authProvider->canExecuteCommand($command); if (!$privilege->granted) { - throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason); + throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->getReason()); } // the commands only calculate which events they want to have published, but do not do the // publishing themselves @@ -250,7 +250,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter { $privilege = $this->authProvider->canReadNodesFromWorkspace($workspaceName); if (!$privilege->granted) { - throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason); + throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->getReason()); } return $this->contentGraphReadModel->getContentGraph($workspaceName); } diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php index 0fcef1552fe..6712ab9279f 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php @@ -22,7 +22,7 @@ { private function __construct( public bool $granted, - public string $reason, + private string $reason, ) { } @@ -35,4 +35,9 @@ public static function denied(string $reason): self { return new self(false, $reason); } + + public function getReason(): string + { + return $this->reason; + } } From 643ce497df50c1f37ab711c35f81442639579c80 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 11:21:46 +0100 Subject: [PATCH 31/58] Remove `SubtreeTagPrivilegeSubject::__toString()` --- .../Privilege/SubtreeTagPrivilegeSubject.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php index fb78ee75883..2a4c9eeb247 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php @@ -14,7 +14,6 @@ namespace Neos\Neos\Security\Authorization\Privilege; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; @@ -29,13 +28,4 @@ public function __construct( public ContentRepositoryId|null $contentRepositoryId = null, ) { } - - public function __toString(): string - { - $label = 'tag' . ($this->subTreeTags->count() > 1 ? 's' : '') . ' "' . implode('", "', $this->subTreeTags->toStringArray()) . '"'; - if ($this->contentRepositoryId !== null) { - $label .= ' in Content Repository "' . $this->contentRepositoryId->value . '"'; - } - return $label; - } } From da297ece279536aa9911711d55ffdfce2034c400 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 11:25:48 +0100 Subject: [PATCH 32/58] Replace PHP assert by phpstan assert in `AbstractSubtreeTagBasedPrivilege` --- .../Privilege/AbstractSubtreeTagBasedPrivilege.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php index 8aaea9f1d35..ee5e41f0f66 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -30,6 +30,7 @@ abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege private SubtreeTags|null $subtreeTagsRuntimeCache = null; private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null; + /** @phpstan-assert !null $this->subtreeTagsRuntimeCache */ private function initialize(): void { if ($this->initialized) { @@ -66,7 +67,6 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool public function getSubtreeTags(): SubtreeTags { $this->initialize(); - assert($this->subtreeTagsRuntimeCache !== null); return $this->subtreeTagsRuntimeCache; } From 187b80ea94840e5e75cc5162c22f6cd50738ec20 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 11:31:39 +0100 Subject: [PATCH 33/58] Add inline docs --- .../Privilege/AbstractSubtreeTagBasedPrivilege.php | 4 ++-- .../Authorization/Privilege/EditNodePrivilege.php | 9 ++------- .../Authorization/Privilege/ReadNodePrivilege.php | 9 ++------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php index ee5e41f0f66..e42051d4bc9 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -14,7 +14,6 @@ namespace Neos\Neos\Security\Authorization\Privilege; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; @@ -22,7 +21,8 @@ use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException; /** - * TODO docs + * Common base class for privileges that evaluate {@see SubtreeTagPrivilegeSubject}s + * @see ReadNodePrivilege, EditNodePrivilege */ abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege { diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php index 52d94c0516b..83c9f53b88a 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php @@ -14,14 +14,9 @@ namespace Neos\Neos\Security\Authorization\Privilege; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; -use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; -use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; -use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException; - /** - * TODO docs + * The privilege to edit any matching node in the Content Repository. + * This includes creation, setting properties or references, disabling/enabling, tagging and moving corresponding nodes */ class EditNodePrivilege extends AbstractSubtreeTagBasedPrivilege { diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php index f96f1a0ddaa..dfbe9f138f3 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php @@ -14,14 +14,9 @@ namespace Neos\Neos\Security\Authorization\Privilege; -use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; -use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; -use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface; -use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException; - /** - * TODO docs + * The privilege to read any matching node from the Content Repository. + * This includes all properties, references and metadata */ class ReadNodePrivilege extends AbstractSubtreeTagBasedPrivilege { From 41e6b57ba8917ef394e97e4ab511db37453032d6 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 11:37:18 +0100 Subject: [PATCH 34/58] Extract `ContentRepositoryAuthProvider::nodeThatRequiresEditPrivilegeForCommand()` --- .../ContentRepositoryAuthProvider.php | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index c4bae9036a4..95ff0a58ece 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -48,6 +48,7 @@ use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; +use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege; /** * Implementation of Content Repository {@see AuthProviderInterface} which ties the authorization @@ -103,25 +104,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); } - - /** @var NodeAddress|null $nodeThatRequiresEditPrivilege */ - $nodeThatRequiresEditPrivilege = match ($command::class) { - CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId), - CreateNodeAggregateWithNode::class, - CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId), - CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId), - DisableNodeAggregate::class, - EnableNodeAggregate::class, - RemoveNodeAggregate::class, - TagSubtree::class, - UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId), - MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId), - SetNodeProperties::class, - SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId), - SetNodeReferences::class, - SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId), - default => null, - }; + $nodeThatRequiresEditPrivilege = $this->nodeThatRequiresEditPrivilegeForCommand($command); if ($nodeThatRequiresEditPrivilege !== null) { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName); if (!$workspacePermissions->write) { @@ -172,6 +155,30 @@ public function canExecuteCommand(CommandInterface $command): Privilege }; } + /** + * For a given command, determine the node (represented as {@see NodeAddress}) that needs {@see EditNodePrivilege} to be granted + */ + private function nodeThatRequiresEditPrivilegeForCommand(CommandInterface $command): ?NodeAddress + { + return match ($command::class) { + CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId), + CreateNodeAggregateWithNode::class, + CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId), + CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId), + DisableNodeAggregate::class, + EnableNodeAggregate::class, + RemoveNodeAggregate::class, + TagSubtree::class, + UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId), + MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId), + SetNodeProperties::class, + SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId), + SetNodeReferences::class, + SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId), + default => null, + }; + } + private function requireWorkspaceWritePermission(WorkspaceName $workspaceName): Privilege { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName); From 7c64c2c6c3f9c4a12cb1104ddd8f74645bfd5115 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 6 Nov 2024 16:50:45 +0100 Subject: [PATCH 35/58] TASK: Tests for "Content Repository Privileges" Related: #3732 Related: #5298 --- ...ory.php => TestingAuthProviderFactory.php} | 9 +- .../Settings.ContentRepositoryRegistry.yaml | 2 +- .../Bootstrap/CRTestSuiteRuntimeVariables.php | 4 +- .../Bootstrap/Helpers/FakeAuthProvider.php | 42 ---- .../Bootstrap/Helpers/TestingAuthProvider.php | 70 ++++++ .../ContentRepositorySecurityTrait.php | 206 ++++++++++++++++++ .../Features/Bootstrap/ExceptionsTrait.php | 9 + .../Features/Bootstrap/FeatureContext.php | 1 + .../Features/Bootstrap/UserServiceTrait.php | 2 + .../ContentRepository/Security.feature | 75 +++++++ .../Security/NodeTreePrivilege.__feature | 148 ------------- 11 files changed, 370 insertions(+), 198 deletions(-) rename Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/{FakeAuthProviderFactory.php => TestingAuthProviderFactory.php} (63%) delete mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php create mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/TestingAuthProvider.php create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature delete mode 100644 Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php similarity index 63% rename from Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php rename to Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php index f239fb410d7..0fb820d5f03 100644 --- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php +++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php @@ -4,16 +4,15 @@ namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior; -use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; +use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; -final class FakeAuthProviderFactory implements AuthProviderFactoryInterface +final class TestingAuthProviderFactory implements AuthProviderFactoryInterface { - public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface + public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): TestingAuthProvider { - return new FakeAuthProvider(); + return new TestingAuthProvider(); } } diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml index 74387df65ff..c7bd8303a73 100644 --- a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml +++ b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml @@ -3,7 +3,7 @@ Neos: presets: default: authProvider: - factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeAuthProviderFactory' + factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\TestingAuthProviderFactory' clock: factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeClockFactory' nodeTypeManager: diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php index b6ca95a29b6..d8ff69457b2 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php @@ -26,7 +26,7 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider; +use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock; /** @@ -73,7 +73,7 @@ abstract protected function getContentRepository(ContentRepositoryId $id): Conte */ public function iAmUserIdentifiedBy(string $userId): void { - FakeAuthProvider::setUserId(UserId::fromString($userId)); + TestingAuthProvider::setDefaultUserId(UserId::fromString($userId)); } /** diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php deleted file mode 100644 index 916e98ffee3..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php +++ /dev/null @@ -1,42 +0,0 @@ -getAuthenticatedUserId(); + } + return self::$userId ?? null; + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->getVisibilityConstraints($workspaceName); + } + return VisibilityConstraints::withoutRestrictions(); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->canReadNodesFromWorkspace($workspaceName); + } + return Privilege::granted(self::class . ' always grants privileges'); + } + + public function canExecuteCommand(CommandInterface $command): Privilege + { + if (self::$contentRepositoryAuthProvider !== null) { + return self::$contentRepositoryAuthProvider->canExecuteCommand($command); + } + return Privilege::granted(self::class . ' always grants privileges'); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php new file mode 100644 index 00000000000..6d950404a80 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -0,0 +1,206 @@ + $className + * @return T + */ + abstract private function getObject(string $className): object; + + #[BeforeScenario] + public function resetContentRepositorySecurity(): void + { + TestingAuthProvider::resetAuthProvider(); + $this->contentRepositorySecurityEnabled = false; + } + + #[BeforeFeature] + #[AfterFeature] + public static function resetPolicies(): void + { + if (self::$testingPolicyPathAndFilename !== null && file_exists(self::$testingPolicyPathAndFilename)) { + unlink(self::$testingPolicyPathAndFilename); + } + } + + private function enableFlowSecurity(): void + { + if ($this->flowSecurityEnabled === true) { + return; + } + $this->getObject(PrivilegeManagerInterface::class)->reset(); + + $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class); + + $this->testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; + + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); + $this->mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); + $securityContext->setRequest($this->mockActionRequest); + $this->flowSecurityEnabled = true; + } + + private function enableContentRepositorySecurity(): void + { + if ($this->contentRepositorySecurityEnabled === true) { + return; + } + $contentRepositoryAuthProviderFactory = $this->getObject(ContentRepositoryAuthProviderFactory::class); + $contentGraphProjection = $this->getContentRepositoryService(new class implements ContentRepositoryServiceFactoryInterface { + public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface + { + $contentGraphProjection = $serviceFactoryDependencies->projectionsAndCatchUpHooks->contentGraphProjection; + return new class ($contentGraphProjection) implements ContentRepositoryServiceInterface { + public function __construct( + public ContentGraphProjectionInterface $contentGraphProjection, + ) { + } + }; + } + })->contentGraphProjection; + $contentRepositoryAuthProvider = $contentRepositoryAuthProviderFactory->build($this->currentContentRepository->id, $contentGraphProjection->getState()); + + TestingAuthProvider::replaceAuthProvider($contentRepositoryAuthProvider); + $this->contentRepositorySecurityEnabled = true; + } + + private function authenticateAccount(Account $account): void + { + $this->enableFlowSecurity(); + $this->testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->testingProvider->setAccount($account); + + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + $securityContext->setRequest($this->mockActionRequest); + $this->getObject(AuthenticationProviderManager::class)->authenticate(); + } + + /** + * @Given content repository security is enabled + */ + public function contentRepositorySecurityIsEnabled(): void + { + $this->enableContentRepositorySecurity(); + } + + + /** + * @Given The following additional policies are configured: + */ + public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void + { + $policyService = $this->getObject(PolicyService::class); + $policyService->getRoles(); // force initialization + $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true); + $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw())); + + self::$testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml'; + file_put_contents(self::$testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration)); + + ObjectAccess::setProperty($policyService, 'initialized', false, true); + $this->getObject(ConfigurationManager::class)->flushConfigurationCache(); + } + + /** + * @When the user :username accesses the content graph for workspace :workspaceName + */ + public function theUserAccessesTheContentGraphForWorkspace(string $username, string $workspaceName): void + { + $this->enableContentRepositorySecurity(); + $user = $this->getObject(UserService::class)->getUser($username); + $this->authenticateAccount($user->getAccounts()->first()); + $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName))); + } + + /** + * @Then The user :username should not be able to read node :nodeAggregateId + */ + public function theUserShouldNotBeAbleToReadNode(string $username, string $nodeAggregateId): void + { + $user = $this->getObject(UserService::class)->getUser($username); + $this->authenticateAccount($user->getAccounts()->first()); + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node !== null) { + Assert::fail(sprintf('Expected node "%s" to be inaccessible to user "%s" but it was loaded', $nodeAggregateId, $username)); + } + } + + /** + * @Then The user :username should be able to read node :nodeAggregateId + */ + public function theUserShouldBeAbleToReadNode(string $username, string $nodeAggregateId): void + { + $user = $this->getObject(UserService::class)->getUser($username); + $this->authenticateAccount($user->getAccounts()->first()); + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node === null) { + Assert::fail(sprintf('Expected node "%s" to be accessible to user "%s" but it could not be loaded', $nodeAggregateId, $username)); + } + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php index fabfcd12608..228d4594bdc 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -46,6 +46,15 @@ public function anExceptionShouldBeThrown(string $exceptionMessage): void $this->lastCaughtException = null; } + /** + * @Then no exception should be thrown + */ + public function noExceptionShouldBeThrown(): void + { + Assert::assertNull($this->lastCaughtException, 'Expected no exception but one was thrown'); + $this->lastCaughtException = null; + } + /** * @BeforeScenario * @AfterScenario diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index 9d06ce491eb..a522afdd352 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -46,6 +46,7 @@ class FeatureContext implements BehatContext use AssetTrait; use WorkspaceServiceTrait; + use ContentRepositorySecurityTrait; use UserServiceTrait; protected Environment $environment; diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 4d8d153f64e..4c4118f8f57 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -16,8 +16,10 @@ use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Security\AccountFactory; use Neos\Flow\Security\Cryptography\HashService; +use Neos\Flow\Security\Policy\PolicyService; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Service\UserService; +use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Party\Domain\Model\PersonName; use Neos\Utility\ObjectAccess; diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature new file mode 100644 index 00000000000..9b4e721c733 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature @@ -0,0 +1,75 @@ +@flowEntities +Feature: TODO + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + 'Neos.Neos:ReadBlog': + matcher: 'blog' + roles: + + 'Neos.Neos:Administrator': + privileges: + - privilegeTarget: 'Neos.Neos:ReadBlog' + permission: GRANT + """ + And using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository.Testing:Document': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | + | a | Neos.ContentRepository.Testing:Document | root | a | + | a1 | Neos.ContentRepository.Testing:Document | a | a1 | + | a1a | Neos.ContentRepository.Testing:Document | a1 | a1a | + | a1a1 | Neos.ContentRepository.Testing:Document | a1a | a1a1 | + | a1a1a | Neos.ContentRepository.Testing:Document | a1a1 | a1a1a | + | a1a1b | Neos.ContentRepository.Testing:Document | a1a1 | a1a1b | + | a1a2 | Neos.ContentRepository.Testing:Document | a1a | a1a2 | + | a1b | Neos.ContentRepository.Testing:Document | a1 | a1b | + | a2 | Neos.ContentRepository.Testing:Document | a | a2 | + | b | Neos.ContentRepository.Testing:Document | root | b | + | b1 | Neos.ContentRepository.Testing:Document | b | b1 | + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | editor | Edward | Editor | Neos.Neos:Editor | + + Scenario: Access content graph for root workspace without role assignments + Given I am in workspace "live" + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "blog" | + And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" + When content repository security is enabled + Then The user "john.doe" should not be able to read node "a1" + Then The user "jane.doe" should be able to read node "a1" + + Scenario: TODO + When content repository security is enabled + And the user "jane.doe" accesses the content graph for workspace "live" + Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown + + Scenario: TODO + Given the role MANAGER is assigned to workspace "live" for user "janedoe" + When content repository security is enabled + And the user "jane.doe" accesses the content graph for workspace "live" + Then no exception should be thrown diff --git a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature b/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature deleted file mode 100644 index 2fa92e93581..00000000000 --- a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature +++ /dev/null @@ -1,148 +0,0 @@ -# TODO rewrite test after https://github.com/neos/neos-development-collection/issues/3732 - -Feature: Privilege to restrict nodes shown in the node tree - - Background: - Given I have the following policies: - """ - privilegeTargets: - - 'Neos\Neos\Security\Authorization\Privilege\NodeTreePrivilege': - 'Neos.ContentRepository:CompanySubtree': - matcher: 'isDescendantNodeOf("/sites/content-repository/company")' - 'Neos.ContentRepository:ServiceSubtree': - matcher: 'isDescendantNodeOf("/sites/content-repository/service")' - - 'Neos.ContentRepository:NeosSite': - matcher: 'isDescendantNodeOf("/sites/neos")' - 'Neos.ContentRepository:NeosTeams': - matcher: 'isAncestorOrDescendantNodeOf("/sites/neos/community/teams")' - - 'Neos\ContentRepository\Security\Authorization\Privilege\Node\EditNodePrivilege': - 'Neos.ContentRepository:EditNeosTeamsPath': - matcher: 'isAncestorNodeOf("/sites/neos/community/teams")' - - roles: - 'Neos.Flow:Everybody': - privileges: [] - - 'Neos.Flow:Anonymous': - privileges: [] - - 'Neos.Flow:AuthenticatedUser': - privileges: [] - - 'Neos.Neos:Editor': - privileges: - - - privilegeTarget: 'Neos.ContentRepository:CompanySubtree' - permission: GRANT - - 'Neos.Neos:Administrator': - parentRoles: ['Neos.Neos:Editor'] - privileges: - - - privilegeTarget: 'Neos.ContentRepository:ServiceSubtree' - permission: GRANT - - - privilegeTarget: 'Neos.ContentRepository:NeosTeams' - permission: GRANT - - - privilegeTarget: 'Neos.ContentRepository:EditNeosTeamsPath' - permission: DENY - - """ - - And I have the following nodes: - | Identifier | Path | Node Type | Properties | Workspace | - | ecf40ad1-3119-0a43-d02e-55f8b5aa3c70 | /sites | unstructured | | live | - | fd5ba6e1-4313-b145-1004-dad2f1173a35 | /sites/content-repository | Neos.ContentRepository.Testing:Document | {"title": "Home"} | live | - | 68ca0dcd-2afb-ef0e-1106-a5301e65b8a0 | /sites/content-repository/company | Neos.ContentRepository.Testing:Document | {"title": "Company"} | live | - | 52540602-b417-11e3-9358-14109fd7a2dd | /sites/content-repository/service | Neos.ContentRepository.Testing:Document | {"title": "Service"} | live | - | 3223481d-e11c-4db7-95de-b371411a2431 | /sites/content-repository/service/newsletter | Neos.ContentRepository.Testing:Document | {"title": "Newsletter"} | live | - | 544e14a3-b21d-429a-9fdd-cbeccc8d2b0f | /sites/content-repository/about-us | Neos.ContentRepository.Testing:Document | {"title": "About us"} | live | - | 56217c92-07e9-4554-ac35-03f86d278870 | /sites/neos | Neos.ContentRepository.Testing:Document | {"title": "Neos"} | live | - | 4be072fe-0738-4892-8a27-342a6ac96075 | /sites/neos/community | Neos.ContentRepository.Testing:Document | {"title": "Community"} | live | - | c56d66e7-9c55-4eef-a2b1-c263b3261996 | /sites/neos/community/teams | Neos.ContentRepository.Testing:Document | {"title": "Teams"} | live | - | 07902b2e-61d9-4ce4-9b90-1cf338830d2f | /sites/neos/community/teams/member| Neos.ContentRepository.Testing:Document | {"title": "Johannes"} | live | - - @Isolated @fixtures - Scenario: Editors are granted to set properties on company node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/company" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "The company" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on service node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/service" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "Our services" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on service sub node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/content-repository/service/newsletter" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "Our newsletter" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on company node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/company" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "The company" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on service node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/service" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Our services" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on service sub node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/content-repository/service/newsletter" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Our newsletter" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Editors are not granted to set properties on a neos sub node - Given I am authenticated with role "Neos.Neos:Editor" - And I get a node by path "/sites/neos/community/teams" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "The Teams" - And I should get false when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are granted to set properties on a neos sub node - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/neos/community/teams/member" with the following context: - | Workspace | - | user-admin | - Then I should be granted to set the "title" property to "Basti" - And I should get true when asking the node authorization service if editing this node is granted - - @Isolated @fixtures - Scenario: Administrators are not granted to set properties on an ancestor node of teams - Given I am authenticated with role "Neos.Neos:Administrator" - And I get a node by path "/sites/neos/community" with the following context: - | Workspace | - | user-admin | - Then I should not be granted to set the "title" property to "The Community" - And I should get false when asking the node authorization service if editing this node is granted From dec5667d4d6fce4974a45689879cc71ea63630ed Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 7 Nov 2024 08:38:40 +0100 Subject: [PATCH 36/58] moar tests --- .../ContentRepositorySecurityTrait.php | 30 +++++---- .../Bootstrap/WorkspaceServiceTrait.php | 31 +++++++-- .../Security/EditNodePrivilege.feature | 65 ++++++++++++++++++ .../ReadNodePrivilege.feature} | 53 +++++++-------- .../Security/WorkspaceAccess.feature | 66 +++++++++++++++++++ 5 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature rename Neos.Neos/Tests/Behavior/Features/ContentRepository/{Security.feature => Security/ReadNodePrivilege.feature} (51%) create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php index 6d950404a80..76c71de0e68 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -146,6 +146,7 @@ private function authenticateAccount(Account $account): void */ public function contentRepositorySecurityIsEnabled(): void { + $this->enableFlowSecurity(); $this->enableContentRepositorySecurity(); } @@ -168,39 +169,42 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici } /** - * @When the user :username accesses the content graph for workspace :workspaceName + * @When the user :username is authenticated */ - public function theUserAccessesTheContentGraphForWorkspace(string $username, string $workspaceName): void + public function theUserIsAuthenticated(string $username): void { - $this->enableContentRepositorySecurity(); $user = $this->getObject(UserService::class)->getUser($username); $this->authenticateAccount($user->getAccounts()->first()); + } + + + /** + * @When the current user accesses the content graph for workspace :workspaceName + */ + public function theCurrentUserAccessesTheContentGraphForWorkspace(string $workspaceName): void + { $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName))); } /** - * @Then The user :username should not be able to read node :nodeAggregateId + * @Then The current user should not be able to read node :nodeAggregateId */ - public function theUserShouldNotBeAbleToReadNode(string $username, string $nodeAggregateId): void + public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId): void { - $user = $this->getObject(UserService::class)->getUser($username); - $this->authenticateAccount($user->getAccounts()->first()); $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); if ($node !== null) { - Assert::fail(sprintf('Expected node "%s" to be inaccessible to user "%s" but it was loaded', $nodeAggregateId, $username)); + Assert::fail(sprintf('Expected node "%s" to be inaccessible but it was loaded', $nodeAggregateId)); } } /** - * @Then The user :username should be able to read node :nodeAggregateId + * @Then The current user should be able to read node :nodeAggregateId */ - public function theUserShouldBeAbleToReadNode(string $username, string $nodeAggregateId): void + public function theCurrentUserShouldBeAbleToReadNode(string $nodeAggregateId): void { - $user = $this->getObject(UserService::class)->getUser($username); - $this->authenticateAccount($user->getAccounts()->first()); $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); if ($node === null) { - Assert::fail(sprintf('Expected node "%s" to be accessible to user "%s" but it could not be loaded', $nodeAggregateId, $username)); + Assert::fail(sprintf('Expected node "%s" to be accessible but it could not be loaded', $nodeAggregateId)); } } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 416b6d4a8d3..28fbdba1a00 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -14,10 +14,12 @@ use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider; +use Neos\ContentRepository\Core\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Flow\Reflection\ReflectionService; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -166,15 +168,20 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table /** * @When the role :role is assigned to workspace :workspaceName for group :groupName - * @When the role :role is assigned to workspace :workspaceName for user :userId + * @When the role :role is assigned to workspace :workspaceName for user :username */ - public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void + public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void { + if ($groupName !== null) { + $subject = WorkspaceRoleSubject::createForGroup($groupName); + } else { + $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); + } $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceRoleAssignment::create( - $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), + $subject, WorkspaceRole::from($role) ) )); @@ -182,14 +189,19 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string /** * @When the role for group :groupName is unassigned from workspace :workspaceName - * @When the role for user :userId is unassigned from workspace :workspaceName + * @When the role for user :username is unassigned from workspace :workspaceName */ - public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void + public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void { + if ($groupName !== null) { + $subject = WorkspaceRoleSubject::createForGroup($groupName); + } else { + $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); + } $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), + $subject, )); } @@ -242,4 +254,11 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, Assert::assertFalse($permissions->write); Assert::assertFalse($permissions->manage); } + + private function userIdForUsername(string $username): UserId + { + $user = $this->getObject(UserService::class)->getUser($username); + Assert::assertNotNull($user); + return $user->getId(); + } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature new file mode 100644 index 00000000000..3f86ee05114 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -0,0 +1,65 @@ +@flowEntities +Feature: EditNodePrivilege related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege': + 'Neos.Neos:EditBlog': + matcher: 'blog' + """ + And using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | editor | Edward | Editor | Neos.Neos:Editor | + + Scenario: TODO + Given I am in workspace "live" + And I am in dimension space point {"language":"de"} + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "blog" | + And the role MANAGER is assigned to workspace "live" for user "jane.doe" + When content repository security is enabled + And the user "jane.doe" is authenticated + When the command DisableNodeAggregate is executed with payload and exceptions are caught: + | Key | Value | + | nodeAggregateId | "a1a" | + | nodeVariantSelectionStrategy | "allVariants" | + Then the last command should have thrown an exception of type "AccessDenied" diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature similarity index 51% rename from Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature rename to Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature index 9b4e721c733..886f619599c 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -1,5 +1,5 @@ @flowEntities -Feature: TODO +Feature: ReadNodePrivilege related features Background: Given The following additional policies are configured: @@ -9,16 +9,17 @@ Feature: TODO 'Neos.Neos:ReadBlog': matcher: 'blog' roles: - 'Neos.Neos:Administrator': privileges: - privilegeTarget: 'Neos.Neos:ReadBlog' permission: GRANT """ - And using no content dimensions + And using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | And using the following node types: """yaml - 'Neos.ContentRepository.Testing:Document': {} + 'Neos.Neos:Document': {} """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -33,26 +34,27 @@ Feature: TODO | nodeAggregateId | "root" | | nodeTypeName | "Neos.ContentRepository:Root" | And the following CreateNodeAggregateWithNode commands are executed: - | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | - | a | Neos.ContentRepository.Testing:Document | root | a | - | a1 | Neos.ContentRepository.Testing:Document | a | a1 | - | a1a | Neos.ContentRepository.Testing:Document | a1 | a1a | - | a1a1 | Neos.ContentRepository.Testing:Document | a1a | a1a1 | - | a1a1a | Neos.ContentRepository.Testing:Document | a1a1 | a1a1a | - | a1a1b | Neos.ContentRepository.Testing:Document | a1a1 | a1a1b | - | a1a2 | Neos.ContentRepository.Testing:Document | a1a | a1a2 | - | a1b | Neos.ContentRepository.Testing:Document | a1 | a1b | - | a2 | Neos.ContentRepository.Testing:Document | a | a2 | - | b | Neos.ContentRepository.Testing:Document | root | b | - | b1 | Neos.ContentRepository.Testing:Document | b | b1 | + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | And the following Neos users exist: | Id | Username | First name | Last name | Roles | | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | | editor | editor | Edward | Editor | Neos.Neos:Editor | - Scenario: Access content graph for root workspace without role assignments + Scenario: TODO Given I am in workspace "live" + And I am in dimension space point {"language":"de"} And the command TagSubtree is executed with payload: | Key | Value | | nodeAggregateId | "a" | @@ -60,16 +62,7 @@ Feature: TODO | tag | "blog" | And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" When content repository security is enabled - Then The user "john.doe" should not be able to read node "a1" - Then The user "jane.doe" should be able to read node "a1" - - Scenario: TODO - When content repository security is enabled - And the user "jane.doe" accesses the content graph for workspace "live" - Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown - - Scenario: TODO - Given the role MANAGER is assigned to workspace "live" for user "janedoe" - When content repository security is enabled - And the user "jane.doe" accesses the content graph for workspace "live" - Then no exception should be thrown + And the user "john.doe" is authenticated + Then The current user should not be able to read node "a1" + When the user "jane.doe" is authenticated + Then The current user should be able to read node "a1" diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature new file mode 100644 index 00000000000..eaf3d7ea4dd --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature @@ -0,0 +1,66 @@ +@flowEntities +Feature: Workspace access related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + 'Neos.Neos:ReadBlog': + matcher: 'blog' + roles: + 'Neos.Neos:Administrator': + privileges: + - privilegeTarget: 'Neos.Neos:ReadBlog' + permission: GRANT + """ + And using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "initiating-user-identifier" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | editor | Edward | Editor | Neos.Neos:Editor | + + Scenario: TODO + When content repository security is enabled + And the user "jane.doe" is authenticated + And the current user accesses the content graph for workspace "live" + Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown + + Scenario: TODO + Given the role MANAGER is assigned to workspace "live" for user "jane.doe" + When content repository security is enabled + And the user "jane.doe" is authenticated + And the current user accesses the content graph for workspace "live" + Then no exception should be thrown From 2efbf3a5d72e2c60ec2d05e6580bc1cacf8b5a7e Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 8 Nov 2024 14:26:48 +0100 Subject: [PATCH 37/58] moar better tests --- ...ricCommandExecutionAndEventPublication.php | 15 ++- .../ContentRepositorySecurityTrait.php | 16 ++-- .../Features/Bootstrap/ExceptionsTrait.php | 12 ++- .../Features/Bootstrap/UserServiceTrait.php | 2 +- .../Bootstrap/WorkspaceServiceTrait.php | 9 +- .../Security/EditNodePrivilege.feature | 23 +++-- .../Security/ReadNodePrivilege.feature | 8 +- .../Security/WorkspaceAccess.feature | 96 +++++++++++++------ .../WorkspaceService.feature | 60 ++++++++---- 9 files changed, 162 insertions(+), 79 deletions(-) diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index dd899ee4e24..5ecc9d7d546 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php @@ -14,6 +14,7 @@ namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap; +use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; use Neos\ContentRepository\Core\EventStore\EventNormalizer; @@ -147,14 +148,17 @@ protected function publishEvent(string $eventType, StreamName $streamName, array } /** - * @Then /^the last command should have thrown an exception of type "([^"]*)"(?: with code (\d*))?$/ + * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode and message: + * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode + * @Then the last command should have thrown an exception of type :shortExceptionName with message: + * @Then the last command should have thrown an exception of type :shortExceptionName */ - public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null): void + public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null, PyStringNode $expectedMessage = null): void { Assert::assertNotNull($this->lastCommandException, 'Command did not throw exception'); $lastCommandExceptionShortName = (new \ReflectionClass($this->lastCommandException))->getShortName(); - Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_class($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage())); - if (!is_null($expectedCode)) { + Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage())); + if ($expectedCode !== null) { Assert::assertSame($expectedCode, $this->lastCommandException->getCode(), sprintf( 'Expected exception code %s, got exception code %s instead; Message: %s', $expectedCode, @@ -162,6 +166,9 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $this->lastCommandException->getMessage() )); } + if ($expectedMessage !== null) { + Assert::assertSame($expectedMessage->getRaw(), $this->lastCommandException->getMessage()); + } } /** diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php index 76c71de0e68..c8bc89a542e 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -169,9 +169,9 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici } /** - * @When the user :username is authenticated + * @When I am authenticated as :username */ - public function theUserIsAuthenticated(string $username): void + public function iAmAuthenticatedAs(string $username): void { $user = $this->getObject(UserService::class)->getUser($username); $this->authenticateAccount($user->getAccounts()->first()); @@ -179,17 +179,17 @@ public function theUserIsAuthenticated(string $username): void /** - * @When the current user accesses the content graph for workspace :workspaceName + * @When I access the content graph for workspace :workspaceName */ - public function theCurrentUserAccessesTheContentGraphForWorkspace(string $workspaceName): void + public function iAccessesTheContentGraphForWorkspace(string $workspaceName): void { $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName))); } /** - * @Then The current user should not be able to read node :nodeAggregateId + * @Then I should not be able to read node :nodeAggregateId */ - public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId): void + public function iShouldNotBeAbleToReadNode(string $nodeAggregateId): void { $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); if ($node !== null) { @@ -198,9 +198,9 @@ public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId) } /** - * @Then The current user should be able to read node :nodeAggregateId + * @Then I should be able to read node :nodeAggregateId */ - public function theCurrentUserShouldBeAbleToReadNode(string $nodeAggregateId): void + public function iShouldBeAbleToReadNode(string $nodeAggregateId): void { $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); if ($node === null) { diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php index 228d4594bdc..5a87e107249 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -12,6 +12,7 @@ * source code. */ +use Behat\Gherkin\Node\PyStringNode; use PHPUnit\Framework\Assert; /** @@ -37,12 +38,17 @@ private function tryCatchingExceptions(\Closure $callback): mixed } /** - * @Then an exception :exceptionMessage should be thrown + * @Then an exception of type :expectedShortExceptionName should be thrown with message: + * @Then an exception of type :expectedShortExceptionName should be thrown */ - public function anExceptionShouldBeThrown(string $exceptionMessage): void + public function anExceptionShouldBeThrown(string $expectedShortExceptionName, PyStringNode $expectedExceptionMessage = null): void { Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown'); - Assert::assertSame($exceptionMessage, $this->lastCaughtException->getMessage()); + $lastCaughtExceptionShortName = (new \ReflectionClass($this->lastCaughtException))->getShortName(); + Assert::assertSame($expectedShortExceptionName, $lastCaughtExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCaughtException), $this->lastCaughtException->getCode(), $this->lastCaughtException->getMessage())); + if ($expectedExceptionMessage !== null) { + Assert::assertSame($expectedExceptionMessage->getRaw(), $this->lastCaughtException->getMessage()); + } $this->lastCaughtException = null; } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 4c4118f8f57..da251d624fb 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -65,7 +65,7 @@ public function theFollowingNeosUsersExist(TableNode $usersTable): void username: $userData['Username'], firstName: $userData['First name'] ?? null, lastName: $userData['Last name'] ?? null, - roleIdentifiers: isset($userData['Roles']) ? explode(',', $userData['Roles']) : null, + roleIdentifiers: !empty($userData['Roles']) ? explode(',', $userData['Roles']) : null, id: $userData['Id'] ?? null, ); } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 28fbdba1a00..5aeae6d712b 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -65,17 +65,18 @@ public function theRootWorkspaceIsCreated(string $workspaceName, string $title = } /** - * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :ownerUserId + * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :username */ - public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $ownerUserId): void + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $username): void { + $ownerUserId = $this->userIdForUsername($username); $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createPersonalWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), - UserId::fromString($ownerUserId), + $ownerUserId, )); } @@ -258,7 +259,7 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, private function userIdForUsername(string $username): UserId { $user = $this->getObject(UserService::class)->getUser($username); - Assert::assertNotNull($user); + Assert::assertNotNull($user, sprintf('The user "%s" does not exist', $username)); return $user->getId(); } } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature index 3f86ee05114..d90d4448763 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -18,7 +18,6 @@ Feature: EditNodePrivilege related features """ And using identifier "default", I define a content repository And I am in content repository "default" - And I am user identified by "initiating-user-identifier" And the command CreateRootWorkspace is executed with payload: | Key | Value | | workspaceName | "live" | @@ -42,10 +41,10 @@ Feature: EditNodePrivilege related features | b | Neos.Neos:Document | root | b | {"language":"de"} | | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | And the following Neos users exist: - | Id | Username | First name | Last name | Roles | - | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | - | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | - | editor | editor | Edward | Editor | Neos.Neos:Editor | + | Username | First name | Last name | Roles | + | jane.doe | Jane | Doe | Neos.Neos:Administrator | + | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | editor | Edward | Editor | Neos.Neos:Editor | Scenario: TODO Given I am in workspace "live" @@ -57,9 +56,19 @@ Feature: EditNodePrivilege related features | tag | "blog" | And the role MANAGER is assigned to workspace "live" for user "jane.doe" When content repository security is enabled - And the user "jane.doe" is authenticated + And I am authenticated as "jane.doe" When the command DisableNodeAggregate is executed with payload and exceptions are caught: | Key | Value | | nodeAggregateId | "a1a" | | nodeVariantSelectionStrategy | "allVariants" | - Then the last command should have thrown an exception of type "AccessDenied" + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 +# Then the last command should have thrown an exception of type "AccessDenied" with message: +# """ +# Command "Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate" was denied: No edit permissions for node "a1a" in workspace "live": Evaluated following 2 privilege target(s): +# "Neos.Neos:ReadBlog": ABSTAIN +# "Neos.Neos:ReadBlog": GRANT +# (1 granted, 0 denied, 1 abstained) +# Evaluated following 1 privilege target(s): +# "Neos.Neos:EditBlog": ABSTAIN +# (0 granted, 0 denied, 1 abstained) +# """ diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature index 886f619599c..f30fc37739f 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -62,7 +62,7 @@ Feature: ReadNodePrivilege related features | tag | "blog" | And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" When content repository security is enabled - And the user "john.doe" is authenticated - Then The current user should not be able to read node "a1" - When the user "jane.doe" is authenticated - Then The current user should be able to read node "a1" + And I am authenticated as "john.doe" + Then I should not be able to read node "a1" + When I am authenticated as "jane.doe" + Then I should be able to read node "a1" diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature index eaf3d7ea4dd..473901a678f 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature @@ -29,38 +29,80 @@ Feature: Workspace access related features | workspaceName | "live" | | newContentStreamId | "cs-identifier" | And I am in workspace "live" and dimension space point {} - And the command CreateRootNodeAggregateWithNode is executed with payload: - | Key | Value | - | nodeAggregateId | "root" | - | nodeTypeName | "Neos.ContentRepository:Root" | - And the following CreateNodeAggregateWithNode commands are executed: - | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | - | a | Neos.Neos:Document | root | a | {"language":"mul"} | - | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | - | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | - | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | - | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | - | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | - | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | - | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | - | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | - | b | Neos.Neos:Document | root | b | {"language":"de"} | - | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | And the following Neos users exist: - | Id | Username | First name | Last name | Roles | - | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | - | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | - | editor | editor | Edward | Editor | Neos.Neos:Editor | + | Username | Roles | + | admin | Neos.Neos:Administrator | + | editor | Neos.Neos:Editor | + | restricted_editor | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | + | no_editor | | Scenario: TODO When content repository security is enabled - And the user "jane.doe" is authenticated - And the current user accesses the content graph for workspace "live" - Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown + And I am authenticated as "admin" + And I access the content graph for workspace "live" + Then an exception of type "AccessDenied" should be thrown with message: + """ + Read access denied for workspace "live": Account "admin" is a Neos Administrator without explicit role for workspace "live" + """ Scenario: TODO - Given the role MANAGER is assigned to workspace "live" for user "jane.doe" + Given the role MANAGER is assigned to workspace "live" for user "admin" + When content repository security is enabled + And I am authenticated as "admin" + And I access the content graph for workspace "live" + Then no exception should be thrown + + Scenario Outline: Accessing content graph for explicitly assigned workspace role to the authenticated user + Given the role is assigned to workspace "live" for user "" + When content repository security is enabled + And I am authenticated as "" + And I access the content graph for workspace "live" + Then no exception should be thrown + + Examples: + | user | workspace role | + | admin | VIEWER | + | editor | COLLABORATOR | + | editor | VIEWER | + | restricted_editor | MANAGER | + | restricted_editor | VIEWER | + + Scenario Outline: Accessing content graph for workspace role assigned to group of the authenticated user + Given the role is assigned to workspace "live" for group "" When content repository security is enabled - And the user "jane.doe" is authenticated - And the current user accesses the content graph for workspace "live" + And I am authenticated as "" + And I access the content graph for workspace "live" Then no exception should be thrown + + Examples: + | user | group | workspace role | + | admin | Neos.Neos:Editor | COLLABORATOR | + | editor | Neos.Neos:Editor | COLLABORATOR | + | restricted_editor | Neos.Neos:RestrictedEditor | VIEWER | + | no_editor | Neos.Flow:Everybody | VIEWER | + + Scenario Outline: Accessing content graph for workspace role assigned to group the authenticated user is not part of + Given the role is assigned to workspace "live" for group "" + When content repository security is enabled + And I am authenticated as "" + And I access the content graph for workspace "live" + Then an exception of type "AccessDenied" should be thrown + + Examples: + | user | group | workspace role | + | admin | Neos.Flow:Anonymous | COLLABORATOR | + | editor | Neos.Neos:Administrator | MANAGER | + | restricted_editor | Neos.Neos:Editor | VIEWER | + + Scenario Outline: Accessing content graph for workspace that is owned by the authenticated user + Given the personal workspace "user-workspace" is created with the target workspace "live" for user "" + When content repository security is enabled + And I am authenticated as "" + And I access the content graph for workspace "user-workspace" + Then no exception should be thrown + + Examples: + | user | + | admin | + | editor | + | restricted_editor | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index ea0a90995a2..22b3ff17e04 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -29,12 +29,15 @@ Feature: Neos WorkspaceService related features Scenario: Create root workspace with a name that exceeds the workspace name max length When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created - Then an exception 'Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters' should be thrown + Then an exception of type "InvalidArgumentException" should be thrown with message: + """ + Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters + """ Scenario: Create root workspace with a name that is already used Given the root workspace "some-root-workspace" is created When the root workspace "some-root-workspace" is created - Then an exception "The workspace some-root-workspace already exists" should be thrown + Then an exception of type "WorkspaceAlreadyExists" should be thrown Scenario: Get metadata of non-existing root workspace When a root workspace "some-root-workspace" exists without metadata @@ -73,10 +76,10 @@ Feature: Neos WorkspaceService related features Scenario: Create a single personal workspace When the root workspace "some-root-workspace" is created - And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id" + And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" Then the workspace "some-user-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | - | some-user-workspace | | PERSONAL | some-user-id | + | some-user-workspace | | PERSONAL | janedoe | Scenario: Create a single shared workspace When the root workspace "some-root-workspace" is created @@ -94,7 +97,10 @@ Feature: Neos WorkspaceService related features Scenario: Assign role to non-existing workspace When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor" - Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to find workspace with name "some-workspace" for content repository "default" + """ Scenario: Assign group role to root workspace Given the root workspace "some-root-workspace" is created @@ -107,42 +113,54 @@ Feature: Neos WorkspaceService related features Given the root workspace "some-root-workspace" is created When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" - Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first + """ Scenario: Assign user role to root workspace Given the root workspace "some-root-workspace" is created - When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + When the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe" Then the workspace "some-root-workspace" should have the following role assignments: - | Subject type | Subject | Role | - | USER | some-user-id | MANAGER | + | Subject type | Subject | Role | + | USER | janedoe | MANAGER | Scenario: Assign a role to the same user twice Given the root workspace "some-root-workspace" is created - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id" - And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" - Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe" + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to assign role for workspace "some-root-workspace" to subject "johndoe" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first + """ Scenario: Unassign role from non-existing workspace When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" - Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to find workspace with name "some-workspace" for content repository "default" + """ Scenario: Unassign role from workspace that has not been assigned before Given the root workspace "some-root-workspace" is created When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" - Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group + """ Scenario: Assign two roles, then unassign one Given the root workspace "some-root-workspace" is created - And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe" And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" Then the workspace "some-root-workspace" should have the following role assignments: | Subject type | Subject | Role | | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | - | USER | some-user-id | MANAGER | + | USER | janedoe | MANAGER | When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" Then the workspace "some-root-workspace" should have the following role assignments: - | Subject type | Subject | Role | - | USER | some-user-id | MANAGER | + | Subject type | Subject | Role | + | USER | janedoe | MANAGER | Scenario: Workspace permissions for personal workspace for admin user Given the root workspace "live" is created @@ -186,14 +204,14 @@ Feature: Neos WorkspaceService related features Scenario: Workspace permissions for collaborator by user When the root workspace "some-root-workspace" is created - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "johndoe" + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe" Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have the permissions "read,write" for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" Scenario: Workspace permissions for manager by user When the root workspace "some-root-workspace" is created - When the role MANAGER is assigned to workspace "some-root-workspace" for user "johndoe" + When the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe" Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" @@ -212,7 +230,7 @@ Feature: Neos WorkspaceService related features Scenario: Permissions for workspace without metadata Given a root workspace "some-root-workspace" exists without metadata - When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "janedoe" + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe" Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace" And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace" And the Neos user "editor" should have no permissions for workspace "some-root-workspace" From 8b8eea7700669e1edaece155d6ebbfc60a39022a Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sat, 9 Nov 2024 19:16:46 +0100 Subject: [PATCH 38/58] Extract authenticated account from current Neos user ...instead of the security context --- Neos.Neos/Classes/Domain/Model/User.php | 12 ++++++++++-- .../Classes/Controller/WorkspaceController.php | 12 ++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php index 24db0c6cfc3..33abe790644 100644 --- a/Neos.Neos/Classes/Domain/Model/User.php +++ b/Neos.Neos/Classes/Domain/Model/User.php @@ -92,13 +92,21 @@ public function setPreferences(UserPreferences $preferences) * @api */ public function isActive() + { + return $this->getFirstActiveAccount() !== null; + } + + /** + * @api + */ + public function getFirstActiveAccount(): ?Account { foreach ($this->accounts as $account) { /** @var Account $account */ if ($account->isActive()) { - return true; + return $account; } } - return false; + return null; } } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 2a8665181de..3b45f907c58 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -43,7 +43,6 @@ use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; -use Neos\Flow\Security\Context; use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -91,9 +90,6 @@ class WorkspaceController extends AbstractModuleController #[Flow\Inject] protected PropertyMapper $propertyMapper; - #[Flow\Inject] - protected Context $securityContext; - #[Flow\Inject] protected UserService $userService; @@ -114,7 +110,7 @@ class WorkspaceController extends AbstractModuleController */ public function indexAction(): void { - $authenticatedAccount = $this->securityContext->getAccount(); + $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); if ($authenticatedAccount === null) { throw new AccessDeniedException('No user authenticated', 1718308216); } @@ -164,7 +160,7 @@ classification: $workspaceMetadata->classification->name, public function showAction(WorkspaceName $workspace): void { - $authenticatedAccount = $this->securityContext->getAccount(); + $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); if ($authenticatedAccount === null) { throw new AccessDeniedException('No user authenticated', 1720371024); } @@ -293,7 +289,7 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $authenticatedAccount = $this->securityContext->getAccount(); + $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); if ($authenticatedAccount === null) { throw new AccessDeniedException('No user is authenticated', 1729620262); } @@ -1012,7 +1008,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { - $authenticatedAccount = $this->securityContext->getAccount(); + $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); foreach ($workspaces as $workspace) { From 85698043954b9a18ca7f0f903a9fdfbcaec83a99 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Sun, 10 Nov 2024 14:21:08 +0100 Subject: [PATCH 39/58] Fix `test_parallel` CR settings --- .../Configuration/Settings.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml index 212fb010b63..d38e376fd54 100644 --- a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml +++ b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml @@ -27,8 +27,8 @@ Neos: factoryObjectName: Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinPyStringNodeBasedNodeTypeManagerFactory contentDimensionSource: factoryObjectName: Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinTableNodeBasedContentDimensionSourceFactory - userIdProvider: - factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory + authProvider: + factoryObjectName: Neos\ContentRepositoryRegistry\Factory\AuthProvider\StaticAuthProviderFactory clock: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory propertyConverters: {} From 466c89e583dbbc9bfdf356e256f7f5cea69ec28f Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 11 Nov 2024 15:40:31 +0100 Subject: [PATCH 40/58] Simplify `ContentRepositoryAuthorizationService` API according to review comments --- .../Classes/Controller/UsageController.php | 10 +- .../Controller/Frontend/NodeController.php | 7 +- Neos.Neos/Classes/Domain/Model/User.php | 12 +- .../Domain/Service/WorkspaceService.php | 4 +- .../ContentRepositoryAuthorizationService.php | 125 ++++-------------- .../ContentRepositoryAuthProvider.php | 40 ++---- .../Bootstrap/WorkspaceServiceTrait.php | 23 ++-- .../Controller/WorkspaceController.php | 30 ++--- 8 files changed, 77 insertions(+), 174 deletions(-) diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index a62d36b6298..d3634d48125 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -22,13 +22,13 @@ use Neos\Flow\Security\Context as SecurityContext; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Service\AssetService; +use Neos\Neos\AssetUsage\Dto\AssetUsageReference; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Service\UserService; -use Neos\Neos\AssetUsage\Dto\AssetUsageReference; /** * Controller for asset usage handling @@ -117,13 +117,7 @@ public function relatedNodesAction(AssetInterface $asset) ); $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null; - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount !== null) { - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($currentContentRepositoryId, $usage->getWorkspaceName(), $authenticatedAccount); - } else { - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($currentContentRepositoryId, $usage->getWorkspaceName()); - } - + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($currentContentRepositoryId, $usage->getWorkspaceName(), $this->securityContext->getRoles(), $this->userService->getBackendUser()?->getId()); $workspace = $contentRepository->findWorkspaceByName($usage->getWorkspaceName()); $inaccessibleRelation['nodeIdentifier'] = $usage->getNodeAggregateId()->value; diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php index 7191c4e496f..19300dea675 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -200,12 +200,7 @@ public function showAction(string $node): void } $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount !== null) { - $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount); - } else { - $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id); - } + $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraints($contentRepository->id, $this->securityContext->getRoles()); // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes. // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated, diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php index 33abe790644..24db0c6cfc3 100644 --- a/Neos.Neos/Classes/Domain/Model/User.php +++ b/Neos.Neos/Classes/Domain/Model/User.php @@ -92,21 +92,13 @@ public function setPreferences(UserPreferences $preferences) * @api */ public function isActive() - { - return $this->getFirstActiveAccount() !== null; - } - - /** - * @api - */ - public function getFirstActiveAccount(): ?Account { foreach ($this->accounts as $account) { /** @var Account $account */ if ($account->isActive()) { - return $account; + return true; } } - return null; + return false; } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 3f7523c94e1..228605c322b 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -236,7 +236,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, /** * Get all role assignments for the specified workspace * - * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used! */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { @@ -272,7 +272,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito /** * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName * - * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used! + * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used! */ public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole { diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 2f6cc32cf1f..d0027c076a7 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -10,12 +10,12 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Account; use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; +use Neos\Flow\Security\Context; use Neos\Flow\Security\Policy\PolicyService; use Neos\Flow\Security\Policy\Role; use Neos\Neos\Domain\Model\NodePermissions; -use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; @@ -24,7 +24,6 @@ use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege; use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject; -use Neos\Party\Domain\Service\PartyService; /** * Central point which does ContentRepository authorization decisions within Neos. @@ -34,13 +33,9 @@ #[Flow\Scope('singleton')] final readonly class ContentRepositoryAuthorizationService { - private const FLOW_ROLE_EVERYBODY = 'Neos.Flow:Everybody'; - private const FLOW_ROLE_ANONYMOUS = 'Neos.Flow:Anonymous'; - private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser'; - private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator'; + private const ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator'; public function __construct( - private PartyService $partyService, private WorkspaceService $workspaceService, private PolicyService $policyService, private PrivilegeManagerInterface $privilegeManager, @@ -48,119 +43,61 @@ public function __construct( } /** - * Determines the {@see WorkspacePermissions} an anonymous user has for the specified workspace (aka "public access") + * Determines the {@see WorkspacePermissions} a user with the specified {@see Role}s has for the specified workspace + * + * @param array $roles The {@see Role} instances to check access for. Note: These have to be the expanded roles auf the authenticated tokens {@see Context::getRoles()} + * @param UserId|null $userId Optional ID of the authenticated Neos user. If set the workspace owner is evaluated since owners always have all permissions on their workspace */ - public function getWorkspacePermissionsForAnonymousUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspacePermissions - { - $subjects = [WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_EVERYBODY), WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_ANONYMOUS)]; - $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); - if ($userWorkspaceRole === null) { - return WorkspacePermissions::none(sprintf('Anonymous user has no explicit role for workspace "%s"', $workspaceName->value)); - } - return WorkspacePermissions::create( - read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), - write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - reason: sprintf('Anonymous user has role "%s" for workspace "%s"', $userWorkspaceRole->value, $workspaceName->value), - ); - } - - /** - * Determines the {@see WorkspacePermissions} the given user has for the specified workspace - */ - public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, Account $account): WorkspacePermissions + public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions { $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - $neosUser = $this->neosUserFromAccount($account); - if ($workspaceMetadata->ownerUserId !== null && $neosUser !== null && $neosUser->getId()->equals($workspaceMetadata->ownerUserId)) { - return WorkspacePermissions::all(sprintf('User "%s" (id: %s is the owner of workspace "%s"', $neosUser->getLabel(), $neosUser->getId()->value, $workspaceName->value)); + if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) { + return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value)); } - $userRoles = $this->expandAccountRoles($account); - $userIsAdministrator = array_key_exists(self::FLOW_ROLE_NEOS_ADMINISTRATOR, $userRoles); - $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles)); - - if ($neosUser !== null) { - $subjects[] = WorkspaceRoleSubject::createForUser($neosUser->getId()); + $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), $roles); + $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers); + if ($userId !== null) { + $subjects[] = WorkspaceRoleSubject::createForUser($userId); } + $userIsAdministrator = array_key_exists(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers); + $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { if ($userIsAdministrator) { - return WorkspacePermissions::manage(sprintf('Account "%s" is a Neos Administrator without explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value)); + return WorkspacePermissions::manage(sprintf('User is a Neos Administrator without explicit role for workspace "%s"', $workspaceName->value)); } - return WorkspacePermissions::none(sprintf('Account "%s" is no Neos Administrator and has no explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value)); + return WorkspacePermissions::none(sprintf('User is no Neos Administrator and has no explicit role for workspace "%s"', $workspaceName->value)); } return WorkspacePermissions::create( read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER), write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - reason: sprintf('Account "%s" is %s Neos Administrator and has role "%s" for workspace "%s"', $account->getAccountIdentifier(), $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value), + reason: sprintf('User is %s Neos Administrator and has role "%s" for workspace "%s"', $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value), ); } - public function getNodePermissionsForAnonymousUser(Node $node): NodePermissions - { - $roles = $this->rolesOfAnonymousUser(); - return $this->nodePermissionsForRoles($node, $roles); - } - - public function getNodePermissionsForAccount(Node $node, Account $account): NodePermissions - { - $roles = $this->expandAccountRoles($account); - return $this->nodePermissionsForRoles($node, $roles); - } - /** - * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access") + * Determines the {@see NodePermissions} a user with the specified {@see Role}s has on the given {@see Node} + * + * @param array $roles */ - public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints + public function getNodePermissions(Node $node, array $roles): NodePermissions { - $roles = $this->rolesOfAnonymousUser(); - return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles)); + return $this->nodePermissionsForRoles($node, $roles); } /** - * Determines the default {@see VisibilityConstraints} for the specified account + * Determines the default {@see VisibilityConstraints} for the specified {@see Role}s + * + * @param array $roles */ - public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints + public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints { - $roles = $this->expandAccountRoles($account); return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles)); } // ------------------------------ - /** - * @return array - */ - private function rolesOfAnonymousUser(): array - { - return [ - self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), - self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS), - ]; - } - - /** - * @return array - */ - private function expandAccountRoles(Account $account): array - { - $roles = [ - self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY), - self::FLOW_ROLE_AUTHENTICATED_USER => $this->policyService->getRole(self::FLOW_ROLE_AUTHENTICATED_USER), - ]; - foreach ($account->getRoles() as $currentRole) { - if (!array_key_exists($currentRole->getIdentifier(), $roles)) { - $roles[$currentRole->getIdentifier()] = $currentRole; - } - foreach ($currentRole->getAllParentRoles() as $currentParentRole) { - if (!array_key_exists($currentParentRole->getIdentifier(), $roles)) { - $roles[$currentParentRole->getIdentifier()] = $currentParentRole; - } - } - } - return $roles; - } /** * @param array $roles @@ -177,12 +114,6 @@ private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId return $restrictedSubtreeTags; } - private function neosUserFromAccount(Account $account): ?User - { - $user = $this->partyService->getAssignedPartyOfAccount($account); - return $user instanceof User ? $user : null; - } - /** * @param array $roles */ diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 95ff0a58ece..a84657c5aaa 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -78,11 +78,7 @@ public function getAuthenticatedUserId(): ?UserId public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints { - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount) { - return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount); - } - return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId); + return $this->authorizationService->getVisibilityConstraints($this->contentRepositoryId, $this->securityContext->getRoles()); } public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege @@ -90,12 +86,12 @@ public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privile if ($this->securityContext->areAuthorizationChecksDisabled()) { return Privilege::granted('Authorization checks are disabled'); } - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount === null) { - $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); - } else { - $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); - } + $workspacePermissions = $this->authorizationService->getWorkspacePermissions( + $this->contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId(), + ); return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason()); } @@ -117,7 +113,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege if ($node === null) { return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value)); } - $nodePermissions = $this->getNodePermissionsForCurrentUser($node); + $nodePermissions = $this->authorizationService->getNodePermissions($node, $this->securityContext->getRoles()); if (!$nodePermissions->edit) { return Privilege::denied(sprintf('No edit permissions for node "%s" in workspace "%s": %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); } @@ -199,19 +195,11 @@ private function requireWorkspaceManagePermission(WorkspaceName $workspaceName): private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions { - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount === null) { - return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName); - } - return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount); - } - - private function getNodePermissionsForCurrentUser(Node $node): NodePermissions - { - $authenticatedAccount = $this->securityContext->getAccount(); - if ($authenticatedAccount === null) { - return $this->authorizationService->getNodePermissionsForAnonymousUser($node); - } - return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount); + return $this->authorizationService->getWorkspacePermissions( + $this->contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId(), + ); } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 416b6d4a8d3..74ba8b77b53 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -18,6 +18,7 @@ use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\Flow\Security\Context; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; @@ -212,14 +213,15 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName */ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void { - $user = $this->getObject(UserService::class)->getUser($username); + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); Assert::assertNotNull($user); - $account = $user->getAccounts()->first(); - Assert::assertNotNull($account); - $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount( + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $account, + $roles, + $user->getId(), ); Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions))))); } @@ -229,14 +231,15 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username */ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void { - $user = $this->getObject(UserService::class)->getUser($username); + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); Assert::assertNotNull($user); - $account = $user->getAccounts()->first(); - Assert::assertNotNull($account); - $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount( + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $account, + $roles, + $user->getId(), ); Assert::assertFalse($permissions->read); Assert::assertFalse($permissions->write); diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 3b45f907c58..e8f02988acb 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -43,6 +43,7 @@ use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; +use Neos\Flow\Security\Context; use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -90,6 +91,9 @@ class WorkspaceController extends AbstractModuleController #[Flow\Inject] protected PropertyMapper $propertyMapper; + #[Flow\Inject] + protected Context $securityContext; + #[Flow\Inject] protected UserService $userService; @@ -110,8 +114,8 @@ class WorkspaceController extends AbstractModuleController */ public function indexAction(): void { - $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); - if ($authenticatedAccount === null) { + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { throw new AccessDeniedException('No user authenticated', 1718308216); } @@ -140,7 +144,7 @@ public function indexAction(): void continue; } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $workspace->workspaceName, $authenticatedAccount); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); if (!$permissions->read) { continue; } @@ -160,8 +164,8 @@ classification: $workspaceMetadata->classification->name, public function showAction(WorkspaceName $workspace): void { - $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); - if ($authenticatedAccount === null) { + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { throw new AccessDeniedException('No user authenticated', 1720371024); } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; @@ -179,7 +183,7 @@ public function showAction(WorkspaceName $workspace): void $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName); assert($baseWorkspace !== null); $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); - $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $baseWorkspace->workspaceName, $authenticatedAccount); + $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, @@ -289,11 +293,11 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); - if ($authenticatedAccount === null) { + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { throw new AccessDeniedException('No user is authenticated', 1729620262); } - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspaceName, $authenticatedAccount); + $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); if (!$workspacePermissions->manage) { throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297); } @@ -1008,7 +1012,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { - $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount(); + $currentUser = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); foreach ($workspaces as $workspace) { @@ -1024,11 +1028,7 @@ protected function prepareBaseWorkspaceOptions( if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - if ($authenticatedAccount !== null) { - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspace->workspaceName, $authenticatedAccount); - } else { - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($contentRepository->id, $workspace->workspaceName); - } + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId()); if (!$permissions->manage) { continue; } From 637a9c653df1c4e98103240e461369512ce4e38e Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 11 Nov 2024 15:55:51 +0100 Subject: [PATCH 41/58] Fix role conversion in `ContentRepositoryAuthorizationService` --- .../Authorization/ContentRepositoryAuthorizationService.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index d0027c076a7..0afdb315f02 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -54,13 +54,12 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) { return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value)); } - $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), $roles); + $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), array_values($roles)); $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers); if ($userId !== null) { $subjects[] = WorkspaceRoleSubject::createForUser($userId); } - $userIsAdministrator = array_key_exists(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers); - + $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true); $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { if ($userIsAdministrator) { From 6397ff4ec27caa004cc796122cea44e7bbb8c762 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 11 Nov 2024 17:11:04 +0100 Subject: [PATCH 42/58] Mark `NodePermissions` and `WorkspacePermissions` `@api` --- Neos.Neos/Classes/Domain/Model/NodePermissions.php | 2 +- Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php index 5a2ff8733a8..28300b92af7 100644 --- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -13,7 +13,7 @@ * - read: Permission to read the node and its properties and references * - edit: Permission to change the node * - * @internal + * @api because it is returned by the {@see ContentRepositoryAuthorizationService} */ #[Flow\Proxy(false)] final readonly class NodePermissions diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index 421ed38c2cb..5761d61a74a 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -14,7 +14,7 @@ * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) * - * @internal + * @api because it is returned by the {@see ContentRepositoryAuthorizationService} */ #[Flow\Proxy(false)] final readonly class WorkspacePermissions From 952932700344fbe63c1a516404582c1a054433ea Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:38:13 +0100 Subject: [PATCH 43/58] TASK: Split mighty `WorkspaceService` into `WorkspaceMetadataAndRoleRepository` By moving the db implementation logic in a separate class, the dependency chain is cleaned up, as the `WorkspaceService` would previously hold the cr which would hold the `ContentRepositoryAuthorizationService` which has the `WorkspaceService` Also we didnt want to add further zickzack and previously it would not have been able to acquire the `ContentRepositoryAuthorizationService` without hacks in the `WorkspaceService` to evaluate permissions for `setWorkspaceTitle` or `assignWorkspaceRole` --- .../WorkspaceMetadataAndRoleRepository.php | 314 ++++++++++++++++++ .../Domain/Service/WorkspaceService.php | 265 +-------------- .../ContentRepositoryAuthorizationService.php | 10 +- 3 files changed, 336 insertions(+), 253 deletions(-) create mode 100644 Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php new file mode 100644 index 00000000000..ed6c928f6ca --- /dev/null +++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php @@ -0,0 +1,314 @@ +dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $assignment->subject->type->value, + 'subject' => $assignment->subject->value, + 'role' => $assignment->role->value, + ]); + } catch (UniqueConstraintViolationException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); + } + } + + /** + * The public and documented API is {@see WorkspaceService::unassignWorkspaceRole} + */ + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void + { + try { + $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subject->type->value, + 'subject' => $subject->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); + } + if ($affectedRows === 0) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); + } + } + + public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchAllAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); + } + return WorkspaceRoleAssignments::fromArray( + array_map(static fn (array $row) => WorkspaceRoleAssignment::create( + WorkspaceRoleSubject::create( + WorkspaceRoleSubjectType::from($row['subject_type']), + $row['subject'], + ), + WorkspaceRole::from($row['role']), + ), $rows) + ); + } + + public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole + { + $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; + $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases())); + $query = <<type === WorkspaceRoleSubjectType::GROUP) { + $groupSubjectValues[] = $subject->value; + } else { + $userSubjectValues[] = $subject->value; + } + } + try { + $role = $this->dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, + 'userSubjectValues' => $userSubjectValues, + 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, + 'groupSubjectValues' => $groupSubjectValues, + ], [ + 'userSubjectValues' => ArrayParameterType::STRING, + 'groupSubjectValues' => ArrayParameterType::STRING, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e); + } + if ($role === false) { + return null; + } + return WorkspaceRole::from($role); + } + + /** + * Removes all workspace metadata records for the specified content repository id + */ + public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to prune workspace metadata Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512100, $e); + } + } + + /** + * Removes all workspace role assignments for the specified content repository id + */ + public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to prune workspace roles for Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512142, $e); + } + } + + /** + * The public and documented API is {@see WorkspaceService::getWorkspaceMetadata()} + */ + public function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata + { + $table = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf( + 'Failed to fetch metadata for workspace "%s" (Content Repository "%s), please ensure the database schema is up to date. %s', + $workspaceName->value, + $contentRepositoryId->value, + $e->getMessage() + ), 1727782164, $e); + } + if (!is_array($metadataRow)) { + return null; + } + return new WorkspaceMetadata( + WorkspaceTitle::fromString($metadataRow['title']), + WorkspaceDescription::fromString($metadataRow['description']), + WorkspaceClassification::from($metadataRow['classification']), + $metadataRow['owner_user_id'] !== null ? UserId::fromString($metadataRow['owner_user_id']) : null, + ); + } + + /** + * The public and documented API is {@see WorkspaceService::setWorkspaceTitle()} and {@see WorkspaceService::setWorkspaceDescription()} + */ + public function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, Workspace $workspace, string|null $title, string|null $description): void + { + $data = array_filter([ + 'title' => $title, + 'description' => $description, + ], fn ($value) => $value !== null); + + try { + $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspace->workspaceName->value, + ]); + if ($affectedRows === 0) { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspace->workspaceName->value, + 'description' => '', + 'title' => $workspace->workspaceName->value, + 'classification' => $workspace->isRootWorkspace() ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, + ...$data, + ]); + } + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspace->workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); + } + } + + public function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void + { + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $title->value, + 'description' => $description->value, + 'classification' => $classification->value, + 'owner_user_id' => $ownerUserId?->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); + } + } + + public function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName + { + $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, + 'userId' => $userId->value, + ]); + return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 228605c322b..9aa2c14670c 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,10 +14,6 @@ namespace Neos\Neos\Domain\Service; -use Doctrine\DBAL\ArrayParameterType; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception as DbalException; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -36,9 +32,8 @@ use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjects; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** @@ -49,12 +44,9 @@ #[Flow\Scope('singleton')] final readonly class WorkspaceService { - private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata'; - private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role'; - public function __construct( private ContentRepositoryRegistry $contentRepositoryRegistry, - private Connection $dbal, + private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository ) { } @@ -68,7 +60,7 @@ public function __construct( public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata { $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); - $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + $metadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); return $metadata ?? new WorkspaceMetadata( WorkspaceTitle::fromString($workspaceName->value), WorkspaceDescription::fromString(''), @@ -84,9 +76,8 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'title' => $newWorkspaceTitle->value, - ]); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null); } /** @@ -96,9 +87,8 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'description' => $newWorkspaceDescription->value, - ]); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value); } /** @@ -108,7 +98,7 @@ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId */ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace { - $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); + $workspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); if ($workspaceName === null) { throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); } @@ -129,7 +119,7 @@ public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, Wo ContentStreamId::create() ) ); - $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); } /** @@ -145,7 +135,7 @@ public function createLiveWorkspaceIfMissing(ContentRepositoryId $contentReposit return; } $this->createRootWorkspace($contentRepositoryId, $workspaceName, WorkspaceTitle::fromString('Public live workspace'), WorkspaceDescription::empty()); - $this->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); } /** @@ -170,7 +160,7 @@ public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, */ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void { - $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + $existingWorkspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); if ($existingWorkspaceName !== null) { $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); return; @@ -195,19 +185,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject_type' => $assignment->subject->type->value, - 'subject' => $assignment->subject->value, - 'role' => $assignment->role->value, - ]); - } catch (UniqueConstraintViolationException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); - } + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment); } /** @@ -218,19 +196,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject_type' => $subject->type->value, - 'subject' => $subject->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); - } - if ($affectedRows === 0) { - throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); - } + $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject); } /** @@ -240,93 +206,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { - $table = self::TABLE_NAME_WORKSPACE_ROLE; - $query = <<dbal->fetchAllAssociative($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); - } - return WorkspaceRoleAssignments::fromArray( - array_map(static fn (array $row) => WorkspaceRoleAssignment::create( - WorkspaceRoleSubject::create( - WorkspaceRoleSubjectType::from($row['subject_type']), - $row['subject'], - ), - WorkspaceRole::from($row['role']), - ), $rows) - ); - } - - /** - * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName - * - * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used! - */ - public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole - { - $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; - $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases())); - $query = <<type === WorkspaceRoleSubjectType::GROUP) { - $groupSubjectValues[] = $subject->value; - } else { - $userSubjectValues[] = $subject->value; - } - } - try { - $role = $this->dbal->fetchOne($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, - 'userSubjectValues' => $userSubjectValues, - 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, - 'groupSubjectValues' => $groupSubjectValues, - ], [ - 'userSubjectValues' => ArrayParameterType::STRING, - 'groupSubjectValues' => ArrayParameterType::STRING, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e); - } - if ($role === false) { - return null; - } - return WorkspaceRole::from($role); + return $this->metadataAndRoleRepository->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); } /** @@ -362,13 +242,7 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, */ public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void { - try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to prune workspace metadata Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512100, $e); - } + $this->metadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId); } /** @@ -376,79 +250,11 @@ public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId) */ public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void { - try { - $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to prune workspace roles for Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512142, $e); - } + $this->metadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId); } // ------------------ - private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata - { - $table = self::TABLE_NAME_WORKSPACE_METADATA; - $query = <<dbal->fetchAssociative($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'workspaceName' => $workspaceName->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf( - 'Failed to fetch metadata for workspace "%s" (Content Repository "%s), please ensure the database schema is up to date. %s', - $workspaceName->value, - $contentRepositoryId->value, - $e->getMessage() - ), 1727782164, $e); - } - if (!is_array($metadataRow)) { - return null; - } - return new WorkspaceMetadata( - WorkspaceTitle::fromString($metadataRow['title']), - WorkspaceDescription::fromString($metadataRow['description']), - WorkspaceClassification::from($metadataRow['classification']), - $metadataRow['owner_user_id'] !== null ? UserId::fromString($metadataRow['owner_user_id']) : null, - ); - } - - /** - * @param array $data - */ - private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void - { - $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); - try { - $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - ]); - if ($affectedRows === 0) { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'description' => '', - 'title' => $workspaceName->value, - 'classification' => $workspace->isRootWorkspace() ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, - ...$data, - ]); - } - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); - } - } - private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -459,44 +265,7 @@ private function createWorkspace(ContentRepositoryId $contentRepositoryId, Works ContentStreamId::create() ) ); - $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); - } - - private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void - { - try { - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'title' => $title->value, - 'description' => $description->value, - 'classification' => $classification->value, - 'owner_user_id' => $ownerUserId?->value, - ]); - } catch (DbalException $e) { - throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); - } - } - - private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName - { - $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; - $query = <<dbal->fetchOne($query, [ - 'contentRepositoryId' => $contentRepositoryId->value, - 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, - 'userId' => $userId->value, - ]); - return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); } private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 0afdb315f02..ec3c91a93f3 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -20,7 +20,7 @@ use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjects; -use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege; use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject; @@ -36,7 +36,7 @@ private const ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator'; public function __construct( - private WorkspaceService $workspaceService, + private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository, private PolicyService $policyService, private PrivilegeManagerInterface $privilegeManager, ) { @@ -50,8 +50,8 @@ public function __construct( */ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions { - $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) { + $workspaceMetadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + if ($userId !== null && $workspaceMetadata?->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) { return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value)); } $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), array_values($roles)); @@ -60,7 +60,7 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId $subjects[] = WorkspaceRoleSubject::createForUser($userId); } $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true); - $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); + $userWorkspaceRole = $this->metadataAndRoleRepository->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { if ($userIsAdministrator) { return WorkspacePermissions::manage(sprintf('User is a Neos Administrator without explicit role for workspace "%s"', $workspaceName->value)); From 4f07965b295b93d105214bac67187ec893b34a24 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:52:29 +0100 Subject: [PATCH 44/58] FEATURE: Check permissions for operations inside `WorkspaceService` --- .../Domain/Service/WorkspaceService.php | 27 ++++++++- .../Bootstrap/WorkspaceServiceTrait.php | 57 +++++++++++-------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 9aa2c14670c..969b88f5c79 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -23,6 +23,8 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Security\Context as SecurityContext; +use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -46,7 +48,10 @@ { public function __construct( private ContentRepositoryRegistry $contentRepositoryRegistry, - private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository + private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository, + private UserService $userService, + private ContentRepositoryAuthorizationService $authorizationService, + private SecurityContext $securityContext, ) { } @@ -76,6 +81,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null); } @@ -87,6 +93,7 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value); } @@ -184,6 +191,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con */ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment); } @@ -195,6 +203,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo */ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $this->requireWorkspace($contentRepositoryId, $workspaceName); $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject); } @@ -278,4 +287,20 @@ private function requireWorkspace(ContentRepositoryId $contentRepositoryId, Work } return $workspace; } + + private function requireManagementWorkspacePermission(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return; + } + $workspacePermissions = $this->authorizationService->getWorkspacePermissions( + $contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId() + ); + if (!$workspacePermissions->manage) { + throw new AccessDeniedException(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731343473); + } + } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index 74ba8b77b53..5377bfc6648 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -18,13 +18,12 @@ use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\Flow\Security\Context; +use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; -use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspaceService; @@ -132,11 +131,13 @@ public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspa */ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void { - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceTitle::fromString($newTitle), - )); + $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($newTitle), + )) + ); } /** @@ -144,11 +145,13 @@ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTit */ public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void { - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceDescription::fromString($newDescription), - )); + $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceDescription::fromString($newDescription), + )) + ); } /** @@ -171,14 +174,16 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table */ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void { - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceRoleAssignment::create( - $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), - WorkspaceRole::from($role) - ) - )); + $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceRoleAssignment::create( + $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), + WorkspaceRole::from($role) + ) + )) + ); } /** @@ -187,11 +192,13 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string */ public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void { - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), - )); + $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)), + )) + ); } /** From 99eb4764ac8d4cf7d9152ffea11c616bea948874 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 18:58:08 +0100 Subject: [PATCH 45/58] TASK: Make `pruneWorkspaceMetadata` and `pruneRoleAssignments` internal by not exposing on the `WorkspaceService` They were introduce in #5306 for the pruning, but this is a purely internal task and there is no need for this to be api for the Neos User. Also, the reason is that it cannot be protected with security easily. --- .../Classes/Command/CrCommandController.php | 6 ++++-- .../Classes/Domain/Service/WorkspaceService.php | 16 ---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php index ac92121499a..83c8ce2d539 100644 --- a/Neos.Neos/Classes/Command/CrCommandController.php +++ b/Neos.Neos/Classes/Command/CrCommandController.php @@ -27,6 +27,7 @@ use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Utility\Files; @@ -41,6 +42,7 @@ public function __construct( private readonly ProjectionReplayServiceFactory $projectionReplayServiceFactory, private readonly AssetUsageService $assetUsageService, private readonly WorkspaceService $workspaceService, + private readonly WorkspaceMetadataAndRoleRepository $workspaceMetadataAndRoleRepository, private readonly ProjectionReplayServiceFactory $projectionServiceFactory, ) { parent::__construct(); @@ -162,8 +164,8 @@ public function pruneCommand(string $contentRepository = 'default', bool $force ); // remove the workspace metadata and role assignments for this cr - $this->workspaceService->pruneRoleAssignments($contentRepositoryId); - $this->workspaceService->pruneWorkspaceMetadata($contentRepositoryId); + $this->workspaceMetadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId); + $this->workspaceMetadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId); // reset the events table $contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream(); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 969b88f5c79..07cd7f8f064 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -246,22 +246,6 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } - /** - * Removes all workspace metadata records for the specified content repository id - */ - public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void - { - $this->metadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId); - } - - /** - * Removes all workspace role assignments for the specified content repository id - */ - public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void - { - $this->metadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId); - } - // ------------------ private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void From 139f3eb20200aa9385506f89352e19d59ce813d6 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:34:41 +0100 Subject: [PATCH 46/58] TASK: Adjust docs for privileg and permission v/o --- .../Classes/Feature/Security/Dto/Privilege.php | 3 +++ Neos.Neos/Classes/Domain/Model/NodePermissions.php | 4 +++- Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php index 6712ab9279f..2cda976620d 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php @@ -36,6 +36,9 @@ public static function denied(string $reason): self return new self(false, $reason); } + /** + * Human-readable explanation for why this privilege was evaluated + */ public function getReason(): string { return $this->reason; diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php index 28300b92af7..62201162bba 100644 --- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -21,7 +21,6 @@ /** * @param bool $read Permission to read data from the corresponding node * @param bool $edit Permission to edit the corresponding node - * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()} */ private function __construct( public bool $read, @@ -53,6 +52,9 @@ public static function none(string $reason): self return new self(false, false, $reason); } + /** + * Human-readable explanation for why this permission was evaluated + */ public function getReason(): string { return $this->reason; diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index 5761d61a74a..67f1f970110 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -23,7 +23,6 @@ * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph) * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) - * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()} */ private function __construct( public bool $read, @@ -63,6 +62,9 @@ public static function none(string $reason): self return new self(false, false, false, $reason); } + /** + * Human-readable explanation for why this permission was evaluated + */ public function getReason(): string { return $this->reason; From bf6e4e29cda80b4fd9d8c9f44589abf8f719690e Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:35:18 +0100 Subject: [PATCH 47/58] TASK: inline methods in ContentRepositoryAuthorizationService instead of having them private --- .../ContentRepositoryAuthorizationService.php | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index ec3c91a93f3..6de599cbd7c 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -82,7 +82,14 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId */ public function getNodePermissions(Node $node, array $roles): NodePermissions { - return $this->nodePermissionsForRoles($node, $roles); + $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId); + $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason); + $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason); + return NodePermissions::create( + read: $readGranted, + edit: $writeGranted, + reason: $readReason . "\n" . $writeReason, + ); } /** @@ -91,17 +98,6 @@ public function getNodePermissions(Node $node, array $roles): NodePermissions * @param array $roles */ public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints - { - return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles)); - } - - // ------------------------------ - - - /** - * @param array $roles - */ - private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags { $restrictedSubtreeTags = SubtreeTags::createEmpty(); /** @var ReadNodePrivilege $privilege */ @@ -110,21 +106,6 @@ private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId $restrictedSubtreeTags = $restrictedSubtreeTags->merge($privilege->getSubtreeTags()); } } - return $restrictedSubtreeTags; - } - - /** - * @param array $roles - */ - private function nodePermissionsForRoles(Node $node, array $roles): NodePermissions - { - $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId); - $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason); - $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason); - return NodePermissions::create( - read: $readGranted, - edit: $writeGranted, - reason: $readReason . "\n" . $writeReason, - ); + return VisibilityConstraints::fromTagConstraints($restrictedSubtreeTags); } } From 80d4c31503a37a02529d28d797c05f4fac1892c8 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:35:33 +0100 Subject: [PATCH 48/58] TASK: Remove obsolete warnings in docs --- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 07cd7f8f064..105a3126bff 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -76,8 +76,6 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W /** * Update/set title metadata for the specified workspace - * - * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { @@ -88,8 +86,6 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work /** * Update/set description metadata for the specified workspace - * - * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { From 843ceda60d2bae856437dcd0aed338692ad572ff Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:24:45 +0100 Subject: [PATCH 49/58] TASK: Fix cli output of `workspace:assignrole` to not throw error --- Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index c4ff1597e03..876e5b8d199 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -299,7 +299,7 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro $workspaceRole ) ); - $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject, $workspaceName->value]); + $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]); } /** From abd32d33ce8e3caf407b91799b60584d05582055 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:25:16 +0100 Subject: [PATCH 50/58] TASK: Declare `AbstractSubtreeTagBasedPrivilege` internal as it doesnt make sense to be extended --- .../Privilege/AbstractSubtreeTagBasedPrivilege.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php index e42051d4bc9..227cb75d6aa 100644 --- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -22,7 +22,7 @@ /** * Common base class for privileges that evaluate {@see SubtreeTagPrivilegeSubject}s - * @see ReadNodePrivilege, EditNodePrivilege + * @internal the public API is {@see ReadNodePrivilege, EditNodePrivilege} */ abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege { From 93c1b8bd5b70193391423fa64344f091278100db Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 13 Nov 2024 13:07:40 +0100 Subject: [PATCH 51/58] Add more tests --- .../Security/EditNodePrivilege.feature | 84 ++++++---- .../Security/ReadNodePrivilege.feature | 53 +++++-- .../Security/WorkspaceAccess.feature | 108 ------------- .../Security/WorkspacePermissions.feature | 147 ++++++++++++++++++ 4 files changed, 241 insertions(+), 151 deletions(-) delete mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature index d90d4448763..d279d035287 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -6,15 +6,26 @@ Feature: EditNodePrivilege related features """ privilegeTargets: 'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege': - 'Neos.Neos:EditBlog': - matcher: 'blog' + 'Neos.Neos:EditSubtreeA': + matcher: 'subtree_a' + roles: + 'Neos.Neos:RoleWithPrivilegeToEditSubtree': + privileges: + - + privilegeTarget: 'Neos.Neos:EditSubtreeA' + permission: GRANT """ And using the following content dimensions: | Identifier | Values | Generalizations | | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | And using the following node types: """yaml - 'Neos.Neos:Document': {} + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] """ And using identifier "default", I define a content repository And I am in content repository "default" @@ -41,34 +52,55 @@ Feature: EditNodePrivilege related features | b | Neos.Neos:Document | root | b | {"language":"de"} | | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | And the following Neos users exist: - | Username | First name | Last name | Roles | - | jane.doe | Jane | Doe | Neos.Neos:Administrator | - | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | - | editor | Edward | Editor | Neos.Neos:Editor | - - Scenario: TODO - Given I am in workspace "live" + | Username | First name | Last name | Roles | + | admin | Armin | Admin | Neos.Neos:Administrator | + | restricted_editor | Rich | Restricted | Neos.Neos:RestrictedEditor | + | editor | Edward | Editor | Neos.Neos:Editor | + | editor_with_privilege | Pete | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToEditSubtree | + And I am in workspace "live" And I am in dimension space point {"language":"de"} And the command TagSubtree is executed with payload: | Key | Value | | nodeAggregateId | "a" | | nodeVariantSelectionStrategy | "allSpecializations" | - | tag | "blog" | - And the role MANAGER is assigned to workspace "live" for user "jane.doe" - When content repository security is enabled - And I am authenticated as "jane.doe" - When the command DisableNodeAggregate is executed with payload and exceptions are caught: + | tag | "subtree_a" | + And the command DisableNodeAggregate is executed with payload: | Key | Value | - | nodeAggregateId | "a1a" | + | nodeAggregateId | "a1a1a" | | nodeVariantSelectionStrategy | "allVariants" | + And the role COLLABORATOR is assigned to workspace "live" for group "Neos.Neos:Editor" + When a personal workspace for user "editor" is created + And content repository security is enabled + + Scenario Outline: Handling all relevant EditNodePrivilege related commands with different users + Given I am authenticated as "editor" + When the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "restricted_editor" + When the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "admin" + When the command is executed with payload '' and exceptions are caught Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 -# Then the last command should have thrown an exception of type "AccessDenied" with message: -# """ -# Command "Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate" was denied: No edit permissions for node "a1a" in workspace "live": Evaluated following 2 privilege target(s): -# "Neos.Neos:ReadBlog": ABSTAIN -# "Neos.Neos:ReadBlog": GRANT -# (1 granted, 0 denied, 1 abstained) -# Evaluated following 1 privilege target(s): -# "Neos.Neos:EditBlog": ABSTAIN -# (0 granted, 0 denied, 1 abstained) -# """ + + When I am authenticated as "editor_with_privilege" + And the command is executed with payload '' + + When I am in workspace "edward-editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | command | command payload | + | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"} | + | CreateNodeVariant | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"en"}} | + | DisableNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | EnableNodeAggregate | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"} | + | RemoveNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | TagSubtree | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"} | + | UntagSubtree | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"} | + | MoveNodeAggregate | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"} | + | SetNodeProperties | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}} | + | SetNodeReferences | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature index f30fc37739f..2545f9c7bac 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -6,12 +6,13 @@ Feature: ReadNodePrivilege related features """ privilegeTargets: 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': - 'Neos.Neos:ReadBlog': - matcher: 'blog' + 'Neos.Neos:ReadSubtreeA': + matcher: 'subtree_a' roles: - 'Neos.Neos:Administrator': + 'Neos.Neos:RoleWithPrivilegeToReadSubtree': privileges: - - privilegeTarget: 'Neos.Neos:ReadBlog' + - + privilegeTarget: 'Neos.Neos:ReadSubtreeA' permission: GRANT """ And using the following content dimensions: @@ -19,11 +20,15 @@ Feature: ReadNodePrivilege related features | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | And using the following node types: """yaml - 'Neos.Neos:Document': {} + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] """ And using identifier "default", I define a content repository And I am in content repository "default" - And I am user identified by "initiating-user-identifier" And the command CreateRootWorkspace is executed with payload: | Key | Value | | workspaceName | "live" | @@ -47,22 +52,36 @@ Feature: ReadNodePrivilege related features | b | Neos.Neos:Document | root | b | {"language":"de"} | | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | And the following Neos users exist: - | Id | Username | First name | Last name | Roles | - | janedoe | jane.doe | Jane | Doe | Neos.Neos:Administrator | - | johndoe | john.doe | John | Doe | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | - | editor | editor | Edward | Editor | Neos.Neos:Editor | - - Scenario: TODO - Given I am in workspace "live" + | Username | First name | Last name | Roles | + | admin | Armin | Admin | Neos.Neos:Administrator | + | restricted_editor | Rich | Restricted | Neos.Neos:RestrictedEditor | + | editor | Edward | Editor | Neos.Neos:Editor | + | editor_with_privilege | Pete | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToReadSubtree | + And I am in workspace "live" And I am in dimension space point {"language":"de"} And the command TagSubtree is executed with payload: | Key | Value | | nodeAggregateId | "a" | | nodeVariantSelectionStrategy | "allSpecializations" | - | tag | "blog" | + | tag | "subtree_a" | And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" - When content repository security is enabled - And I am authenticated as "john.doe" + When a personal workspace for user "editor" is created + And content repository security is enabled + + Scenario Outline: Read tagged node as user without corresponding ReadNodePrivilege + And I am authenticated as "" Then I should not be able to read node "a1" - When I am authenticated as "jane.doe" + + Examples: + | user | + | admin | + | restricted_editor | + | editor | + + Scenario Outline: Read tagged node as user with corresponding ReadNodePrivilege + And I am authenticated as "" Then I should be able to read node "a1" + + Examples: + | user | + | editor_with_privilege | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature deleted file mode 100644 index 473901a678f..00000000000 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature +++ /dev/null @@ -1,108 +0,0 @@ -@flowEntities -Feature: Workspace access related features - - Background: - Given The following additional policies are configured: - """ - privilegeTargets: - 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': - 'Neos.Neos:ReadBlog': - matcher: 'blog' - roles: - 'Neos.Neos:Administrator': - privileges: - - privilegeTarget: 'Neos.Neos:ReadBlog' - permission: GRANT - """ - And using the following content dimensions: - | Identifier | Values | Generalizations | - | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | - And using the following node types: - """yaml - 'Neos.Neos:Document': {} - """ - And using identifier "default", I define a content repository - And I am in content repository "default" - And I am user identified by "initiating-user-identifier" - And the command CreateRootWorkspace is executed with payload: - | Key | Value | - | workspaceName | "live" | - | newContentStreamId | "cs-identifier" | - And I am in workspace "live" and dimension space point {} - And the following Neos users exist: - | Username | Roles | - | admin | Neos.Neos:Administrator | - | editor | Neos.Neos:Editor | - | restricted_editor | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager | - | no_editor | | - - Scenario: TODO - When content repository security is enabled - And I am authenticated as "admin" - And I access the content graph for workspace "live" - Then an exception of type "AccessDenied" should be thrown with message: - """ - Read access denied for workspace "live": Account "admin" is a Neos Administrator without explicit role for workspace "live" - """ - - Scenario: TODO - Given the role MANAGER is assigned to workspace "live" for user "admin" - When content repository security is enabled - And I am authenticated as "admin" - And I access the content graph for workspace "live" - Then no exception should be thrown - - Scenario Outline: Accessing content graph for explicitly assigned workspace role to the authenticated user - Given the role is assigned to workspace "live" for user "" - When content repository security is enabled - And I am authenticated as "" - And I access the content graph for workspace "live" - Then no exception should be thrown - - Examples: - | user | workspace role | - | admin | VIEWER | - | editor | COLLABORATOR | - | editor | VIEWER | - | restricted_editor | MANAGER | - | restricted_editor | VIEWER | - - Scenario Outline: Accessing content graph for workspace role assigned to group of the authenticated user - Given the role is assigned to workspace "live" for group "" - When content repository security is enabled - And I am authenticated as "" - And I access the content graph for workspace "live" - Then no exception should be thrown - - Examples: - | user | group | workspace role | - | admin | Neos.Neos:Editor | COLLABORATOR | - | editor | Neos.Neos:Editor | COLLABORATOR | - | restricted_editor | Neos.Neos:RestrictedEditor | VIEWER | - | no_editor | Neos.Flow:Everybody | VIEWER | - - Scenario Outline: Accessing content graph for workspace role assigned to group the authenticated user is not part of - Given the role is assigned to workspace "live" for group "" - When content repository security is enabled - And I am authenticated as "" - And I access the content graph for workspace "live" - Then an exception of type "AccessDenied" should be thrown - - Examples: - | user | group | workspace role | - | admin | Neos.Flow:Anonymous | COLLABORATOR | - | editor | Neos.Neos:Administrator | MANAGER | - | restricted_editor | Neos.Neos:Editor | VIEWER | - - Scenario Outline: Accessing content graph for workspace that is owned by the authenticated user - Given the personal workspace "user-workspace" is created with the target workspace "live" for user "" - When content repository security is enabled - And I am authenticated as "" - And I access the content graph for workspace "user-workspace" - Then no exception should be thrown - - Examples: - | user | - | admin | - | editor | - | restricted_editor | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature new file mode 100644 index 00000000000..b7ba9bfb5cc --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature @@ -0,0 +1,147 @@ +@flowEntities +Feature: Workspace permission related features + + Background: + When using the following content dimensions: + | Identifier | Values | Generalizations | + | language | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul | + And using the following node types: + """yaml + 'Neos.Neos:Document': + properties: + foo: + type: string + references: + ref: [] + 'Neos.Neos:Document2': {} + 'Neos.Neos:CustomRoot': + superTypes: + 'Neos.ContentRepository:Root': true + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And the command CreateRootWorkspace is executed with payload: + | Key | Value | + | workspaceName | "live" | + | newContentStreamId | "cs-identifier" | + And I am in workspace "live" and dimension space point {} + And the command CreateRootNodeAggregateWithNode is executed with payload: + | Key | Value | + | nodeAggregateId | "root" | + | nodeTypeName | "Neos.ContentRepository:Root" | + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | nodeName | originDimensionSpacePoint | + | a | Neos.Neos:Document | root | a | {"language":"mul"} | + | a1 | Neos.Neos:Document | a | a1 | {"language":"de"} | + | a1a | Neos.Neos:Document | a1 | a1a | {"language":"de"} | + | a1a1 | Neos.Neos:Document | a1a | a1a1 | {"language":"de"} | + | a1a1a | Neos.Neos:Document | a1a1 | a1a1a | {"language":"de"} | + | a1a1b | Neos.Neos:Document | a1a1 | a1a1b | {"language":"de"} | + | a1a2 | Neos.Neos:Document | a1a | a1a2 | {"language":"de"} | + | a1b | Neos.Neos:Document | a1 | a1b | {"language":"de"} | + | a2 | Neos.Neos:Document | a | a2 | {"language":"de"} | + | b | Neos.Neos:Document | root | b | {"language":"de"} | + | b1 | Neos.Neos:Document | b | b1 | {"language":"de"} | + And the following Neos users exist: + | Username | Roles | + | admin | Neos.Neos:Administrator | + | editor | Neos.Neos:Editor | + | restricted_editor | Neos.Neos:RestrictedEditor | + | owner | Neos.Neos:Editor | + | manager | Neos.Neos:Editor | + | collaborator | Neos.Neos:Editor | + | uninvolved | Neos.Neos:Editor | + And I am in workspace "live" + And I am in dimension space point {"language":"de"} + And the command TagSubtree is executed with payload: + | Key | Value | + | nodeAggregateId | "a" | + | nodeVariantSelectionStrategy | "allSpecializations" | + | tag | "subtree_a" | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | nodeAggregateId | "a1a1a" | + | nodeVariantSelectionStrategy | "allVariants" | + And the personal workspace "workspace" is created with the target workspace "live" for user "owner" + And I am in workspace "workspace" + And the role MANAGER is assigned to workspace "workspace" for user "manager" + And the role COLLABORATOR is assigned to workspace "workspace" for user "collaborator" + # The following step was added in order to make the `AddDimensionShineThrough` command viable + And I change the content dimensions in content repository "default" to: + | Identifier | Values | Generalizations | + | language | mul, de, ch | ch->de->mul | + And content repository security is enabled + + Scenario Outline: Creating a root workspace + Given I am authenticated as + When the command CreateRootWorkspace is executed with payload '{"workspaceName":"new-ws","newContentStreamId":"new-cs"}' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | admin | + | editor | + | restricted_editor | + + Scenario Outline: Deleting a workspace without MANAGE permissions + Given I am authenticated as + When the command DeleteWorkspace is executed with payload '{"workspaceName":"workspace"}' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | collaborator | + | uninvolved | + + Scenario Outline: Deleting a workspace with MANAGE permissions + Given I am authenticated as + When the command DeleteWorkspace is executed with payload '{"workspaceName":"workspace"}' + + Examples: + | user | + | admin | + | manager | + | owner | + + Scenario Outline: Handling commands that require WRITE permissions on the workspace + When I am authenticated as "editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "restricted_editor" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "admin" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + When I am authenticated as "owner" + And the command is executed with payload '' + + Examples: + | command | command payload | + | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"} | + | CreateNodeVariant | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"mul"}} | + | DisableNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | EnableNodeAggregate | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"} | + | RemoveNodeAggregate | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"} | + | TagSubtree | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"} | + | UntagSubtree | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"} | + | MoveNodeAggregate | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"} | + | SetNodeProperties | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}} | + | SetNodeReferences | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} | + + | AddDimensionShineThrough | {"nodeAggregateId":"a1","source":{"language":"de"},"target":{"language":"ch"}} | + | ChangeNodeAggregateName | {"nodeAggregateId":"a1","newNodeName":"changed"} | + | ChangeNodeAggregateType | {"nodeAggregateId":"a1","newNodeTypeName":"Neos.Neos:Document2","strategy":"happypath"} | + | CreateRootNodeAggregateWithNode | {"nodeAggregateId":"c","nodeTypeName":"Neos.Neos:CustomRoot"} | + | MoveDimensionSpacePoint | {"source":{"language":"de"},"target":{"language":"ch"}} | + | UpdateRootNodeAggregateDimensions | {"nodeAggregateId":"root"} | + | DiscardWorkspace | {} | + | DiscardIndividualNodesFromWorkspace | {"nodesToDiscard":[{"nodeAggregateId":"a1"}]} | + | PublishWorkspace | {} | + | PublishIndividualNodesFromWorkspace | {"nodesToPublish":[{"nodeAggregateId":"a1"}]} | + | RebaseWorkspace | {} | + | CreateWorkspace | {"workspaceName":"new-workspace","baseWorkspaceName":"workspace","newContentStreamId":"any"} | + From 810b0a3d430b878db4044ca649a7643cfd0362e3 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 13 Nov 2024 13:10:29 +0100 Subject: [PATCH 52/58] Prefix ContentRepositorySecurityTrait fields to avoid naming clashes --- .../ContentRepositorySecurityTrait.php | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php index c8bc89a542e..188186397ae 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -54,14 +54,14 @@ trait ContentRepositorySecurityTrait use CRBehavioralTestsSubjectProvider; use ExceptionsTrait; - private bool $flowSecurityEnabled = false; - private bool $contentRepositorySecurityEnabled = false; + private bool $crSecurity_flowSecurityEnabled = false; + private bool $crSecurity_contentRepositorySecurityEnabled = false; - private ?TestingProvider $testingProvider = null; + private ?TestingProvider $crSecurity_testingProvider = null; - private ?ActionRequest $mockActionRequest = null; + private ?ActionRequest $crSecurity_mockActionRequest = null; - private static ?string $testingPolicyPathAndFilename = null; + private static ?string $crSecurity_testingPolicyPathAndFilename = null; /** * @template T of object @@ -74,40 +74,40 @@ abstract private function getObject(string $className): object; public function resetContentRepositorySecurity(): void { TestingAuthProvider::resetAuthProvider(); - $this->contentRepositorySecurityEnabled = false; + $this->crSecurity_contentRepositorySecurityEnabled = false; } #[BeforeFeature] #[AfterFeature] public static function resetPolicies(): void { - if (self::$testingPolicyPathAndFilename !== null && file_exists(self::$testingPolicyPathAndFilename)) { - unlink(self::$testingPolicyPathAndFilename); + if (self::$crSecurity_testingPolicyPathAndFilename !== null && file_exists(self::$crSecurity_testingPolicyPathAndFilename)) { + unlink(self::$crSecurity_testingPolicyPathAndFilename); } } private function enableFlowSecurity(): void { - if ($this->flowSecurityEnabled === true) { + if ($this->crSecurity_flowSecurityEnabled === true) { return; } $this->getObject(PrivilegeManagerInterface::class)->reset(); $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class); - $this->testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; + $this->crSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; $securityContext = $this->getObject(SecurityContext::class); $securityContext->clearContext(); $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); - $this->mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); - $securityContext->setRequest($this->mockActionRequest); - $this->flowSecurityEnabled = true; + $this->crSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); + $securityContext->setRequest($this->crSecurity_mockActionRequest); + $this->crSecurity_flowSecurityEnabled = true; } private function enableContentRepositorySecurity(): void { - if ($this->contentRepositorySecurityEnabled === true) { + if ($this->crSecurity_contentRepositorySecurityEnabled === true) { return; } $contentRepositoryAuthProviderFactory = $this->getObject(ContentRepositoryAuthProviderFactory::class); @@ -126,18 +126,18 @@ public function __construct( $contentRepositoryAuthProvider = $contentRepositoryAuthProviderFactory->build($this->currentContentRepository->id, $contentGraphProjection->getState()); TestingAuthProvider::replaceAuthProvider($contentRepositoryAuthProvider); - $this->contentRepositorySecurityEnabled = true; + $this->crSecurity_contentRepositorySecurityEnabled = true; } private function authenticateAccount(Account $account): void { $this->enableFlowSecurity(); - $this->testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); - $this->testingProvider->setAccount($account); + $this->crSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->crSecurity_testingProvider->setAccount($account); $securityContext = $this->getObject(SecurityContext::class); $securityContext->clearContext(); - $securityContext->setRequest($this->mockActionRequest); + $securityContext->setRequest($this->crSecurity_mockActionRequest); $this->getObject(AuthenticationProviderManager::class)->authenticate(); } @@ -161,8 +161,8 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true); $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw())); - self::$testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml'; - file_put_contents(self::$testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration)); + self::$crSecurity_testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml'; + file_put_contents(self::$crSecurity_testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration)); ObjectAccess::setProperty($policyService, 'initialized', false, true); $this->getObject(ConfigurationManager::class)->flushConfigurationCache(); From 33c0877d1e33af4d25e949361cf269c3f8518444 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 07:58:38 +0100 Subject: [PATCH 53/58] TASK: Fix new Neos security tests and fake `PolicyService` correctly - we must not write the settings to the file system via $crSecurity_testingPolicyPathAndFilename as following processes and tests in the same context would be affected. Also flushing and reloading the configuration catch (`flushConfigurationCache`) is expensive and the tests must not mess with that low level flow behaviour - instead we only rely on faking the `PolicyService`'s runtime caches. This is done by injecting a custom configuration into `$policyConfiguration` and then initialising the `PolicyService` --- .../ContentRepositorySecurityTrait.php | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php index 188186397ae..7260cd10844 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -61,8 +61,6 @@ trait ContentRepositorySecurityTrait private ?ActionRequest $crSecurity_mockActionRequest = null; - private static ?string $crSecurity_testingPolicyPathAndFilename = null; - /** * @template T of object * @param class-string $className @@ -75,15 +73,14 @@ public function resetContentRepositorySecurity(): void { TestingAuthProvider::resetAuthProvider(); $this->crSecurity_contentRepositorySecurityEnabled = false; - } + $this->crSecurity_flowSecurityEnabled = false; + + $policyService = $this->getObject(PolicyService::class); + // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager) + $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class)); - #[BeforeFeature] - #[AfterFeature] - public static function resetPolicies(): void - { - if (self::$crSecurity_testingPolicyPathAndFilename !== null && file_exists(self::$crSecurity_testingPolicyPathAndFilename)) { - unlink(self::$crSecurity_testingPolicyPathAndFilename); - } } private function enableFlowSecurity(): void @@ -157,15 +154,30 @@ public function contentRepositorySecurityIsEnabled(): void public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void { $policyService = $this->getObject(PolicyService::class); - $policyService->getRoles(); // force initialization - $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true); - $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw())); - self::$crSecurity_testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml'; - file_put_contents(self::$crSecurity_testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration)); + $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule( + $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY), + Yaml::parse($policies->getRaw()) + ); + + // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used) + // we can change the roles and privileges at runtime :D + $policyService->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager + { + public function __construct( + private array $mergedPolicyConfiguration + ) { + } - ObjectAccess::setProperty($policyService, 'initialized', false, true); - $this->getObject(ConfigurationManager::class)->flushConfigurationCache(); + public function getConfiguration(string $configurationType, string $configurationPath = null) + { + Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType); + Assert::assertSame(null, $configurationPath); + return $this->mergedPolicyConfiguration; + } + }); } /** From 3e73e68de492e2cdef7b495ac01a2cf53f254a36 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:06:41 +0100 Subject: [PATCH 54/58] TASK: Split `ContentRepositorySecurityTrait` and `FlowSecurityTrait` --- .../ContentRepositorySecurityTrait.php | 95 +------------ .../Features/Bootstrap/FlowSecurityTrait.php | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 91 deletions(-) create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php index 7260cd10844..86fc2bb454e 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -12,9 +12,6 @@ * source code. */ -use Behat\Gherkin\Node\PyStringNode; -use Behat\Hook\AfterFeature; -use Behat\Hook\BeforeFeature; use Behat\Hook\BeforeScenario; use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider; use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies; @@ -24,25 +21,11 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider; -use Neos\Flow\Configuration\ConfigurationManager; use Neos\Flow\Mvc\ActionRequest; -use Neos\Flow\Security\Account; -use Neos\Flow\Security\Authentication\AuthenticationProviderManager; use Neos\Flow\Security\Authentication\Provider\TestingProvider; -use Neos\Flow\Security\Authentication\TokenAndProviderFactoryInterface; -use Neos\Flow\Security\Authentication\TokenInterface; -use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; -use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Policy\PolicyService; -use Neos\Flow\Utility\Environment; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege; use Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory; -use Neos\Utility\Arrays; -use Neos\Utility\ObjectAccess; use PHPUnit\Framework\Assert; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Symfony\Component\Yaml\Yaml; /** * Step implementations and helper for Content Repository Security related tests inside Neos.Neos @@ -53,6 +36,7 @@ trait ContentRepositorySecurityTrait { use CRBehavioralTestsSubjectProvider; use ExceptionsTrait; + use FlowSecurityTrait; private bool $crSecurity_flowSecurityEnabled = false; private bool $crSecurity_contentRepositorySecurityEnabled = false; @@ -68,38 +52,13 @@ trait ContentRepositorySecurityTrait */ abstract private function getObject(string $className): object; - #[BeforeScenario] + /** + * @BeforeScenario + */ public function resetContentRepositorySecurity(): void { TestingAuthProvider::resetAuthProvider(); $this->crSecurity_contentRepositorySecurityEnabled = false; - $this->crSecurity_flowSecurityEnabled = false; - - $policyService = $this->getObject(PolicyService::class); - // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager) - $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset() - ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); - $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class)); - - } - - private function enableFlowSecurity(): void - { - if ($this->crSecurity_flowSecurityEnabled === true) { - return; - } - $this->getObject(PrivilegeManagerInterface::class)->reset(); - - $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class); - - $this->crSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; - - $securityContext = $this->getObject(SecurityContext::class); - $securityContext->clearContext(); - $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); - $this->crSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); - $securityContext->setRequest($this->crSecurity_mockActionRequest); - $this->crSecurity_flowSecurityEnabled = true; } private function enableContentRepositorySecurity(): void @@ -126,18 +85,6 @@ public function __construct( $this->crSecurity_contentRepositorySecurityEnabled = true; } - private function authenticateAccount(Account $account): void - { - $this->enableFlowSecurity(); - $this->crSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); - $this->crSecurity_testingProvider->setAccount($account); - - $securityContext = $this->getObject(SecurityContext::class); - $securityContext->clearContext(); - $securityContext->setRequest($this->crSecurity_mockActionRequest); - $this->getObject(AuthenticationProviderManager::class)->authenticate(); - } - /** * @Given content repository security is enabled */ @@ -147,39 +94,6 @@ public function contentRepositorySecurityIsEnabled(): void $this->enableContentRepositorySecurity(); } - - /** - * @Given The following additional policies are configured: - */ - public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void - { - $policyService = $this->getObject(PolicyService::class); - - $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule( - $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY), - Yaml::parse($policies->getRaw()) - ); - - // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used) - // we can change the roles and privileges at runtime :D - $policyService->reset(); // TODO also reset privilegeTargets in ->reset() - ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); - $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager - { - public function __construct( - private array $mergedPolicyConfiguration - ) { - } - - public function getConfiguration(string $configurationType, string $configurationPath = null) - { - Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType); - Assert::assertSame(null, $configurationPath); - return $this->mergedPolicyConfiguration; - } - }); - } - /** * @When I am authenticated as :username */ @@ -189,7 +103,6 @@ public function iAmAuthenticatedAs(string $username): void $this->authenticateAccount($user->getAccounts()->first()); } - /** * @When I access the content graph for workspace :workspaceName */ diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php new file mode 100644 index 00000000000..9e2123e3c70 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php @@ -0,0 +1,132 @@ + $className + * @return T + */ + abstract protected function getObject(string $className): object; + + /** + * @BeforeScenario + */ + final public function resetFlowSecurity(): void + { + $this->flowSecurity_securityEnabled = false; + + $policyService = $this->getObject(PolicyService::class); + // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager) + $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class)); + + $this->getObject(SecurityContext::class)->clearContext(); + $this->getObject(PrivilegeManagerInterface::class)->reset(); + } + + final protected function enableFlowSecurity(): void + { + if ($this->flowSecurity_securityEnabled === true) { + return; + } + + $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class); + + $this->flowSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; + + $securityContext = $this->getObject(SecurityContext::class); + $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); + $this->flowSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); + $securityContext->setRequest($this->flowSecurity_mockActionRequest); + $this->flowSecurity_securityEnabled = true; + } + + final protected function authenticateAccount(Account $account): void + { + $this->enableFlowSecurity(); + $this->flowSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL); + $this->flowSecurity_testingProvider->setAccount($account); + + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + $securityContext->setRequest($this->flowSecurity_mockActionRequest); + $this->getObject(AuthenticationProviderManager::class)->authenticate(); + } + + /** + * @Given The following additional policies are configured: + */ + final public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void + { + $policyService = $this->getObject(PolicyService::class); + + $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule( + $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY), + Yaml::parse($policies->getRaw()) + ); + + // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used) + // we can change the roles and privileges at runtime :D + $policyService->reset(); // TODO also reset privilegeTargets in ->reset() + ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); + $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager + { + public function __construct( + private array $mergedPolicyConfiguration + ) { + } + + public function getConfiguration(string $configurationType, string $configurationPath = null) + { + Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType); + Assert::assertSame(null, $configurationPath); + return $this->mergedPolicyConfiguration; + } + }); + } +} From db43d1f26afbb8d229adcf6c77c22a4791dfba98 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:08:04 +0100 Subject: [PATCH 55/58] TASK: Cleanup imports --- .../ContentRepositoryAuthProvider.php | 5 ----- .../ContentRepositoryAuthProviderFactory.php | 1 - 2 files changed, 6 deletions(-) diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 0ece429d71f..6bd844d8a5a 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -8,15 +8,12 @@ use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough; use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode; -use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively; use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; -use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties; use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; @@ -38,13 +35,11 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; -use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Neos\Domain\Model\NodePermissions; use Neos\Neos\Domain\Model\WorkspacePermissions; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php index cf3d2fb9ac5..cc2ebb54dab 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -4,7 +4,6 @@ namespace Neos\Neos\Security\ContentRepositoryAuthProvider; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; From f1415b8301266d0fa6f0af7424eadea2fe5b3669 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:19:51 +0100 Subject: [PATCH 56/58] TASK: Cleanup imports Now we throw the cr AccessDenied exception instead of flows access denied in all places of the workspace service to make behaviour consistent. $contentRepository->handle might for example throw the `AccessDenied` when creating a workspace --- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 105a3126bff..95be477088c 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,6 +14,7 @@ namespace Neos\Neos\Domain\Service; +use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -24,7 +25,6 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context as SecurityContext; -use Neos\Flow\Security\Exception\AccessDeniedException; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -280,7 +280,7 @@ private function requireManagementWorkspacePermission(ContentRepositoryId $conte $this->userService->getCurrentUser()?->getId() ); if (!$workspacePermissions->manage) { - throw new AccessDeniedException(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731343473); + throw new AccessDenied(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731654519); } } } From efca1247f240b9d78756dca0cd7827ab08e7a9b0 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:20:34 +0100 Subject: [PATCH 57/58] TASK: Remove `withoutAuthorizationChecks` in tests And introduce the behaviour of the `FunctionalTestCase`: https://github.com/neos/flow-development-collection/commit/b9c89e3e08649cbb5366cb769b2f79b0f13bd68e --- .../Features/Bootstrap/FlowSecurityTrait.php | 5 +- .../Bootstrap/WorkspaceServiceTrait.php | 54 ++++++++----------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php index 9e2123e3c70..5f4915dfcf1 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php @@ -65,7 +65,10 @@ final public function resetFlowSecurity(): void ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true); $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class)); - $this->getObject(SecurityContext::class)->clearContext(); + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + // todo add setter! Also used in FunctionalTestCase https://github.com/neos/flow-development-collection/commit/b9c89e3e08649cbb5366cb769b2f79b0f13bd68e + ObjectAccess::setProperty($securityContext, 'authorizationChecksDisabled', true, true); $this->getObject(PrivilegeManagerInterface::class)->reset(); } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index e1a3ece74af..af046a39443 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -132,13 +132,11 @@ public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspa */ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void { - $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceTitle::fromString($newTitle), - )) - ); + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($newTitle), + )); } /** @@ -146,13 +144,11 @@ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTit */ public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void { - $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceDescription::fromString($newDescription), - )) - ); + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceDescription::fromString($newDescription), + )); } /** @@ -180,16 +176,14 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string } else { $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); } - $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - WorkspaceRoleAssignment::create( - $subject, - WorkspaceRole::from($role) - ) - )) - ); + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceRoleAssignment::create( + $subject, + WorkspaceRole::from($role) + ) + )); } /** @@ -203,13 +197,11 @@ public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $ } else { $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username)); } - $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () => - $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( - $this->currentContentRepository->id, - WorkspaceName::fromString($workspaceName), - $subject, - )) - ); + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $subject, + )); } /** From 8768298c1ed45802094fc59be20e1e82c9c4a46f Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 15 Nov 2024 08:54:05 +0100 Subject: [PATCH 58/58] TASK: Add security tests for workspace service assert that base workspace creation is only allowed for writing and that managing (setting title and roles) is only allowed to managers --- .../Domain/Service/WorkspaceService.php | 2 +- .../Features/Bootstrap/ExceptionsTrait.php | 6 +- .../Security/WorkspacePermissions.feature | 69 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 95be477088c..b4021fae155 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -280,7 +280,7 @@ private function requireManagementWorkspacePermission(ContentRepositoryId $conte $this->userService->getCurrentUser()?->getId() ); if (!$workspacePermissions->manage) { - throw new AccessDenied(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731654519); + throw new AccessDenied(sprintf('Managing workspace "%s" in "%s" was denied: %s', $workspaceName->value, $contentRepositoryId->value, $workspacePermissions->getReason()), 1731654519); } } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php index 5a87e107249..b2257a5c4d2 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -38,10 +38,11 @@ private function tryCatchingExceptions(\Closure $callback): mixed } /** + * @Then an exception of type :expectedShortExceptionName should be thrown with code :code * @Then an exception of type :expectedShortExceptionName should be thrown with message: * @Then an exception of type :expectedShortExceptionName should be thrown */ - public function anExceptionShouldBeThrown(string $expectedShortExceptionName, PyStringNode $expectedExceptionMessage = null): void + public function anExceptionShouldBeThrown(string $expectedShortExceptionName, ?int $code = null, PyStringNode $expectedExceptionMessage = null): void { Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown'); $lastCaughtExceptionShortName = (new \ReflectionClass($this->lastCaughtException))->getShortName(); @@ -49,6 +50,9 @@ public function anExceptionShouldBeThrown(string $expectedShortExceptionName, Py if ($expectedExceptionMessage !== null) { Assert::assertSame($expectedExceptionMessage->getRaw(), $this->lastCaughtException->getMessage()); } + if ($code !== null) { + Assert::assertSame($code, $this->lastCaughtException->getCode()); + } $this->lastCaughtException = null; } diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature index b7ba9bfb5cc..7b02cca647b 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature @@ -82,6 +82,35 @@ Feature: Workspace permission related features | admin | | editor | | restricted_editor | + | owner | + | collaborator | + | uninvolved | + + Scenario Outline: Creating a base workspace without WRITE permissions + Given I am authenticated as + And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" + Then an exception of type "AccessDenied" should be thrown with code 1729086686 + + And the personal workspace "some-other-personal-workspace" is created with the target workspace "workspace" for user + Then an exception of type "AccessDenied" should be thrown with code 1729086686 + + Examples: + | user | + | admin | + | editor | + | restricted_editor | + | uninvolved | + + Scenario Outline: Creating a base workspace with WRITE permissions + Given I am authenticated as + And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" + + And the personal workspace "some-other-personal-workspace" is created with the target workspace "workspace" for user + + Examples: + | user | + | collaborator | + | owner | Scenario Outline: Deleting a workspace without MANAGE permissions Given I am authenticated as @@ -103,7 +132,43 @@ Feature: Workspace permission related features | manager | | owner | + Scenario Outline: Managing metadata and roles of a workspace without MANAGE permissions + Given I am authenticated as + And the title of workspace "workspace" is set to "Some new workspace title" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + And the description of workspace "workspace" is set to "Some new workspace description" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + When the role COLLABORATOR is assigned to workspace "workspace" for group "Neos.Neos:AbstractEditor" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "workspace" + Then an exception of type "AccessDenied" should be thrown with code 1731654519 + + Examples: + | user | + | collaborator | + | uninvolved | + + Scenario Outline: Managing metadata and roles of a workspace with MANAGE permissions + Given I am authenticated as + And the title of workspace "workspace" is set to "Some new workspace title" + And the description of workspace "workspace" is set to "Some new workspace description" + When the role COLLABORATOR is assigned to workspace "workspace" for group "Neos.Neos:AbstractEditor" + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "workspace" + + Examples: + | user | + | admin | + | manager | + | owner | + Scenario Outline: Handling commands that require WRITE permissions on the workspace + When I am authenticated as "uninvolved" + And the command is executed with payload '' and exceptions are caught + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + When I am authenticated as "editor" And the command is executed with payload '' and exceptions are caught Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 @@ -119,6 +184,10 @@ Feature: Workspace permission related features When I am authenticated as "owner" And the command is executed with payload '' + # todo test also collaborator, but cannot commands twice here: + # When I am authenticated as "collaborator" + # And the command is executed with payload '' and exceptions are caught + Examples: | command | command payload | | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"} |