diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php deleted file mode 100644 index 6a0a5c7a408..00000000000 --- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php +++ /dev/null @@ -1,21 +0,0 @@ - $options - */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new FakeUserIdProvider(); - } -} diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php new file mode 100644 index 00000000000..0fb820d5f03 --- /dev/null +++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php @@ -0,0 +1,18 @@ +commandHook->onBeforeHandle($command); + $privilege = $this->authProvider->canExecuteCommand($command); + if (!$privilege->granted) { + throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->getReason()); + } $toPublish = $this->commandBus->handle($command); @@ -263,12 +271,31 @@ 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->canReadNodesFromWorkspace($workspaceName); + if (!$privilege->granted) { + throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->getReason()); + } return $this->contentGraphReadModel->getContentGraph($workspaceName); } + /** + * 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}) + */ + public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface + { + $contentGraph = $this->getContentGraph($workspaceName); + $visibilityConstraints = $this->authProvider->getVisibilityConstraints($workspaceName); + return $contentGraph->getSubgraph($dimensionSpacePoint, $visibilityConstraints); + } + /** * Returns the workspace with the given name, or NULL if it does not exist in this content repository */ @@ -313,7 +340,7 @@ public function getContentDimensionSource(): ContentDimensionSourceInterface private function enrichEventsToPublishWithMetadata(EventsToPublish $eventsToPublish): EventsToPublish { - $initiatingUserId = $this->userIdProvider->getUserId(); + $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser(); $initiatingTimestamp = $this->clock->now(); return new EventsToPublish( diff --git a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php index 87be6d0b2cd..260226553e2 100644 --- a/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php +++ b/Neos.ContentRepository.Core/Classes/EventStore/InitiatingEventMetadata.php @@ -4,7 +4,7 @@ namespace Neos\ContentRepository\Core\EventStore; -use Neos\ContentRepository\Core\SharedModel\User\UserId; +use Neos\ContentRepository\Core\Feature\Security\Dto\UserId; use Neos\EventStore\Model\Event\EventMetadata; /** diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php index 1268c604ca0..09c51da4293 100644 --- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php +++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php @@ -15,9 +15,8 @@ namespace Neos\ContentRepository\Core\Factory; use Neos\ContentRepository\Core\CommandHandler\CommandBus; -use Neos\ContentRepository\Core\CommandHandler\CommandHooks; -use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; use Neos\ContentRepository\Core\CommandHandler\CommandHandlingDependencies; +use Neos\ContentRepository\Core\CommandHandler\CommandSimulatorFactory; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface; use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper; @@ -32,7 +31,7 @@ use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; +use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface; use Neos\EventStore\EventStoreInterface; use Psr\Clock\ClockInterface; use Symfony\Component\Serializer\Serializer; @@ -54,7 +53,7 @@ public function __construct( ContentDimensionSourceInterface $contentDimensionSource, Serializer $propertySerializer, ProjectionsAndCatchUpHooksFactory $projectionsAndCatchUpHooksFactory, - private readonly UserIdProviderInterface $userIdProvider, + private readonly AuthProviderFactoryInterface $authProviderFactory, private readonly ClockInterface $clock, private readonly CommandHooksFactory $commandHooksFactory, ) { @@ -71,7 +70,7 @@ public function __construct( $contentDimensionSource, $contentDimensionZookeeper, $interDimensionalVariationGraph, - new PropertyConverter($propertySerializer) + new PropertyConverter($propertySerializer), ); $this->projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies); } @@ -135,6 +134,7 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->eventNormalizer, ) ); + $authProvider = $this->authProviderFactory->build($this->contentRepositoryId, $contentGraphReadModel); $commandHooks = $this->commandHooksFactory->build(CommandHooksFactoryDependencies::create( $this->contentRepositoryId, $this->projectionsAndCatchUpHooks->contentGraphProjection->getState(), @@ -152,7 +152,7 @@ public function getOrBuild(): ContentRepository $this->projectionFactoryDependencies->nodeTypeManager, $this->projectionFactoryDependencies->interDimensionalVariationGraph, $this->projectionFactoryDependencies->contentDimensionSource, - $this->userIdProvider, + $authProvider, $this->clock, $contentGraphReadModel, $commandHooks, diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php new file mode 100644 index 00000000000..e6f8524e3e1 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php @@ -0,0 +1,27 @@ +reason; + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php similarity index 96% rename from Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php index 86f78e31a21..b43e9b5feb1 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/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\User; +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/Feature/Security/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php new file mode 100644 index 00000000000..2d44bcf63fe --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php @@ -0,0 +1,44 @@ +userId; + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return VisibilityConstraints::default(); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + return Privilege::granted(self::class . ' always grants privileges'); + } + + 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 91d35a9e4ad..1dc3613824c 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; @@ -51,7 +52,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.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php index 7462e28a4e1..f1c4f3b93ae 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php @@ -34,6 +34,14 @@ private function __construct( ) { } + /** + * @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())); @@ -48,11 +56,16 @@ 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')); } + 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/User/StaticUserIdProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php deleted file mode 100644 index 5bc969ed6b6..00000000000 --- a/Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php +++ /dev/null @@ -1,23 +0,0 @@ -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 @@ -currentVisibilityConstraints = match ($restrictionType) { 'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(), - 'frontend' => VisibilityConstraints::frontend(), + 'default' => 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 e15be252379..68e1075f1c1 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php @@ -81,7 +81,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/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php index 8ca963361d9..5d1b7157069 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\CommandHandler\CommandInterface; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet; @@ -302,9 +303,12 @@ 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 { if ($shortExceptionName === 'WorkspaceRebaseFailed') { throw new \RuntimeException('Please use the assertion "the last command should have thrown the WorkspaceRebaseFailed exception with" instead.'); @@ -312,8 +316,8 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int 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, @@ -321,6 +325,9 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $this->lastCommandException->getMessage() )); } + if ($expectedMessage !== null) { + Assert::assertSame($expectedMessage->getRaw(), $this->lastCommandException->getMessage()); + } $this->lastCommandException = null; } diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php deleted file mode 100644 index 88614645a23..00000000000 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php +++ /dev/null @@ -1,23 +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.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index 9261fcc4010..86804d3cd93 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -14,22 +14,21 @@ use Neos\ContentRepository\Core\Factory\ProjectionsAndCatchUpHooksFactory; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface; +use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; -use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface; use Neos\ContentRepository\Core\Projection\ProjectionInterface; use Neos\ContentRepository\Core\Projection\ProjectionStateInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds; -use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; 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\UserIdProvider\UserIdProviderFactoryInterface; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\ContentSubgraphWithRuntimeCaches; use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\SubgraphCachePool; use Neos\EventStore\EventStoreInterface; @@ -177,7 +176,7 @@ private function buildFactory(ContentRepositoryId $contentRepositoryId): Content $this->buildContentDimensionSource($contentRepositoryId, $contentRepositorySettings), $this->buildPropertySerializer($contentRepositoryId, $contentRepositorySettings), $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings), - $this->buildUserIdProvider($contentRepositoryId, $contentRepositorySettings), + $this->buildAuthProviderFactory($contentRepositoryId, $contentRepositorySettings), $clock, $this->buildCommandHooksFactory($contentRepositoryId, $contentRepositorySettings), ); @@ -318,14 +317,14 @@ private function registerCatchupHookForProjection(mixed $projectionOptions, Proj } /** @param array $contentRepositorySettings */ - private function buildUserIdProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): UserIdProviderInterface + private function buildAuthProviderFactory(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderFactoryInterface { - 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; } /** @param array $contentRepositorySettings */ diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php new file mode 100644 index 00000000000..5b8593b7954 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php @@ -0,0 +1,17 @@ + $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface - { - return new StaticUserIdProvider(UserId::forSystemUser()); - } -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php deleted file mode 100644 index a6145c7e8dc..00000000000 --- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - $options */ - public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface; -} diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 2a71cbd4333..0ac97763c0a 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -699,27 +699,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/Configuration/Settings.yaml b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml index 060affe4041..80574bfb60f 100644 --- a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml +++ b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml @@ -31,8 +31,8 @@ Neos: contentDimensionSource: factoryObjectName: Neos\ContentRepositoryRegistry\Factory\ContentDimensionSource\ConfigurationBasedContentDimensionSourceFactory - 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.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 4e75aa55aa1..d3634d48125 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -19,14 +19,16 @@ 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\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 @@ -65,6 +67,18 @@ class UsageController extends ActionController */ protected $workspaceService; + /** + * @Flow\Inject + * @var SecurityContext + */ + protected $securityContext; + + /** + * @Flow\Inject + * @var ContentRepositoryAuthorizationService + */ + protected $contentRepositoryAuthorizationService; + /** * Get Related Nodes for an asset * @@ -103,12 +117,7 @@ public function relatedNodesAction(AssetInterface $asset) ); $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null; - $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser( - $currentContentRepositoryId, - $usage->getWorkspaceName(), - $currentUser - ); - + $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/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index ddd62e7a8b3..876e5b8d199 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) * @@ -268,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 @@ -284,25 +285,16 @@ 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 ) @@ -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]); @@ -517,7 +508,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)), [ @@ -526,4 +517,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 a426c24328f..19300dea675 100644 --- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php +++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php @@ -14,6 +14,7 @@ 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; @@ -35,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; @@ -42,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; @@ -110,6 +113,9 @@ class NodeController extends ActionController #[Flow\Inject] protected NodeUriBuilderFactory $nodeUriBuilderFactory; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * @param string $node * @throws NodeNotFoundException @@ -125,21 +131,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); @@ -197,17 +194,20 @@ 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->getContentGraph($nodeAddress->workspaceName)->getSubgraph( - $nodeAddress->dimensionSpacePoint, - VisibilityConstraints::frontend() - ); + $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, + // to ensure that disabled nodes are NEVER shown recursively. + $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled()); + $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints); + $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool); $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId); diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php new file mode 100644 index 00000000000..62201162bba --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php @@ -0,0 +1,62 @@ +reason; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index faf543259cf..67f1f970110 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -5,15 +5,16 @@ 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 * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles) * - * @api + * @api because it is returned by the {@see ContentRepositoryAuthorizationService} */ #[Flow\Proxy(false)] final readonly class WorkspacePermissions @@ -23,28 +24,49 @@ * @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) */ - 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(): self + 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); + } + + /** + * Human-readable explanation for why this permission was evaluated + */ + 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..0b487fb03f9 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 */ @@ -27,11 +32,12 @@ public function isAtLeast(self $role): bool return $this->specificity() >= $role->specificity(); } - private function specificity(): int + public 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..c53388bb23c 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -12,28 +12,42 @@ * @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; } } 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/Pruning/RoleAndMetadataPruningProcessor.php b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php index 063f7e6f4de..c98174f0b6a 100644 --- a/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php +++ b/Neos.Neos/Classes/Domain/Pruning/RoleAndMetadataPruningProcessor.php @@ -17,7 +17,7 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Export\ProcessingContext; use Neos\ContentRepository\Export\ProcessorInterface; -use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; /** * Pruning processor that removes role and metadata for a specified content repository @@ -26,13 +26,13 @@ { public function __construct( private ContentRepositoryId $contentRepositoryId, - private WorkspaceService $workspaceService, + private WorkspaceMetadataAndRoleRepository $workspaceMetadataAndRoleRepository, ) { } public function run(ProcessingContext $context): void { - $this->workspaceService->pruneRoleAssignments($this->contentRepositoryId); - $this->workspaceService->pruneWorkspaceMetadata($this->contentRepositoryId); + $this->workspaceMetadataAndRoleRepository->pruneRoleAssignments($this->contentRepositoryId); + $this->workspaceMetadataAndRoleRepository->pruneWorkspaceMetadata($this->contentRepositoryId); } } 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/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/Domain/Service/SitePruningService.php b/Neos.Neos/Classes/Domain/Service/SitePruningService.php index 71ed7c18763..9a1c7c67537 100644 --- a/Neos.Neos/Classes/Domain/Service/SitePruningService.php +++ b/Neos.Neos/Classes/Domain/Service/SitePruningService.php @@ -33,6 +33,7 @@ use Neos\Neos\Domain\Pruning\SitePruningProcessor; use Neos\Neos\Domain\Repository\DomainRepository; use Neos\Neos\Domain\Repository\SiteRepository; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; #[Flow\Scope('singleton')] final readonly class SitePruningService @@ -42,7 +43,7 @@ public function __construct( private SiteRepository $siteRepository, private DomainRepository $domainRepository, private PersistenceManagerInterface $persistenceManager, - private WorkspaceService $workspaceService, + private WorkspaceMetadataAndRoleRepository $workspaceMetadataAndRoleRepository, ) { } @@ -71,7 +72,7 @@ public function pruneAll(ContentRepositoryId $contentRepositoryId, \Closure $onP new ContentStreamPrunerFactory() ) ), - 'Prune roles and metadata' => new RoleAndMetadataPruningProcessor($contentRepositoryId, $this->workspaceService), + 'Prune roles and metadata' => new RoleAndMetadataPruningProcessor($contentRepositoryId, $this->workspaceMetadataAndRoleRepository), 'Reset all projections' => new ProjectionResetProcessor( $this->contentRepositoryRegistry->buildService( $contentRepositoryId, diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index c0c0e05aacf..b4021fae155 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -14,10 +14,7 @@ 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\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; @@ -27,19 +24,19 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Exception\NoSuchRoleException; +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; 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\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; /** * Central authority to interact with Content Repository Workspaces within Neos @@ -47,15 +44,14 @@ * @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 UserService $userService, - private readonly Connection $dbal, + private ContentRepositoryRegistry $contentRepositoryRegistry, + private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository, + private UserService $userService, + private ContentRepositoryAuthorizationService $authorizationService, + private SecurityContext $securityContext, ) { } @@ -69,7 +65,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(''), @@ -83,9 +79,9 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W */ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'title' => $newWorkspaceTitle->value, - ]); + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null); } /** @@ -93,9 +89,9 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work */ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void { - $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ - 'description' => $newWorkspaceDescription->value, - ]); + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value); } /** @@ -105,7 +101,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); } @@ -126,7 +122,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); } /** @@ -142,7 +138,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)); } /** @@ -167,7 +163,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; @@ -191,20 +187,9 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con */ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void { + $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $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->subjectType->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); } /** @@ -212,82 +197,21 @@ 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->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName); $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' => $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); } /** * 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::getWorkspacePermissions()} should be used! */ 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( - WorkspaceRoleSubjectType::from($row['subject_type']), - WorkspaceRoleSubject::fromString($row['subject']), - WorkspaceRole::from($row['role']), - ), $rows) - ); - } - - /** - * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} - */ - public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions - { - 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); - } - $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspaceMetadata !== null && $workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { - return WorkspacePermissions::all(); - } - $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); - } - return WorkspacePermissions::create( - read: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - ); + return $this->metadataAndRoleRepository->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); } /** @@ -318,98 +242,8 @@ 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 - { - 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); - } - } - // ------------------ - 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); @@ -420,87 +254,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); - } - - /** - * @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); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); } private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace @@ -513,4 +267,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 AccessDenied(sprintf('Managing workspace "%s" in "%s" was denied: %s', $workspaceName->value, $contentRepositoryId->value, $workspacePermissions->getReason()), 1731654519); + } + } } 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/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php new file mode 100644 index 00000000000..6de599cbd7c --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -0,0 +1,111 @@ + $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 getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions + { + $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)); + $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers); + if ($userId !== null) { + $subjects[] = WorkspaceRoleSubject::createForUser($userId); + } + $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true); + $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)); + } + 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('User is %s Neos Administrator and has role "%s" for workspace "%s"', $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value), + ); + } + + /** + * Determines the {@see NodePermissions} a user with the specified {@see Role}s has on the given {@see Node} + * + * @param array $roles + */ + public function getNodePermissions(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, + ); + } + + /** + * Determines the default {@see VisibilityConstraints} for the specified {@see Role}s + * + * @param array $roles + */ + public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints + { + $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 VisibilityConstraints::fromTagConstraints($restrictedSubtreeTags); + } +} diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php new file mode 100644 index 00000000000..227cb75d6aa --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php @@ -0,0 +1,78 @@ +subtreeTagsRuntimeCache */ + private function initialize(): void + { + if ($this->initialized) { + return; + } + $subtreeTag = $this->getParsedMatcher(); + if (str_contains($subtreeTag, ':')) { + [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag); + $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId); + } + $this->subtreeTagsRuntimeCache = SubtreeTags::fromStrings($subtreeTag); + $this->initialized = true; + } + + /** + * Returns true, if this privilege covers the given subject + * + * @param PrivilegeSubjectInterface $subject + * @return boolean + * @throws InvalidPrivilegeTypeException if the given $subject is not supported by the privilege + */ + 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); + } + $contentRepositoryId = $this->getContentRepositoryId(); + if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) { + return false; + } + return !$this->getSubtreeTags()->intersection($subject->subTreeTags)->isEmpty(); + } + + public function getSubtreeTags(): SubtreeTags + { + $this->initialize(); + return $this->subtreeTagsRuntimeCache; + } + + public function getContentRepositoryId(): ?ContentRepositoryId + { + $this->initialize(); + return $this->contentRepositoryIdRuntimeCache; + } +} 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..83c9f53b88a --- /dev/null +++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php @@ -0,0 +1,23 @@ +userService->getCurrentUser(); + if ($user === null) { + return null; + } + return UserId::fromString($user->getId()->value); + } + + public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints + { + return $this->authorizationService->getVisibilityConstraints($this->contentRepositoryId, $this->securityContext->getRoles()); + } + + public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted('Authorization checks are disabled'); + } + $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()); + } + + public function canExecuteCommand(CommandInterface $command): Privilege + { + if ($this->securityContext->areAuthorizationChecksDisabled()) { + return Privilege::granted('Authorization checks are disabled'); + } + $nodeThatRequiresEditPrivilege = $this->nodeThatRequiresEditPrivilegeForCommand($command); + 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->contentGraphReadModel + ->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"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value)); + } + $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())); + } + return Privilege::granted(sprintf('Edit permissions for node "%s" in workspace "%s" granted: %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason())); + } + 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(sprintf('Missing "manage" permissions for workspace "%s": %s', $command->workspaceName->value, $workspacePermissions->getReason())); + } + $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName); + if (!$baseWorkspacePermissions->read) { + return Privilege::denied(sprintf('Missing "read" permissions for base workspace "%s": %s', $command->baseWorkspaceName->value, $baseWorkspacePermissions->getReason())); + } + 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) { + AddDimensionShineThrough::class, + ChangeNodeAggregateName::class, + ChangeNodeAggregateType::class, + CreateRootNodeAggregateWithNode::class, + MoveDimensionSpacePoint::class, + UpdateRootNodeAggregateDimensions::class, + DiscardWorkspace::class, + DiscardIndividualNodesFromWorkspace::class, + PublishWorkspace::class, + PublishIndividualNodesFromWorkspace::class, + RebaseWorkspace::class => $this->requireWorkspaceWritePermission($command->workspaceName), + CreateWorkspace::class => $this->requireWorkspaceWritePermission($command->baseWorkspaceName), + DeleteWorkspace::class => $this->requireWorkspaceManagePermission($command->workspaceName), + default => Privilege::granted('Command not restricted'), + }; + } + + /** + * 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 => 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 => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId), + SetNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId), + default => null, + }; + } + + 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->manage) { + return Privilege::denied("Missing 'manage' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}"); + } + return Privilege::granted("User has 'manage' permissions for workspace '{$workspaceName->value}'"); + } + + private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions + { + return $this->authorizationService->getWorkspacePermissions( + $this->contentRepositoryId, + $workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId(), + ); + } +} diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php new file mode 100644 index 00000000000..cc2ebb54dab --- /dev/null +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php @@ -0,0 +1,34 @@ +userService, $contentGraphReadModel, $this->contentRepositoryAuthorizationService, $this->securityContext); + } +} 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); } /** 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/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/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php index fa517e69154..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::frontend() + $arbitraryRootDimensionSpacePoint ); } catch (WorkspaceDoesNotExist | \RuntimeException) { return $this->renderErrorWelcomeScreen(); diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index 8ccf44e7ce0..c441d522255 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -142,6 +142,14 @@ privilegeTargets: label: General access to the dimensions module matcher: 'administration/dimensions' + + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + + '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: 'Neos.Flow:Everybody': @@ -229,6 +237,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/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml index d7d0bc7c720..ae5349f5f50 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\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory contentGraphProjection: catchUpHooks: 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..86fc2bb454e --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php @@ -0,0 +1,135 @@ + $className + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @BeforeScenario + */ + public function resetContentRepositorySecurity(): void + { + TestingAuthProvider::resetAuthProvider(); + $this->crSecurity_contentRepositorySecurityEnabled = false; + } + + private function enableContentRepositorySecurity(): void + { + if ($this->crSecurity_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->crSecurity_contentRepositorySecurityEnabled = true; + } + + /** + * @Given content repository security is enabled + */ + public function contentRepositorySecurityIsEnabled(): void + { + $this->enableFlowSecurity(); + $this->enableContentRepositorySecurity(); + } + + /** + * @When I am authenticated as :username + */ + public function iAmAuthenticatedAs(string $username): void + { + $user = $this->getObject(UserService::class)->getUser($username); + $this->authenticateAccount($user->getAccounts()->first()); + } + + /** + * @When I access the content graph for workspace :workspaceName + */ + public function iAccessesTheContentGraphForWorkspace(string $workspaceName): void + { + $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName))); + } + + /** + * @Then I should not be able to read node :nodeAggregateId + */ + public function iShouldNotBeAbleToReadNode(string $nodeAggregateId): void + { + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node !== null) { + Assert::fail(sprintf('Expected node "%s" to be inaccessible but it was loaded', $nodeAggregateId)); + } + } + + /** + * @Then I should be able to read node :nodeAggregateId + */ + public function iShouldBeAbleToReadNode(string $nodeAggregateId): void + { + $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId)); + if ($node === null) { + 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/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php index fabfcd12608..b2257a5c4d2 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,30 @@ private function tryCatchingExceptions(\Closure $callback): mixed } /** - * @Then an exception :exceptionMessage should be thrown + * @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 $exceptionMessage): void + public function anExceptionShouldBeThrown(string $expectedShortExceptionName, ?int $code = null, 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()); + } + if ($code !== null) { + Assert::assertSame($code, $this->lastCaughtException->getCode()); + } + $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; } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index fce5b7a3278..bd87a6fd454 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -47,6 +47,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/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php new file mode 100644 index 00000000000..5f4915dfcf1 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php @@ -0,0 +1,135 @@ + $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)); + + $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(); + } + + 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; + } + }); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index 4d8d153f64e..da251d624fb 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; @@ -63,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 06916378f00..af046a39443 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -18,15 +18,16 @@ 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 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; +use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use PHPUnit\Framework\Assert; /** @@ -62,17 +63,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, )); } @@ -169,12 +171,16 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table */ 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 ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $subject, WorkspaceRole::from($role) ) )); @@ -186,11 +192,15 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string */ 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 ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, - WorkspaceRoleSubject::fromString($groupName ?? $username), + $subject, )); } @@ -201,7 +211,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)); @@ -213,13 +223,17 @@ 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( + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + Assert::assertNotNull($user); + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $roles, + $user->getId(), ); - Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter((array)$permissions)))); + Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions))))); } /** @@ -227,14 +241,25 @@ 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( + $userService = $this->getObject(UserService::class); + $user = $userService->getUser($username); + Assert::assertNotNull($user); + $roles = $userService->getAllRoles($user); + $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), - $user, + $roles, + $user->getId(), ); Assert::assertFalse($permissions->read); Assert::assertFalse($permissions->write); Assert::assertFalse($permissions->manage); } + + private function userIdForUsername(string $username): UserId + { + $user = $this->getObject(UserService::class)->getUser($username); + 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 new file mode 100644 index 00000000000..d279d035287 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -0,0 +1,106 @@ +@flowEntities +Feature: EditNodePrivilege related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege': + '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': + properties: + foo: + type: string + references: + ref: [] + """ + 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 | 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 | "subtree_a" | + And the command DisableNodeAggregate is executed with payload: + | Key | Value | + | 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 + + 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 new file mode 100644 index 00000000000..2545f9c7bac --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -0,0 +1,87 @@ +@flowEntities +Feature: ReadNodePrivilege related features + + Background: + Given The following additional policies are configured: + """ + privilegeTargets: + 'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege': + 'Neos.Neos:ReadSubtreeA': + matcher: 'subtree_a' + roles: + 'Neos.Neos:RoleWithPrivilegeToReadSubtree': + privileges: + - + privilegeTarget: 'Neos.Neos:ReadSubtreeA' + 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': + properties: + foo: + type: string + references: + ref: [] + """ + 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 | 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 | "subtree_a" | + And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" + 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" + + 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/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature new file mode 100644 index 00000000000..7b02cca647b --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature @@ -0,0 +1,216 @@ +@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 | + | 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 + 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: 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 + + 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 '' + + # 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"} | + | 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"} | + 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" 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 diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index dc1c0aa5bb0..e8f02988acb 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -44,6 +44,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; @@ -63,6 +64,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; @@ -104,6 +106,9 @@ class WorkspaceController extends AbstractModuleController #[Flow\Inject] protected WorkspaceService $workspaceService; + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + /** * Display a list of unpublished content */ @@ -111,7 +116,7 @@ public function indexAction(): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1718308216); + throw new AccessDeniedException('No user authenticated', 1718308216); } $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); @@ -139,7 +144,7 @@ public function indexAction(): void continue; } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); if (!$permissions->read) { continue; } @@ -161,7 +166,7 @@ public function showAction(WorkspaceName $workspace): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new \RuntimeException('No user authenticated', 1720371024); + throw new AccessDeniedException('No user authenticated', 1720371024); } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -178,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->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, @@ -207,7 +212,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 { @@ -288,6 +293,15 @@ public function updateAction( $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new AccessDeniedException('No user is authenticated', 1729620262); + } + $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); + } + if ($title->value === '') { $title = WorkspaceTitle::fromString($workspaceName->value); } @@ -998,7 +1012,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { - $user = $this->userService->getCurrentUser(); + $currentUser = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); foreach ($workspaces as $workspace) { @@ -1014,10 +1028,7 @@ protected function prepareBaseWorkspaceOptions( if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - if ($user === null) { - continue; - } - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); + $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId()); if (!$permissions->manage) { continue; }