From 49aa8bbaada53ec737548f8d3bd89c9b2b51d1c8 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 15 Oct 2024 18:23:44 +0200
Subject: [PATCH 01/58] FEATURE: Content Repository Privileges

Related: #3732
---
 ...trineDbalContentGraphProjectionFactory.php |   2 +-
 ...actory.php => FakeAuthProviderFactory.php} |  12 +-
 .../Settings.ContentRepositoryRegistry.yaml   |   4 +-
 .../Classes/CommandHandlingDependencies.php   |   8 +-
 .../Classes/ContentRepository.php             |  19 ++-
 .../Classes/ContentRepositoryReadModel.php    |   5 +-
 .../Factory/ContentRepositoryFactory.php      |   9 +-
 .../Factory/ProjectionFactoryDependencies.php |   2 +
 .../Auth/AuthProviderInterface.php            |  20 +++
 .../Classes/SharedModel/Auth/Privilege.php    |  38 +++++
 .../SharedModel/Auth/StaticAuthProvider.php   |  36 +++++
 .../SharedModel/{User => Auth}/UserId.php     |   2 +-
 .../Auth/WorkspacePrivilegeType.php           |  23 +++
 .../SharedModel/User/StaticUserIdProvider.php |  23 ---
 .../User/UserIdProviderInterface.php          |  13 --
 .../Bootstrap/CRTestSuiteRuntimeVariables.php |   6 +-
 .../Bootstrap/Helpers/FakeAuthProvider.php    |  37 +++++
 .../Bootstrap/Helpers/FakeUserIdProvider.php  |  23 ---
 .../Classes/ContentRepositoryRegistry.php     |  18 +--
 .../AuthProviderFactoryInterface.php}         |  10 +-
 .../StaticAuthProviderFactory.php             |  20 +++
 .../StaticUserIdProviderFactory.php           |  20 ---
 .../Configuration/Settings.yaml               |   4 +-
 .../ContentRepositoryAuthProvider.php         | 147 ++++++++++++++++++
 .../ContentRepositoryAuthProviderFactory.php  |  35 +++++
 .../Domain/Service/WorkspaceService.php       |   9 +-
 .../Controller/AbstractServiceController.php  |  10 --
 .../Classes/UserIdProvider/UserIdProvider.php |  29 ----
 .../UserIdProvider/UserIdProviderFactory.php  |  34 ----
 .../Settings.ContentRepositoryRegistry.yaml   |   4 +-
 30 files changed, 426 insertions(+), 196 deletions(-)
 rename Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/{FakeUserIdProviderFactory.php => FakeAuthProviderFactory.php} (52%)
 create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
 create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
 create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
 rename Neos.ContentRepository.Core/Classes/SharedModel/{User => Auth}/UserId.php (96%)
 create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php
 delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/User/StaticUserIdProvider.php
 delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/User/UserIdProviderInterface.php
 create mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
 delete mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php
 rename Neos.ContentRepositoryRegistry/Classes/Factory/{UserIdProvider/UserIdProviderFactoryInterface.php => AuthProvider/AuthProviderFactoryInterface.php} (52%)
 create mode 100644 Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
 delete mode 100644 Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php
 create mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
 create mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
 delete mode 100644 Neos.Neos/Classes/UserIdProvider/UserIdProvider.php
 delete mode 100644 Neos.Neos/Classes/UserIdProvider/UserIdProviderFactory.php

diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php
index 1b750c114a4..6a0644842a9 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/DoctrineDbalContentGraphProjectionFactory.php
@@ -58,7 +58,7 @@ public function build(
             ),
             $tableNames,
             $dimensionSpacePointsRepository,
-            new ContentRepositoryReadModel($contentRepositoryReadModelAdapter)
+            new ContentRepositoryReadModel($contentRepositoryReadModelAdapter),
         );
     }
 }
diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
similarity index 52%
rename from Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php
rename to Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
index 6a0a5c7a408..df59e7f80c8 100644
--- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeUserIdProviderFactory.php
+++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
@@ -5,17 +5,17 @@
 namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior;
 
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
-use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeUserIdProvider;
-use Neos\ContentRepositoryRegistry\Factory\UserIdProvider\UserIdProviderFactoryInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
+use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 
-final class FakeUserIdProviderFactory implements UserIdProviderFactoryInterface
+final class FakeAuthProviderFactory implements AuthProviderFactoryInterface
 {
     /**
      * @param array<string,mixed> $options
      */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface
+    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface
     {
-        return new FakeUserIdProvider();
+        return new FakeAuthProvider();
     }
 }
diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
index 53eafcc4977..74387df65ff 100644
--- a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
+++ b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
@@ -2,8 +2,8 @@ Neos:
   ContentRepositoryRegistry:
     presets:
       default:
-        userIdProvider:
-          factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeUserIdProviderFactory'
+        authProvider:
+          factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeAuthProviderFactory'
         clock:
           factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeClockFactory'
         nodeTypeManager:
diff --git a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php
index 1c778159fcd..12d7b67eabe 100644
--- a/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php
+++ b/Neos.ContentRepository.Core/Classes/CommandHandlingDependencies.php
@@ -38,8 +38,10 @@ final class CommandHandlingDependencies
      */
     private array $overriddenContentGraphInstances = [];
 
-    public function __construct(private readonly ContentRepository $contentRepository)
-    {
+    public function __construct(
+        private readonly ContentRepository $contentRepository,
+        private readonly ContentRepositoryReadModel $contentRepositoryReadModel,
+    ) {
     }
 
     public function handle(CommandInterface $command): CommandResult
@@ -84,7 +86,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter
             return $this->overriddenContentGraphInstances[$workspaceName->value];
         }
 
-        return $this->contentRepository->getContentGraph($workspaceName);
+        return $this->contentRepositoryReadModel->getContentGraphByWorkspaceName($workspaceName);
     }
 
     /**
diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index 54e81fb63a3..f2e50762a18 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -35,10 +35,11 @@
 use Neos\ContentRepository\Core\Projection\ProjectionStateInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionStatuses;
 use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus;
 use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreams;
@@ -85,10 +86,10 @@ public function __construct(
         private readonly NodeTypeManager $nodeTypeManager,
         private readonly InterDimensionalVariationGraph $variationGraph,
         private readonly ContentDimensionSourceInterface $contentDimensionSource,
-        private readonly UserIdProviderInterface $userIdProvider,
+        private readonly AuthProviderInterface $authProvider,
         private readonly ClockInterface $clock,
     ) {
-        $this->commandHandlingDependencies = new CommandHandlingDependencies($this);
+        $this->commandHandlingDependencies = new CommandHandlingDependencies($this, $this->getContentRepositoryReadModel());
     }
 
     /**
@@ -99,12 +100,16 @@ public function __construct(
      */
     public function handle(CommandInterface $command): CommandResult
     {
+        $privilege = $this->authProvider->getCommandPrivilege($command);
+        if (!$privilege->granted) {
+            throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->message), 1729086686);
+        }
         // the commands only calculate which events they want to have published, but do not do the
         // publishing themselves
         $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies);
 
         // TODO meaningful exception message
-        $initiatingUserId = $this->userIdProvider->getUserId();
+        $initiatingUserId = $this->authProvider->getUserId();
         $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM);
 
         // Add "initiatingUserId" and "initiatingTimestamp" metadata to all events.
@@ -269,6 +274,12 @@ public function findContentStreams(): ContentStreams
      */
     public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface
     {
+        $privilege = $this->authProvider->getWorkspacePrivilege($workspaceName, WorkspacePrivilegeType::READ_NODES);
+        if (!$privilege->granted) {
+            throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760);
+            // TODO more specific exception
+            //throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName);
+        }
         return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName);
     }
 
diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
index 95584a07f76..a9fd7d0eeef 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
@@ -16,6 +16,9 @@
 
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionStateInterface;
+use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
 use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
@@ -35,7 +38,7 @@
 final class ContentRepositoryReadModel implements ProjectionStateInterface
 {
     public function __construct(
-        private readonly ContentRepositoryReadModelAdapterInterface $adapter
+        private readonly ContentRepositoryReadModelAdapterInterface $adapter,
     ) {
     }
 
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
index bba7d784579..8dc6e517c88 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
@@ -31,7 +31,7 @@
 use Neos\ContentRepository\Core\Projection\ProjectionCatchUpTriggerInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\EventStore\EventStoreInterface;
 use Psr\Clock\ClockInterface;
 use Symfony\Component\Serializer\Serializer;
@@ -54,7 +54,7 @@ public function __construct(
         Serializer $propertySerializer,
         ProjectionsAndCatchUpHooksFactory $projectionsAndCatchUpHooksFactory,
         private readonly ProjectionCatchUpTriggerInterface $projectionCatchUpTrigger,
-        private readonly UserIdProviderInterface $userIdProvider,
+        private readonly AuthProviderInterface $authProvider,
         private readonly ClockInterface $clock,
     ) {
         $contentDimensionZookeeper = new ContentDimensionZookeeper($contentDimensionSource);
@@ -70,7 +70,8 @@ public function __construct(
             $contentDimensionSource,
             $contentDimensionZookeeper,
             $interDimensionalVariationGraph,
-            new PropertyConverter($propertySerializer)
+            new PropertyConverter($propertySerializer),
+            $this->authProvider,
         );
         $this->projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies);
     }
@@ -99,7 +100,7 @@ public function getOrBuild(): ContentRepository
                 $this->projectionFactoryDependencies->nodeTypeManager,
                 $this->projectionFactoryDependencies->interDimensionalVariationGraph,
                 $this->projectionFactoryDependencies->contentDimensionSource,
-                $this->userIdProvider,
+                $this->authProvider,
                 $this->clock,
             );
         }
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
index 9bb2f0cc31f..36b614ba623 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
@@ -20,6 +20,7 @@
 use Neos\ContentRepository\Core\EventStore\EventNormalizer;
 use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter;
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\EventStore\EventStoreInterface;
 
@@ -37,6 +38,7 @@ public function __construct(
         public ContentDimensionZookeeper $contentDimensionZookeeper,
         public InterDimensionalVariationGraph $interDimensionalVariationGraph,
         public PropertyConverter $propertyConverter,
+        public AuthProviderInterface $authProvider,
     ) {
     }
 }
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
new file mode 100644
index 00000000000..b2d03733050
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\Core\SharedModel\Auth;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+
+/**
+ * @internal except for CR factory implementations
+ */
+interface AuthProviderInterface
+{
+    public function getUserId(): UserId;
+
+    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege;
+
+    public function getCommandPrivilege(CommandInterface $command): Privilege;
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
new file mode 100644
index 00000000000..e7746a7dfe4
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
@@ -0,0 +1,38 @@
+<?php
+
+/*
+ * This file is part of the Neos.ContentRepository package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\Core\SharedModel\Auth;
+
+/**
+ * A privilege that is returned by the {@see AuthProviderInterface}
+ * @api
+ */
+final readonly class Privilege
+{
+    private function __construct(
+        public bool $granted,
+        public ?string $message,
+    ) {
+    }
+
+    public static function granted(): self
+    {
+        return new self(true, null);
+    }
+
+    public static function denied(string $message): self
+    {
+        return new self(false, $message);
+    }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
new file mode 100644
index 00000000000..66ad3102805
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\Core\SharedModel\Auth;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+
+/**
+ * A simple auth provider that just statically returns the same user id that it was given upon construction time and grants all privileges
+ *
+ * @api
+ */
+final class StaticAuthProvider implements AuthProviderInterface
+{
+    public function __construct(
+        private readonly UserId $userId,
+    ) {
+    }
+
+    public function getUserId(): UserId
+    {
+        return $this->userId;
+    }
+
+    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    {
+        return Privilege::granted();
+    }
+
+    public function getCommandPrivilege(CommandInterface $command): Privilege
+    {
+        return Privilege::granted();
+    }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php
similarity index 96%
rename from Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php
rename to Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php
index 86f78e31a21..80228a031d0 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/User/UserId.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php
@@ -12,7 +12,7 @@
 
 declare(strict_types=1);
 
-namespace Neos\ContentRepository\Core\SharedModel\User;
+namespace Neos\ContentRepository\Core\SharedModel\Auth;
 
 use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory;
 
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php
new file mode 100644
index 00000000000..c08d6aa4d46
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php
@@ -0,0 +1,23 @@
+<?php
+
+/*
+ * This file is part of the Neos.ContentRepository package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\Core\SharedModel\Auth;
+
+/**
+ * @api
+ */
+enum WorkspacePrivilegeType
+{
+    case READ_NODES;
+}
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 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\ContentRepository\Core\SharedModel\User;
-
-/**
- * A user id provider that just statically returns the same user id that it was given upon construction time
- *
- * @api
- */
-final class StaticUserIdProvider implements UserIdProviderInterface
-{
-    public function __construct(
-        private readonly UserId $userId,
-    ) {
-    }
-
-    public function getUserId(): UserId
-    {
-        return $this->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 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\ContentRepository\Core\SharedModel\User;
-
-/**
- * @internal except for CR factory implementations
- */
-interface UserIdProviderInterface
-{
-    public function getUserId(): UserId;
-}
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
index 12ebd6b0a5f..1bdc2a5be01 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
@@ -24,11 +24,11 @@
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
-use Neos\ContentRepository\Core\SharedModel\User\UserId;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock;
-use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeUserIdProvider;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
 
 /**
  * The node creation trait for behavioral tests
@@ -74,7 +74,7 @@ abstract protected function getContentRepository(ContentRepositoryId $id): Conte
      */
     public function iAmUserIdentifiedBy(string $userId): void
     {
-        FakeUserIdProvider::setUserId(UserId::fromString($userId));
+        FakeAuthProvider::setUserId(UserId::fromString($userId));
     }
 
     /**
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
new file mode 100644
index 00000000000..977e82ac1ec
--- /dev/null
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+
+final class FakeAuthProvider implements AuthProviderInterface
+{
+    public static ?UserId $userId = null;
+
+    public static function setUserId(UserId $userId): void
+    {
+        self::$userId = $userId;
+    }
+
+    public function getUserId(): UserId
+    {
+        return self::$userId ?? UserId::forSystemUser();
+    }
+
+    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    {
+        return Privilege::granted();
+    }
+
+    public function getCommandPrivilege(CommandInterface $command): Privilege
+    {
+        return Privilege::granted();
+    }
+}
diff --git a/Neos.ContentRepository.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 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
-
-use Neos\ContentRepository\Core\SharedModel\User\UserId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
-
-final class FakeUserIdProvider implements UserIdProviderInterface
-{
-    public static ?UserId $userId = null;
-
-    public static function setUserId(UserId $userId): void
-    {
-        self::$userId = $userId;
-    }
-
-    public function getUserId(): UserId
-    {
-        return self::$userId ?? UserId::forSystemUser();
-    }
-}
diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
index 0a4bf64988f..acd1dc18012 100644
--- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
+++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
@@ -18,7 +18,7 @@
 use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepositoryRegistry\Exception\ContentRepositoryNotFoundException;
 use Neos\ContentRepositoryRegistry\Exception\InvalidConfigurationException;
 use Neos\ContentRepositoryRegistry\Factory\Clock\ClockFactoryInterface;
@@ -26,7 +26,7 @@
 use Neos\ContentRepositoryRegistry\Factory\EventStore\EventStoreFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\NodeTypeManager\NodeTypeManagerFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\ProjectionCatchUpTrigger\ProjectionCatchUpTriggerFactoryInterface;
-use Neos\ContentRepositoryRegistry\Factory\UserIdProvider\UserIdProviderFactoryInterface;
+use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\ContentSubgraphWithRuntimeCaches;
 use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\SubgraphCachePool;
 use Neos\EventStore\EventStoreInterface;
@@ -171,7 +171,7 @@ private function buildFactory(ContentRepositoryId $contentRepositoryId): Content
                 $this->buildPropertySerializer($contentRepositoryId, $contentRepositorySettings),
                 $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings),
                 $this->buildProjectionCatchUpTrigger($contentRepositoryId, $contentRepositorySettings),
-                $this->buildUserIdProvider($contentRepositoryId, $contentRepositorySettings),
+                $this->buildAuthProvider($contentRepositoryId, $contentRepositorySettings),
                 $clock
             );
         } catch (\Exception $exception) {
@@ -275,14 +275,14 @@ private function buildProjectionCatchUpTrigger(ContentRepositoryId $contentRepos
     }
 
     /** @param array<string, mixed> $contentRepositorySettings */
-    private function buildUserIdProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): UserIdProviderInterface
+    private function buildAuthProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderInterface
     {
-        isset($contentRepositorySettings['userIdProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have userIdProvider.factoryObjectName configured.', $contentRepositoryId->value);
-        $userIdProviderFactory = $this->objectManager->get($contentRepositorySettings['userIdProvider']['factoryObjectName']);
-        if (!$userIdProviderFactory instanceof UserIdProviderFactoryInterface) {
-            throw InvalidConfigurationException::fromMessage('userIdProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, UserIdProviderFactoryInterface::class, get_debug_type($userIdProviderFactory));
+        isset($contentRepositorySettings['authProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have authProvider.factoryObjectName configured.', $contentRepositoryId->value);
+        $authProviderFactory = $this->objectManager->get($contentRepositorySettings['authProvider']['factoryObjectName']);
+        if (!$authProviderFactory instanceof AuthProviderFactoryInterface) {
+            throw InvalidConfigurationException::fromMessage('authProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, AuthProviderFactoryInterface::class, get_debug_type($authProviderFactory));
         }
-        return $userIdProviderFactory->build($contentRepositoryId, $contentRepositorySettings['userIdProvider']['options'] ?? []);
+        return $authProviderFactory->build($contentRepositoryId, $contentRepositorySettings['authProvider']['options'] ?? []);
     }
 
     /** @param array<string, mixed> $contentRepositorySettings */
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
similarity index 52%
rename from Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php
rename to Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
index a6145c7e8dc..9aebd57e688 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/UserIdProviderFactoryInterface.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
@@ -1,15 +1,17 @@
 <?php
+
 declare(strict_types=1);
-namespace Neos\ContentRepositoryRegistry\Factory\UserIdProvider;
+
+namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider;
 
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 
 /**
  * @api
  */
-interface UserIdProviderFactoryInterface
+interface AuthProviderFactoryInterface
 {
     /** @param array<string, mixed> $options */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface;
+    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface;
 }
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
new file mode 100644
index 00000000000..4cc8b9a7ebd
--- /dev/null
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types=1);
+namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider;
+
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Auth\StaticAuthProvider;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+
+/**
+ * @api
+ */
+final class StaticAuthProviderFactory implements AuthProviderFactoryInterface
+{
+    /** @param array<string, mixed> $options */
+    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface
+    {
+        return new StaticAuthProvider(UserId::forSystemUser());
+    }
+}
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php
deleted file mode 100644
index 563bc6b19a9..00000000000
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/UserIdProvider/StaticUserIdProviderFactory.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-declare(strict_types=1);
-namespace Neos\ContentRepositoryRegistry\Factory\UserIdProvider;
-
-use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\User\StaticUserIdProvider;
-use Neos\ContentRepository\Core\SharedModel\User\UserId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
-
-/**
- * @api
- */
-final class StaticUserIdProviderFactory implements UserIdProviderFactoryInterface
-{
-    /** @param array<string, mixed> $options */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface
-    {
-        return new StaticUserIdProvider(UserId::forSystemUser());
-    }
-}
diff --git a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml
index 44e28699641..460706caaf8 100644
--- a/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml
+++ b/Neos.ContentRepositoryRegistry/Configuration/Settings.yaml
@@ -34,8 +34,8 @@ Neos:
         projectionCatchUpTrigger:
           factoryObjectName: Neos\ContentRepositoryRegistry\Factory\ProjectionCatchUpTrigger\SubprocessProjectionCatchUpTriggerFactory
 
-        userIdProvider:
-          factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory
+        authProvider:
+          factoryObjectName: Neos\ContentRepositoryRegistry\Factory\AuthProvider\StaticAuthProviderFactory
 
         clock:
           factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory
diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
new file mode 100644
index 00000000000..b154f482211
--- /dev/null
+++ b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\ContentRepositoryAuthProvider;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough;
+use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint;
+use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
+use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties;
+use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively;
+use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
+use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
+use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences;
+use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences;
+use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName;
+use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType;
+use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant;
+use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode;
+use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree;
+use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
+use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Neos\Domain\Model\WorkspacePermissions;
+use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Domain\Service\WorkspaceService;
+
+/**
+ * @api
+ */
+final class ContentRepositoryAuthProvider implements AuthProviderInterface
+{
+    public function __construct(
+        private readonly ContentRepositoryId $contentRepositoryId,
+        private readonly UserService $userService,
+        private readonly WorkspaceService $workspaceService,
+        private readonly SecurityContext $securityContext,
+    ) {
+    }
+
+    public function getUserId(): UserId
+    {
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return UserId::forSystemUser();
+        }
+        return UserId::fromString($user->getId()->value);
+    }
+
+    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return Privilege::granted();
+        }
+        $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName);
+        if ($workspacePermissions === null) {
+            return Privilege::denied('No user is authenticated');
+        }
+        return match ($privilegeType) {
+            WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User has no read permission for workspace "%s"', $workspaceName->value)),
+        };
+    }
+
+    public function getCommandPrivilege(CommandInterface $command): Privilege
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return Privilege::granted();
+        }
+        if ($command instanceof CreateWorkspace) {
+            $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName);
+            if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) {
+                return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value));
+            }
+            return Privilege::granted();
+        }
+        list($privilege, $workspaceName) = match ($command::class) {
+            AddDimensionShineThrough::class,
+            ChangeNodeAggregateName::class,
+            ChangeNodeAggregateType::class,
+            CopyNodesRecursively::class,
+            CreateNodeAggregateWithNode::class,
+            CreateNodeAggregateWithNodeAndSerializedProperties::class,
+            CreateNodeVariant::class,
+            CreateRootNodeAggregateWithNode::class,
+            DisableNodeAggregate::class,
+            DiscardIndividualNodesFromWorkspace::class,
+            DiscardWorkspace::class,
+            EnableNodeAggregate::class,
+            MoveDimensionSpacePoint::class,
+            MoveNodeAggregate::class,
+            PublishIndividualNodesFromWorkspace::class,
+            PublishWorkspace::class,
+            RebaseWorkspace::class,
+            RemoveNodeAggregate::class,
+            SetNodeProperties::class,
+            SetNodeReferences::class,
+            SetSerializedNodeProperties::class,
+            SetSerializedNodeReferences::class,
+            TagSubtree::class,
+            UntagSubtree::class,
+            UpdateRootNodeAggregateDimensions::class => ['write', $command->workspaceName],
+            ChangeBaseWorkspace::class,
+            CreateRootWorkspace::class,
+            CreateWorkspace::class,
+            DeleteWorkspace::class => ['manage', $command->workspaceName],
+            default => [null, null],
+        };
+        if ($privilege === null) {
+            return Privilege::granted();
+        }
+        $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName);
+        if ($workspacePermissions === null) {
+            return Privilege::denied(sprintf('No user is authenticated to %s workspace "%s" because no user is authenticated', $privilege, $workspaceName->value));
+        }
+        $privilegeGranted = $privilege === 'write' ? $workspacePermissions->write : $workspacePermissions->manage;
+        return $privilegeGranted ? Privilege::granted() : Privilege::denied(sprintf('User has no %s permission for workspace "%s"', $privilege, $workspaceName->value));
+    }
+
+    private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions
+    {
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return null;
+        }
+        return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+    }
+}
diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
new file mode 100644
index 00000000000..63f84ae439e
--- /dev/null
+++ b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\ContentRepositoryAuthProvider;
+
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
+use Neos\Flow\Annotations as Flow;
+use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Domain\Service\WorkspaceService;
+
+/**
+ * Implementation of the {@see AuthProviderFactoryInterface} in order to provide authentication and authorization for Content Repositories
+ *
+ * @api
+ */
+#[Flow\Scope('singleton')]
+final class ContentRepositoryAuthProviderFactory implements AuthProviderFactoryInterface
+{
+    public function __construct(
+        private readonly UserService $userService,
+        private readonly WorkspaceService $workspaceService,
+    ) {
+    }
+
+    /**
+     * @param array<string, mixed> $options
+     */
+    public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider
+    {
+        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService);
+    }
+}
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 0ada7877998..e64c5acba84 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -27,6 +27,7 @@
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
+use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Flow\Security\Exception\NoSuchRoleException;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\UserId;
@@ -44,6 +45,8 @@
 /**
  * Central authority to interact with Content Repository Workspaces within Neos
  *
+ * TODO evaluate permissions for workspace changes
+ *
  * @api
  */
 #[Flow\Scope('singleton')]
@@ -56,6 +59,7 @@ public function __construct(
         private readonly ContentRepositoryRegistry $contentRepositoryRegistry,
         private readonly UserService $userService,
         private readonly Connection $dbal,
+        private readonly SecurityContext $securityContext,
     ) {
     }
 
@@ -83,6 +87,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
+        // TODO check workspace permissions -> $this->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceName, $this->userService->getCurrentUser());
         $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
             'title' => $newWorkspaceTitle->value,
         ]);
@@ -173,14 +178,14 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con
             return;
         }
         $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel());
-        $this->createPersonalWorkspace(
+        $this->securityContext->withoutAuthorizationChecks(fn () => $this->createPersonalWorkspace(
             $contentRepositoryId,
             $workspaceName,
             WorkspaceTitle::fromString($user->getLabel()),
             WorkspaceDescription::empty(),
             WorkspaceName::forLive(),
             $user->getId(),
-        );
+        ));
     }
 
     /**
diff --git a/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php b/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php
index a212f1ac725..aca0d7a9327 100644
--- a/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php
+++ b/Neos.Neos/Classes/Service/Controller/AbstractServiceController.php
@@ -15,7 +15,6 @@
 namespace Neos\Neos\Service\Controller;
 
 use GuzzleHttp\Psr7\Response;
-use Neos\ContentRepository\Core\SharedModel\User\UserId;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Exception as FlowException;
 use Neos\Flow\Log\ThrowableStorageInterface;
@@ -157,13 +156,4 @@ protected function convertException(\Throwable $exception): array
         }
         return $exceptionData;
     }
-
-    protected function getCurrentUserIdentifier(): ?UserId
-    {
-        $user = $this->domainUserService->getCurrentUser();
-
-        return $user
-            ? UserId::fromString($this->persistenceManager->getIdentifierByObject($user))
-            : null;
-    }
 }
diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php
deleted file mode 100644
index b81c97e8c5a..00000000000
--- a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php
+++ /dev/null
@@ -1,29 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\Neos\UserIdProvider;
-
-use Neos\ContentRepository\Core\SharedModel\User\UserId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
-use Neos\Neos\Domain\Service\UserService;
-
-/**
- * @api
- */
-final class UserIdProvider implements UserIdProviderInterface
-{
-    public function __construct(
-        private readonly UserService $userService
-    ) {
-    }
-
-    public function getUserId(): UserId
-    {
-        $user = $this->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 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\Neos\UserIdProvider;
-
-use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface;
-use Neos\ContentRepositoryRegistry\Factory\UserIdProvider\UserIdProviderFactoryInterface;
-use Neos\Flow\Annotations as Flow;
-use Neos\Neos\Domain\Service\UserService;
-
-/**
- * Implementation of the Neos AssetUsageStrategyInterface in order to protect assets in use
- * to be deleted via the Media Module.
- *
- * @api
- */
-#[Flow\Scope('singleton')]
-final class UserIdProviderFactory implements UserIdProviderFactoryInterface
-{
-    public function __construct(
-        private readonly UserService $userService
-    ) {
-    }
-
-    /**
-     * @param array<string, mixed> $options
-     */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): UserIdProviderInterface
-    {
-        return new UserIdProvider($this->userService);
-    }
-}
diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
index a3ae6ab53c0..c088a067cbe 100644
--- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
+++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
@@ -3,8 +3,8 @@ Neos:
     presets:
       'default':
 
-        userIdProvider:
-          factoryObjectName: Neos\Neos\UserIdProvider\UserIdProviderFactory
+        authProvider:
+          factoryObjectName: Neos\Neos\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory
 
         projections:
           'Neos.Neos:DocumentUriPathProjection':

From 4a9361072f76bd414b6ec31fc1a39b6424d50c44 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Thu, 17 Oct 2024 17:36:31 +0200
Subject: [PATCH 02/58] WIP: FEATURE: Content Repository Privileges

---
 .../Classes/ContentRepository.php             |   9 ++
 .../RebasableToOtherWorkspaceInterface.php    |   2 +
 .../Command/AddDimensionShineThrough.php      |   5 +
 .../Command/MoveDimensionSpacePoint.php       |   5 +
 ...gregateWithNodeAndSerializedProperties.php |   5 +
 .../Command/DisableNodeAggregate.php          |   5 +
 .../Command/EnableNodeAggregate.php           |   5 +
 .../Command/CopyNodesRecursively.php          |   5 +
 .../Command/SetSerializedNodeProperties.php   |   5 +
 .../NodeMove/Command/MoveNodeAggregate.php    |   5 +
 .../Command/SetSerializedNodeReferences.php   |   5 +
 .../Command/RemoveNodeAggregate.php           |   5 +
 .../Command/ChangeNodeAggregateName.php       |   5 +
 .../Command/ChangeNodeAggregateType.php       |   5 +
 .../Command/CreateNodeVariant.php             |   5 +
 .../CreateRootNodeAggregateWithNode.php       |   5 +
 .../UpdateRootNodeAggregateDimensions.php     |   5 +
 .../SubtreeTagging/Command/TagSubtree.php     |   5 +
 .../SubtreeTagging/Command/UntagSubtree.php   |   5 +
 .../ContentGraph/VisibilityConstraints.php    |   4 +-
 .../Auth/AuthProviderInterface.php            |   3 +
 .../SharedModel/Auth/StaticAuthProvider.php   |   6 +
 .../Bootstrap/CRTestSuiteRuntimeVariables.php |   2 +-
 .../Features/Bootstrap/CRTestSuiteTrait.php   |   2 +-
 .../Bootstrap/Helpers/FakeAuthProvider.php    |   6 +
 .../ContentRepositoryAuthProvider.php         | 147 ------------------
 .../Controller/Frontend/NodeController.php    |  17 +-
 .../Cache/NeosFusionContextSerializer.php     |   7 +-
 .../Privilege/SubtreeTagPrivilege.php         |  40 +++++
 .../Privilege/SubtreeTagPrivilegeSubject.php  |  29 ++++
 .../ContentRepositoryAuthProvider.php         | 126 +++++++++++++++
 .../ContentRepositoryAuthProviderFactory.php  |   3 +-
 .../NodeAddressToNodeConverter.php            |   9 +-
 .../Classes/View/FusionExceptionView.php      |   2 +-
 .../Settings.ContentRepositoryRegistry.yaml   |   2 +-
 35 files changed, 317 insertions(+), 184 deletions(-)
 delete mode 100644 Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
 create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
 create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
 create mode 100644 Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
 rename Neos.Neos/Classes/{ => Security}/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php (89%)

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index f2e50762a18..7ba16616c99 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -18,6 +18,7 @@
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
 use Neos\ContentRepository\Core\CommandHandler\CommandResult;
 use Neos\ContentRepository\Core\Dimension\ContentDimensionSourceInterface;
+use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
 use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph;
 use Neos\ContentRepository\Core\EventStore\DecoratedEvent;
 use Neos\ContentRepository\Core\EventStore\EventInterface;
@@ -30,6 +31,7 @@
 use Neos\ContentRepository\Core\Projection\CatchUp;
 use Neos\ContentRepository\Core\Projection\CatchUpOptions;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks;
 use Neos\ContentRepository\Core\Projection\ProjectionStateInterface;
@@ -283,6 +285,13 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter
         return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName);
     }
 
+    public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface
+    {
+        $contentGraph = $this->getContentGraph($workspaceName);
+        $visibilityConstraints = $this->authProvider->getVisibilityConstraints($workspaceName);
+        return $contentGraph->getSubgraph($dimensionSpacePoint, $visibilityConstraints);
+    }
+
     public function getNodeTypeManager(): NodeTypeManager
     {
         return $this->nodeTypeManager;
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
index 4d2d5094818..844f12ae402 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
@@ -31,6 +31,8 @@ public function createCopyForWorkspace(
         WorkspaceName $targetWorkspaceName,
     ): CommandInterface;
 
+    public function getWorkspaceName(): WorkspaceName;
+
     /**
      * called during deserialization from metadata
      * @param array<string,mixed> $array
diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
index 936505d278d..e3143635b15 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
@@ -84,6 +84,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
+
     /**
      * @return array<string,\JsonSerializable>
      */
diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
index 498ba261e49..e6ae20eb2b5 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
@@ -80,6 +80,11 @@ public function createCopyForWorkspace(
         );
     }
 
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
+
     /**
      * @return array<string,\JsonSerializable>
      */
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
index f295cd6dabe..6f1f44c2531 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
@@ -176,4 +176,9 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
index 454b7c2ccb2..527e19ae21c 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
@@ -99,4 +99,9 @@ public function createCopyForWorkspace(
             $this->nodeVariantSelectionStrategy
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
index de0ad11d57d..41d70615b29 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
@@ -99,4 +99,9 @@ public function createCopyForWorkspace(
             $this->nodeVariantSelectionStrategy
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
index 52355fd5e4a..20ca7998a06 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
@@ -183,4 +183,9 @@ public function createCopyForWorkspace(
             $this->nodeAggregateIdMapping
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
index ca589337e85..edae34fbc7f 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
@@ -119,4 +119,9 @@ public function createCopyForWorkspace(
             $this->propertiesToUnset,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
index 3a756163488..ca2fe213734 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
@@ -131,4 +131,9 @@ public function createCopyForWorkspace(
             $this->newSucceedingSiblingNodeAggregateId
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
index 3f635af08e0..6ddcdbb6c93 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
@@ -107,4 +107,9 @@ public function createCopyForWorkspace(
             $this->references,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
index 085af255b8c..654edc63dd5 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
@@ -119,4 +119,9 @@ public function createCopyForWorkspace(
             $this->removalAttachmentPoint,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
index 38d1f195ed6..5df67f01181 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
@@ -93,4 +93,9 @@ public function createCopyForWorkspace(
             $this->newNodeName,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
index e979e4d25c1..ede35595343 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
@@ -118,4 +118,9 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
index 001f9bd66e9..a8224f79dbc 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
@@ -97,4 +97,9 @@ public function createCopyForWorkspace(
             $this->targetOrigin,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
index 9bbb1f320cc..b2fe51dc5c6 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
@@ -135,4 +135,9 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
index 302c05ed895..489c0d96ca5 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
@@ -78,4 +78,9 @@ public function createCopyForWorkspace(
             $this->nodeAggregateId,
         );
     }
+
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
index 71fb012b0cc..21e9ad2cb21 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
@@ -89,6 +89,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
+
     public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool
     {
         return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId)
diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
index cca49333c95..7aa78d270aa 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
@@ -90,6 +90,11 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
+    public function getWorkspaceName(): WorkspaceName
+    {
+        return $this->workspaceName;
+    }
+
     public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool
     {
         return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId)
diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
index 7462e28a4e1..7271e3634a3 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
@@ -29,7 +29,7 @@
     /**
      * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query
      */
-    private function __construct(
+    public function __construct(
         public SubtreeTags $tagConstraints,
     ) {
     }
@@ -48,7 +48,7 @@ public static function withoutRestrictions(): self
         return new self(SubtreeTags::createEmpty());
     }
 
-    public static function frontend(): VisibilityConstraints
+    public static function default(): VisibilityConstraints
     {
         return new self(SubtreeTags::fromStrings('disabled'));
     }
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
index b2d03733050..b01147cf727 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
@@ -5,6 +5,7 @@
 namespace Neos\ContentRepository\Core\SharedModel\Auth;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
 /**
@@ -16,5 +17,7 @@ public function getUserId(): UserId;
 
     public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege;
 
+    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints;
+
     public function getCommandPrivilege(CommandInterface $command): Privilege;
 }
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
index 66ad3102805..4b49b9bcea3 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
@@ -5,6 +5,7 @@
 namespace Neos\ContentRepository\Core\SharedModel\Auth;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
 /**
@@ -24,6 +25,11 @@ public function getUserId(): UserId
         return $this->userId;
     }
 
+    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
+    {
+        return VisibilityConstraints::default();
+    }
+
     public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
     {
         return Privilege::granted();
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
index 1bdc2a5be01..66b1e76d28d 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
@@ -118,7 +118,7 @@ public function visibilityConstraintsAreSetTo(string $restrictionType): void
     {
         $this->currentVisibilityConstraints = match ($restrictionType) {
             'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(),
-            'frontend' => VisibilityConstraints::frontend(),
+            'frontend' => VisibilityConstraints::default(),
             default => throw new \InvalidArgumentException('Visibility constraint "' . $restrictionType . '" not supported.'),
         };
     }
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php
index 6dbe16daf40..b067f059ae2 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteTrait.php
@@ -93,7 +93,7 @@ public function beforeEventSourcedScenarioDispatcher(BeforeScenarioScope $scope)
             $this->contentRepositories = [];
         }
         $this->currentContentRepository = null;
-        $this->currentVisibilityConstraints = VisibilityConstraints::frontend();
+        $this->currentVisibilityConstraints = VisibilityConstraints::default();
         $this->currentDimensionSpacePoint = null;
         $this->currentRootNodeAggregateId = null;
         $this->currentWorkspaceName = null;
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index 977e82ac1ec..605c17e0957 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -5,6 +5,7 @@
 namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
 use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
@@ -25,6 +26,11 @@ public function getUserId(): UserId
         return self::$userId ?? UserId::forSystemUser();
     }
 
+    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
+    {
+        return VisibilityConstraints::withoutRestrictions();
+    }
+
     public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
     {
         return Privilege::granted();
diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
deleted file mode 100644
index b154f482211..00000000000
--- a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ /dev/null
@@ -1,147 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\Neos\ContentRepositoryAuthProvider;
-
-use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
-use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough;
-use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint;
-use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
-use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties;
-use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate;
-use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate;
-use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively;
-use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
-use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
-use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate;
-use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences;
-use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences;
-use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate;
-use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName;
-use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType;
-use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant;
-use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode;
-use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions;
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree;
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree;
-use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
-use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
-use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
-use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Neos\Domain\Model\WorkspacePermissions;
-use Neos\Neos\Domain\Service\UserService;
-use Neos\Neos\Domain\Service\WorkspaceService;
-
-/**
- * @api
- */
-final class ContentRepositoryAuthProvider implements AuthProviderInterface
-{
-    public function __construct(
-        private readonly ContentRepositoryId $contentRepositoryId,
-        private readonly UserService $userService,
-        private readonly WorkspaceService $workspaceService,
-        private readonly SecurityContext $securityContext,
-    ) {
-    }
-
-    public function getUserId(): UserId
-    {
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
-            return UserId::forSystemUser();
-        }
-        return UserId::fromString($user->getId()->value);
-    }
-
-    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
-    {
-        if ($this->securityContext->areAuthorizationChecksDisabled()) {
-            return Privilege::granted();
-        }
-        $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName);
-        if ($workspacePermissions === null) {
-            return Privilege::denied('No user is authenticated');
-        }
-        return match ($privilegeType) {
-            WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User has no read permission for workspace "%s"', $workspaceName->value)),
-        };
-    }
-
-    public function getCommandPrivilege(CommandInterface $command): Privilege
-    {
-        if ($this->securityContext->areAuthorizationChecksDisabled()) {
-            return Privilege::granted();
-        }
-        if ($command instanceof CreateWorkspace) {
-            $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName);
-            if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) {
-                return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value));
-            }
-            return Privilege::granted();
-        }
-        list($privilege, $workspaceName) = match ($command::class) {
-            AddDimensionShineThrough::class,
-            ChangeNodeAggregateName::class,
-            ChangeNodeAggregateType::class,
-            CopyNodesRecursively::class,
-            CreateNodeAggregateWithNode::class,
-            CreateNodeAggregateWithNodeAndSerializedProperties::class,
-            CreateNodeVariant::class,
-            CreateRootNodeAggregateWithNode::class,
-            DisableNodeAggregate::class,
-            DiscardIndividualNodesFromWorkspace::class,
-            DiscardWorkspace::class,
-            EnableNodeAggregate::class,
-            MoveDimensionSpacePoint::class,
-            MoveNodeAggregate::class,
-            PublishIndividualNodesFromWorkspace::class,
-            PublishWorkspace::class,
-            RebaseWorkspace::class,
-            RemoveNodeAggregate::class,
-            SetNodeProperties::class,
-            SetNodeReferences::class,
-            SetSerializedNodeProperties::class,
-            SetSerializedNodeReferences::class,
-            TagSubtree::class,
-            UntagSubtree::class,
-            UpdateRootNodeAggregateDimensions::class => ['write', $command->workspaceName],
-            ChangeBaseWorkspace::class,
-            CreateRootWorkspace::class,
-            CreateWorkspace::class,
-            DeleteWorkspace::class => ['manage', $command->workspaceName],
-            default => [null, null],
-        };
-        if ($privilege === null) {
-            return Privilege::granted();
-        }
-        $workspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($workspaceName);
-        if ($workspacePermissions === null) {
-            return Privilege::denied(sprintf('No user is authenticated to %s workspace "%s" because no user is authenticated', $privilege, $workspaceName->value));
-        }
-        $privilegeGranted = $privilege === 'write' ? $workspacePermissions->write : $workspacePermissions->manage;
-        return $privilegeGranted ? Privilege::granted() : Privilege::denied(sprintf('User has no %s permission for workspace "%s"', $privilege, $workspaceName->value));
-    }
-
-    private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions
-    {
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
-            return null;
-        }
-        return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
-    }
-}
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index a426c24328f..36fba3aacfe 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -20,7 +20,6 @@
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree;
-use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
@@ -125,21 +124,12 @@ public function previewAction(string $node): void
     {
         // @todo add $renderingModeName as parameter and append it for successive links again as get parameter to node uris
         $renderingMode = $this->renderingModeService->findByCurrentUser();
-
-        $visibilityConstraints = VisibilityConstraints::frontend();
-        if ($this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.GeneralAccess')) {
-            $visibilityConstraints = VisibilityConstraints::withoutRestrictions();
-        }
-
         $siteDetectionResult = SiteDetectionResult::fromRequest($this->request->getHttpRequest());
         $contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId);
 
         $nodeAddress = NodeAddress::fromJsonString($node);
 
-        $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph(
-            $nodeAddress->dimensionSpacePoint,
-            $visibilityConstraints
-        );
+        $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
 
         $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId);
 
@@ -204,10 +194,7 @@ public function showAction(string $node): void
         }
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph(
-            $nodeAddress->dimensionSpacePoint,
-            VisibilityConstraints::frontend()
-        );
+        $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
         $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool);
 
         $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId);
diff --git a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php
index ac47b1e3172..cf9165eb0fa 100644
--- a/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php
+++ b/Neos.Neos/Classes/Fusion/Cache/NeosFusionContextSerializer.php
@@ -73,12 +73,7 @@ private function tryDeserializeNode(array $serializedNode): ?Node
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
 
         try {
-            $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph(
-                $nodeAddress->dimensionSpacePoint,
-                $nodeAddress->workspaceName->isLive()
-                    ? VisibilityConstraints::frontend()
-                    : VisibilityConstraints::withoutRestrictions()
-            );
+            $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
         } catch (WorkspaceDoesNotExist $exception) {
             // in case the workspace was deleted the rendering should probably not come to this very point
             // still if it does we fail silently
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
new file mode 100644
index 00000000000..2b3d1f157d0
--- /dev/null
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
@@ -0,0 +1,40 @@
+<?php
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\Authorization\Privilege;
+
+use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
+use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
+use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
+
+/**
+ * TODO docs
+ */
+class SubtreeTagPrivilege extends AbstractPrivilege
+{
+    /**
+     * 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);
+        }
+        return false;
+    }
+}
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
new file mode 100644
index 00000000000..df2cd33c5b6
--- /dev/null
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
@@ -0,0 +1,29 @@
+<?php
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\Authorization\Privilege;
+
+use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
+
+/**
+ * A subject for the {@see SubtreeTagPrivilege}
+ */
+final readonly class SubtreeTagPrivilegeSubject implements PrivilegeSubjectInterface
+{
+    public function __construct(
+        public string $subTreeTag,
+        public string|null $contentRepository = null,
+    ) {
+    }
+}
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
new file mode 100644
index 00000000000..ed63f43ee57
--- /dev/null
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
+use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
+use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
+use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
+use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Flow\Security\Policy\PolicyService;
+use Neos\Neos\Domain\Model\WorkspacePermissions;
+use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege;
+
+/**
+ * @api
+ */
+final class ContentRepositoryAuthProvider implements AuthProviderInterface
+{
+    public function __construct(
+        private readonly ContentRepositoryId $contentRepositoryId,
+        private readonly UserService $userService,
+        private readonly WorkspaceService $workspaceService,
+        private readonly SecurityContext $securityContext,
+        private readonly PolicyService $policyService,
+    ) {
+    }
+
+    public function getUserId(): UserId
+    {
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return UserId::forSystemUser();
+        }
+        return UserId::fromString($user->getId()->value);
+    }
+
+    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return VisibilityConstraints::default();
+        }
+        $restrictedSubtreeTags = [SubtreeTag::disabled()];
+        try {
+            /** @var array<SubtreeTagPrivilege> $subtreeTagPrivileges */
+            $subtreeTagPrivileges = $this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class);
+        } catch (\Exception $e) {
+            throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e);
+        }
+        foreach ($subtreeTagPrivileges as $privilege) {
+            if (!$privilege->isGranted()) {
+                $restrictedSubtreeTags[] = SubtreeTag::fromString($privilege->getParsedMatcher());
+            }
+        }
+        return new VisibilityConstraints(
+            SubtreeTags::fromArray($restrictedSubtreeTags)
+        );
+    }
+
+    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return Privilege::granted();
+        }
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return Privilege::denied('No user is authenticated');
+        }
+        $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+        return match ($privilegeType) {
+            WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)),
+        };
+    }
+
+    public function getCommandPrivilege(CommandInterface $command): Privilege
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return Privilege::granted();
+        }
+        // TODO handle:
+        // ChangeBaseWorkspace
+        // CreateRootWorkspace
+        // DeleteWorkspace
+        // DiscardIndividualNodesFromWorkspace
+        // DiscardWorkspace
+        // PublishWorkspace
+        // PublishIndividualNodesFromWorkspace
+        // RebaseWorkspace
+        if ($command instanceof CreateWorkspace) {
+            $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName);
+            if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) {
+                return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value));
+            }
+            return Privilege::granted();
+        }
+        if (!$command instanceof RebasableToOtherWorkspaceInterface) {
+            return Privilege::granted();
+        }
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return Privilege::denied('No user is authenticated');
+        }
+        $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $command->getWorkspaceName(), $user);
+        return $workspacePermissions->write ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no write permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $command->getWorkspaceName()->value));
+    }
+
+    private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions
+    {
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return null;
+        }
+        return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+    }
+}
diff --git a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
similarity index 89%
rename from Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
rename to Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index 63f84ae439e..d1535c50275 100644
--- a/Neos.Neos/Classes/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -2,9 +2,8 @@
 
 declare(strict_types=1);
 
-namespace Neos\Neos\ContentRepositoryAuthProvider;
+namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\Flow\Annotations as Flow;
diff --git a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php
index c8744bb5eda..4157629043d 100644
--- a/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php
+++ b/Neos.Neos/Classes/TypeConverter/NodeAddressToNodeConverter.php
@@ -15,7 +15,6 @@
  */
 
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
-use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
@@ -59,13 +58,7 @@ public function convertFrom(
     ) {
         $nodeAddress = NodeAddress::fromJsonString($source);
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        $subgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)
-            ->getSubgraph(
-                $nodeAddress->dimensionSpacePoint,
-                $nodeAddress->workspaceName->isLive()
-                    ? VisibilityConstraints::frontend()
-                    : VisibilityConstraints::withoutRestrictions()
-            );
+        $subgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
 
         return $subgraph->findNodeById($nodeAddress->aggregateId);
     }
diff --git a/Neos.Neos/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php
index fd6814acf20..564304dbda0 100644
--- a/Neos.Neos/Classes/View/FusionExceptionView.php
+++ b/Neos.Neos/Classes/View/FusionExceptionView.php
@@ -123,7 +123,7 @@ public function render(): ResponseInterface|StreamInterface
                 $site,
                 WorkspaceName::forLive(),
                 $dimensionSpacePoint,
-                VisibilityConstraints::frontend()
+                VisibilityConstraints::default()
             );
         } catch (WorkspaceDoesNotExist | \RuntimeException) {
             return $this->renderErrorWelcomeScreen();
diff --git a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
index c088a067cbe..ea085721623 100644
--- a/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
+++ b/Neos.Neos/Configuration/Settings.ContentRepositoryRegistry.yaml
@@ -4,7 +4,7 @@ Neos:
       'default':
 
         authProvider:
-          factoryObjectName: Neos\Neos\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory
+          factoryObjectName: Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory
 
         projections:
           'Neos.Neos:DocumentUriPathProjection':

From cf1273c006daeed0f754675e05f64be1e2e26259 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Thu, 17 Oct 2024 18:15:47 +0200
Subject: [PATCH 03/58] Fix ContentRepositoryAuthProviderFactory

---
 .../ContentRepositoryAuthProviderFactory.php         | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index d1535c50275..d708cb18ac8 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -7,6 +7,8 @@
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\Flow\Annotations as Flow;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Domain\Service\WorkspaceService;
 
@@ -16,11 +18,13 @@
  * @api
  */
 #[Flow\Scope('singleton')]
-final class ContentRepositoryAuthProviderFactory implements AuthProviderFactoryInterface
+final readonly class ContentRepositoryAuthProviderFactory implements AuthProviderFactoryInterface
 {
     public function __construct(
-        private readonly UserService $userService,
-        private readonly WorkspaceService $workspaceService,
+        private UserService $userService,
+        private WorkspaceService $workspaceService,
+        private SecurityContext $securityContext,
+        private PolicyService $policyService,
     ) {
     }
 
@@ -29,6 +33,6 @@ public function __construct(
      */
     public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider
     {
-        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService);
+        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService, $this->securityContext, $this->policyService);
     }
 }

From b1fe6c68cca50bfb0d0cf06464712723c497dcff Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Fri, 18 Oct 2024 13:11:10 +0200
Subject: [PATCH 04/58] Simplify
 `AuthProviderInterface::getWorkspacePrivilege()`

---
 .../Classes/ContentRepository.php             |  2 +-
 .../Auth/AuthProviderInterface.php            |  2 +-
 .../SharedModel/Auth/StaticAuthProvider.php   |  2 +-
 .../Auth/WorkspacePrivilegeType.php           | 23 -------------------
 .../Bootstrap/Helpers/FakeAuthProvider.php    |  2 +-
 .../ContentRepositoryAuthProvider.php         |  9 ++++----
 6 files changed, 9 insertions(+), 31 deletions(-)
 delete mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index 77dce980c42..b6d5448e188 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -249,7 +249,7 @@ public function resetProjectionState(string $projectionClassName): void
      */
     public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface
     {
-        $privilege = $this->authProvider->getWorkspacePrivilege($workspaceName, WorkspacePrivilegeType::READ_NODES);
+        $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName);
         if (!$privilege->granted) {
             // TODO more specific exception
             throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760);
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
index b01147cf727..98b488bd420 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
@@ -15,7 +15,7 @@ interface AuthProviderInterface
 {
     public function getUserId(): UserId;
 
-    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege;
+    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege;
 
     public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints;
 
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
index 4b49b9bcea3..b7b8f5a3b86 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
@@ -30,7 +30,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         return VisibilityConstraints::default();
     }
 
-    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
         return Privilege::granted();
     }
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php
deleted file mode 100644
index c08d6aa4d46..00000000000
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/WorkspacePrivilegeType.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-/*
- * This file is part of the Neos.ContentRepository package.
- *
- * (c) Contributors of the Neos Project - www.neos.io
- *
- * This package is Open Source Software. For the full copyright and license
- * information, please view the LICENSE file which was distributed with this
- * source code.
- */
-
-declare(strict_types=1);
-
-namespace Neos\ContentRepository\Core\SharedModel\Auth;
-
-/**
- * @api
- */
-enum WorkspacePrivilegeType
-{
-    case READ_NODES;
-}
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index 605c17e0957..e056ae14d54 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -31,7 +31,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         return VisibilityConstraints::withoutRestrictions();
     }
 
-    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
         return Privilege::granted();
     }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index ed63f43ee57..2666e9f768b 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -68,7 +68,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         );
     }
 
-    public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePrivilegeType $privilegeType): Privilege
+    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted();
@@ -78,9 +78,7 @@ public function getWorkspacePrivilege(WorkspaceName $workspaceName, WorkspacePri
             return Privilege::denied('No user is authenticated');
         }
         $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
-        return match ($privilegeType) {
-            WorkspacePrivilegeType::READ_NODES => $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value)),
-        };
+        return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value));
     }
 
     public function getCommandPrivilege(CommandInterface $command): Privilege
@@ -104,6 +102,9 @@ public function getCommandPrivilege(CommandInterface $command): Privilege
             }
             return Privilege::granted();
         }
+        // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all
+        // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the
+        // code if we were to add new commands in the future
         if (!$command instanceof RebasableToOtherWorkspaceInterface) {
             return Privilege::granted();
         }

From 3dbf15cfb880b825b6a2a0d25433363d28573d11 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Fri, 18 Oct 2024 13:11:38 +0200
Subject: [PATCH 05/58] Always grant read access to `live` workspace

---
 .../ContentRepositoryAuthProvider.php                           | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 2666e9f768b..cc9e59012d0 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -75,7 +75,7 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName)
         }
         $user = $this->userService->getCurrentUser();
         if ($user === null) {
-            return Privilege::denied('No user is authenticated');
+            return $workspaceName->isLive() ? Privilege::granted() : Privilege::denied('No user is authenticated');
         }
         $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
         return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value));

From ea2a9d5706f9e86f4f5595f1e70c964d6be80a39 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Fri, 18 Oct 2024 13:13:04 +0200
Subject: [PATCH 06/58] Support CR specific subtree tag privileges

```yaml

privilegeTargets:

  'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege':

    'Some.Package:FooInAllContentRepositories':
      matcher: 'foo'

    'Some.Package:BarInDefaultContentRepository':
      matcher: 'default:bar'
```
---
 .../ContentRepositoryAuthProvider.php                | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index cc9e59012d0..1123cf07e64 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -59,9 +59,17 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
             throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e);
         }
         foreach ($subtreeTagPrivileges as $privilege) {
-            if (!$privilege->isGranted()) {
-                $restrictedSubtreeTags[] = SubtreeTag::fromString($privilege->getParsedMatcher());
+            if ($privilege->isGranted()) {
+                continue;
             }
+            $subtreeTag = $privilege->getParsedMatcher();
+            if (str_contains($subtreeTag, ':')) {
+                [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag);
+                if ($this->contentRepositoryId->value !== $contentRepositoryId) {
+                    continue;
+                }
+            }
+            $restrictedSubtreeTags[] = SubtreeTag::fromString($subtreeTag);
         }
         return new VisibilityConstraints(
             SubtreeTags::fromArray($restrictedSubtreeTags)

From 6bf2e73222ecb1263298afbc695d37026d0005ee Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Fri, 18 Oct 2024 15:47:36 +0200
Subject: [PATCH 07/58] Rename `AuthProviderInterface::getUserId()`

to `getAuthenticatedUserId()` and make return type nullable
---
 Neos.ContentRepository.Core/Classes/ContentRepository.php     | 4 ++--
 .../Classes/SharedModel/Auth/AuthProviderInterface.php        | 2 +-
 .../Classes/SharedModel/Auth/StaticAuthProvider.php           | 2 +-
 .../Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php  | 4 ++--
 .../ContentRepositoryAuthProvider.php                         | 4 ++--
 5 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index b6d5448e188..7f2b6ab77c0 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -38,7 +38,7 @@
 use Neos\ContentRepository\Core\Projection\ProjectionStatuses;
 use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface;
 use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
+use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus;
 use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
@@ -111,7 +111,7 @@ public function handle(CommandInterface $command): CommandResult
         $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies);
 
         // TODO meaningful exception message
-        $initiatingUserId = $this->authProvider->getUserId();
+        $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser();
         $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM);
 
         // Add "initiatingUserId" and "initiatingTimestamp" metadata to all events.
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
index 98b488bd420..84890012d9d 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
@@ -13,7 +13,7 @@
  */
 interface AuthProviderInterface
 {
-    public function getUserId(): UserId;
+    public function getAuthenticatedUserId(): ?UserId;
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege;
 
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
index b7b8f5a3b86..893c6967573 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
@@ -20,7 +20,7 @@ public function __construct(
     ) {
     }
 
-    public function getUserId(): UserId
+    public function getAuthenticatedUserId(): UserId
     {
         return $this->userId;
     }
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index e056ae14d54..163da1395ad 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -21,9 +21,9 @@ public static function setUserId(UserId $userId): void
         self::$userId = $userId;
     }
 
-    public function getUserId(): UserId
+    public function getAuthenticatedUserId(): ?UserId
     {
-        return self::$userId ?? UserId::forSystemUser();
+        return self::$userId ?? null;
     }
 
     public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 1123cf07e64..7b073144476 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -37,11 +37,11 @@ public function __construct(
     ) {
     }
 
-    public function getUserId(): UserId
+    public function getAuthenticatedUserId(): ?UserId
     {
         $user = $this->userService->getCurrentUser();
         if ($user === null) {
-            return UserId::forSystemUser();
+            return null;
         }
         return UserId::fromString($user->getId()->value);
     }

From bf57af6d351550860be19f28b8318889fc9d2836 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Sat, 19 Oct 2024 13:16:45 +0200
Subject: [PATCH 08/58] Extract permission evaluation from
 `ContentRepositoryAuthProvider` to new
 `ContentRepositoryAuthorizationService` singleton

---
 .../src/Domain/Repository/ContentSubgraph.php |  15 ++
 .../Repository/ContentSubhypergraph.php       |  15 ++
 .../Classes/ContentRepository.php             |   4 +-
 .../Classes/ContentRepositoryReadModel.php    |   3 -
 .../ContentGraph/ContentSubgraphInterface.php |   2 +
 .../ContentGraph/VisibilityConstraints.php    |   5 +
 .../Classes/SharedModel/Auth/Privilege.php    |  10 +-
 .../SharedModel/Auth/StaticAuthProvider.php   |   4 +-
 .../Bootstrap/Helpers/FakeAuthProvider.php    |   5 +-
 .../Classes/Service/EventMigrationService.php |  15 +-
 .../ContentSubgraphWithRuntimeCaches.php      |   5 +
 .../Classes/Controller/UsageController.php    |   9 +-
 .../Classes/Command/CrCommandController.php   |   1 +
 .../Command/WorkspaceCommandController.php    |  40 +++---
 .../Controller/Frontend/NodeController.php    |   4 +
 .../Domain/Model/WorkspacePermissions.php     |  48 +++++--
 .../Classes/Domain/Model/WorkspaceRole.php    |  10 +-
 .../Domain/Model/WorkspaceRoleAssignment.php  |  10 +-
 .../Domain/Model/WorkspaceRoleAssignments.php |   3 +-
 .../Domain/Model/WorkspaceRoleSubject.php     |  35 +++--
 .../Domain/Model/WorkspaceRoleSubjects.php    |  50 +++++++
 .../Domain/Service/WorkspaceService.php       | 135 +++++++++---------
 .../ContentRepositoryAuthorizationService.php | 127 ++++++++++++++++
 .../Privilege/SubtreeTagPrivilege.php         |  37 ++++-
 .../Privilege/SubtreeTagPrivilegeSubject.php  |   6 +-
 .../ContentRepositoryAuthProvider.php         | 124 ++++++++--------
 .../ContentRepositoryAuthProviderFactory.php  |   8 +-
 Neos.Neos/Configuration/Policy.yaml           |  11 ++
 .../Bootstrap/WorkspaceServiceTrait.php       |  23 ++-
 .../Controller/WorkspaceController.php        |  10 +-
 30 files changed, 549 insertions(+), 225 deletions(-)
 create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php
 create mode 100644 Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php

diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
index 810a095069f..72b74631867 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
@@ -122,6 +122,21 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->visibilityConstraints;
     }
 
+    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
+    {
+        return new self(
+            $this->contentRepositoryId,
+            $this->workspaceName,
+            $this->contentStreamId,
+            $this->dimensionSpacePoint,
+            $newVisibilityConstraints,
+            $this->dbal,
+            $this->nodeFactory,
+            $this->nodeTypeManager,
+            $this->nodeQueryBuilder->tableNames,
+        );
+    }
+
     public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes
     {
         $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter);
diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
index 8b312166d2a..994a880dae7 100644
--- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
+++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
@@ -106,6 +106,21 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->visibilityConstraints;
     }
 
+    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
+    {
+        return new self(
+            $this->contentRepositoryId,
+            $this->contentStreamId,
+            $this->workspaceName,
+            $this->dimensionSpacePoint,
+            $newVisibilityConstraints,
+            $this->dbal,
+            $this->nodeFactory,
+            $this->nodeTypeManager,
+            $this->tableNamePrefix,
+        );
+    }
+
     public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node
     {
         $query = HypergraphQuery::create($this->contentStreamId, $this->tableNamePrefix);
diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index 7f2b6ab77c0..ad566c64655 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -104,7 +104,7 @@ public function handle(CommandInterface $command): CommandResult
     {
         $privilege = $this->authProvider->getCommandPrivilege($command);
         if (!$privilege->granted) {
-            throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->message), 1729086686);
+            throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->reason), 1729086686);
         }
         // the commands only calculate which events they want to have published, but do not do the
         // publishing themselves
@@ -252,7 +252,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter
         $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName);
         if (!$privilege->granted) {
             // TODO more specific exception
-            throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->message ?? ''), 1729014760);
+            throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->reason ?? ''), 1729014760);
         }
         return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName);
     }
diff --git a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
index 631d827e624..26be912f0cd 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepositoryReadModel.php
@@ -16,9 +16,6 @@
 
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionStateInterface;
-use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
 use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStream;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
index c47c0771932..d2df7ce2dd7 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
@@ -57,6 +57,8 @@ public function getDimensionSpacePoint(): DimensionSpacePoint;
 
     public function getVisibilityConstraints(): VisibilityConstraints;
 
+    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self;
+
     /**
      * Find a single node by its aggregate id
      *
diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
index 7271e3634a3..598b9945a15 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
@@ -53,6 +53,11 @@ public static function default(): VisibilityConstraints
         return new self(SubtreeTags::fromStrings('disabled'));
     }
 
+    public function withAddedSubtreeTag(SubtreeTag $subtreeTag): self
+    {
+        return new self($this->tagConstraints->merge(SubtreeTags::fromArray([$subtreeTag])));
+    }
+
     /**
      * @return array<string, mixed>
      */
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
index e7746a7dfe4..cfe8798273b 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
@@ -22,17 +22,17 @@
 {
     private function __construct(
         public bool $granted,
-        public ?string $message,
+        public string $reason,
     ) {
     }
 
-    public static function granted(): self
+    public static function granted(string $reason): self
     {
-        return new self(true, null);
+        return new self(true, $reason);
     }
 
-    public static function denied(string $message): self
+    public static function denied(string $reason): self
     {
-        return new self(false, $message);
+        return new self(false, $reason);
     }
 }
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
index 893c6967573..a44a4e61db1 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
@@ -32,11 +32,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
-        return Privilege::granted();
+        return Privilege::granted(self::class . ' always grants privileges');
     }
 
     public function getCommandPrivilege(CommandInterface $command): Privilege
     {
-        return Privilege::granted();
+        return Privilege::granted(self::class . ' always grants privileges');
     }
 }
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index 163da1395ad..c2cb6b8a84d 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -9,7 +9,6 @@
 use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
 use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
 final class FakeAuthProvider implements AuthProviderInterface
@@ -33,11 +32,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
-        return Privilege::granted();
+        return Privilege::granted(self::class . ' always grants privileges');
     }
 
     public function getCommandPrivilege(CommandInterface $command): Privilege
     {
-        return Privilege::granted();
+        return Privilege::granted(self::class . ' always grants privileges');
     }
 }
diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php
index 9cd7d315f80..a747c1611ba 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php
@@ -589,27 +589,32 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn):
             } catch (UniqueConstraintViolationException) {
                 $outputFn('  Metadata already exists');
             }
-            $roleAssignment = [];
+            $roleAssignments = [];
             if ($workspaceName->isLive()) {
-                $roleAssignment = [
+                $roleAssignments[] = [
                     'subject_type' => WorkspaceRoleSubjectType::GROUP->value,
                     'subject' => 'Neos.Neos:LivePublisher',
                     'role' => WorkspaceRole::COLLABORATOR->value,
                 ];
+                $roleAssignments[] = [
+                    'subject_type' => WorkspaceRoleSubjectType::GROUP->value,
+                    'subject' => 'Neos.Neos:Everybody',
+                    'role' => WorkspaceRole::VIEWER->value,
+                ];
             } elseif ($isInternalWorkspace) {
-                $roleAssignment = [
+                $roleAssignments[] = [
                     'subject_type' => WorkspaceRoleSubjectType::GROUP->value,
                     'subject' => 'Neos.Neos:AbstractEditor',
                     'role' => WorkspaceRole::COLLABORATOR->value,
                 ];
             } elseif ($isPrivateWorkspace) {
-                $roleAssignment = [
+                $roleAssignments[] = [
                     'subject_type' => WorkspaceRoleSubjectType::USER->value,
                     'subject' => $workspaceOwner,
                     'role' => WorkspaceRole::COLLABORATOR->value,
                 ];
             }
-            if ($roleAssignment !== []) {
+            foreach ($roleAssignments as $roleAssignment) {
                 try {
                     $this->connection->insert('neos_neos_workspace_role', [
                         'content_repository_id' => $this->contentRepositoryId->value,
diff --git a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
index c26bcef38d1..f9f88c2e164 100644
--- a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
+++ b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
@@ -77,6 +77,11 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->wrappedContentSubgraph->getVisibilityConstraints();
     }
 
+    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
+    {
+        return new self($this->wrappedContentSubgraph->withVisibilityConstraints($newVisibilityConstraints), $this->subgraphCachePool);
+    }
+
     public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes
     {
         if (!self::isFilterEmpty($filter)) {
diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php
index 4e75aa55aa1..5d11138f12c 100644
--- a/Neos.Media.Browser/Classes/Controller/UsageController.php
+++ b/Neos.Media.Browser/Classes/Controller/UsageController.php
@@ -25,6 +25,7 @@
 use Neos\Neos\Domain\Service\NodeTypeNameFactory;
 use Neos\Neos\Domain\Service\WorkspaceService;
 use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 use Neos\Neos\Service\UserService;
 use Neos\Neos\AssetUsage\Dto\AssetUsageReference;
 
@@ -65,6 +66,12 @@ class UsageController extends ActionController
      */
     protected $workspaceService;
 
+    /**
+     * @Flow\Inject
+     * @var ContentRepositoryAuthorizationService
+     */
+    protected $contentRepositoryAuthorizationService;
+
     /**
      * Get Related Nodes for an asset
      *
@@ -103,7 +110,7 @@ public function relatedNodesAction(AssetInterface $asset)
             );
             $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null;
 
-            $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser(
+            $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser(
                 $currentContentRepositoryId,
                 $usage->getWorkspaceName(),
                 $currentUser
diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php
index a6c2f0606ad..12fc64ac987 100644
--- a/Neos.Neos/Classes/Command/CrCommandController.php
+++ b/Neos.Neos/Classes/Command/CrCommandController.php
@@ -122,6 +122,7 @@ public function importCommand(string $path, string $contentRepository = 'default
         // set the live-workspace title to (implicitly) create the metadata record for this workspace
         $this->workspaceService->setWorkspaceTitle($contentRepositoryId, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace'));
         $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR));
+        $this->workspaceService->assignWorkspaceRole($contentRepositoryId, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:Everybody', WorkspaceRole::VIEWER));
 
         $this->outputLine('<success>Done</success>');
     }
diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
index 2fdd87931c2..d4d9f2688ef 100644
--- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php
+++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
@@ -255,6 +255,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription,
      *
      * Without explicit workspace roles, only administrators can change the corresponding workspace.
      * With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles:
+     * - viewer: Can read from the workspace
      * - collaborator: Can read from and write to the workspace
      * - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments)
      *
@@ -284,30 +285,21 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro
             default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802),
         };
         $workspaceRole = match ($role) {
+            'viewer' => WorkspaceRole::VIEWER,
             'collaborator' => WorkspaceRole::COLLABORATOR,
             'manager' => WorkspaceRole::MANAGER,
-            default => throw new \InvalidArgumentException(sprintf('role must be "collaborator" or "manager", given "%s"', $role), 1728398880),
+            default => throw new \InvalidArgumentException(sprintf('role must be "viewer", "collaborator" or "manager", given "%s"', $role), 1728398880),
         };
-        if ($subjectType === WorkspaceRoleSubjectType::USER) {
-            $neosUser = $this->userService->getUser($subject);
-            if ($neosUser === null) {
-                $this->outputLine('<error>The user "%s" specified as subject does not exist</error>', [$subject]);
-                $this->quit(1);
-            }
-            $roleSubject = WorkspaceRoleSubject::fromString($neosUser->getId()->value);
-        } else {
-            $roleSubject = WorkspaceRoleSubject::fromString($subject);
-        }
+        $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject);
         $this->workspaceService->assignWorkspaceRole(
             $contentRepositoryId,
             $workspaceName,
             WorkspaceRoleAssignment::create(
-                $subjectType,
                 $roleSubject,
                 $workspaceRole
             )
         );
-        $this->outputLine('<success>Assigned role "%s" to subject "%s" for workspace "%s"</success>', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]);
+        $this->outputLine('<success>Assigned role "%s" to subject "%s" for workspace "%s"</success>', [$workspaceRole->value, $roleSubject, $workspaceName->value]);
     }
 
     /**
@@ -331,11 +323,10 @@ public function unassignRoleCommand(string $workspace, string $subject, string $
             'user' => WorkspaceRoleSubjectType::USER,
             default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802),
         };
-        $roleSubject = WorkspaceRoleSubject::fromString($subject);
+        $roleSubject = $this->buildWorkspaceRoleSubject($subjectType, $subject);
         $this->workspaceService->unassignWorkspaceRole(
             $contentRepositoryId,
             $workspaceName,
-            $subjectType,
             $roleSubject,
         );
         $this->outputLine('<success>Removed role assignment from subject "%s" for workspace "%s"</success>', [$roleSubject->value, $workspaceName->value]);
@@ -524,7 +515,7 @@ public function showCommand(string $workspace, string $contentRepository = 'defa
             return;
         }
         $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [
-            $assignment->subjectType->value,
+            $assignment->subject->type->value,
             $assignment->subject->value,
             $assignment->role->value,
         ], iterator_to_array($workspaceRoleAssignments)), [
@@ -533,4 +524,21 @@ public function showCommand(string $workspace, string $contentRepository = 'defa
             'Role',
         ]);
     }
+
+    // -----------------------
+
+    private function buildWorkspaceRoleSubject(WorkspaceRoleSubjectType $subjectType, string $usernameOrRoleIdentifier): WorkspaceRoleSubject
+    {
+        if ($subjectType === WorkspaceRoleSubjectType::USER) {
+            $neosUser = $this->userService->getUser($usernameOrRoleIdentifier);
+            if ($neosUser === null) {
+                $this->outputLine('<error>The user "%s" specified as subject does not exist</error>', [$usernameOrRoleIdentifier]);
+                $this->quit(1);
+            }
+            $roleSubject = WorkspaceRoleSubject::createForUser($neosUser->getId());
+        } else {
+            $roleSubject = WorkspaceRoleSubject::createForGroup($usernameOrRoleIdentifier);
+        }
+        return $roleSubject;
+    }
 }
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index 36fba3aacfe..9d5756d5fba 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -14,12 +14,14 @@
 
 namespace Neos\Neos\Controller\Frontend;
 
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindSubtreeFilter;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Subtree;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
@@ -195,6 +197,8 @@ public function showAction(string $node): void
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
         $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
+        // todo document
+        $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled()));
         $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool);
 
         $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId);
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
index faf543259cf..cdb2461f6bb 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
@@ -5,9 +5,10 @@
 namespace Neos\Neos\Domain\Model;
 
 use Neos\Flow\Annotations as Flow;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
- * Calculated permissions a specific user has on a workspace
+ * Evaluated permissions a specific user has on a workspace, usually evaluated by the {@see ContentRepositoryAuthorizationService}
  *
  * - read: Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph)
  * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it
@@ -22,29 +23,48 @@
      * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph)
      * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it
      * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles)
+     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
      */
-    public static function create(
-        bool $read,
-        bool $write,
-        bool $manage,
-    ): self {
-        return new self($read, $write, $manage);
+    private function __construct(
+        public bool $read,
+        public bool $write,
+        public bool $manage,
+        private string $reason,
+    ) {
     }
 
     /**
      * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph)
      * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it
      * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles)
+     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
      */
-    private function __construct(
-        public bool $read,
-        public bool $write,
-        public bool $manage,
-    ) {
+    public static function create(
+        bool $read,
+        bool $write,
+        bool $manage,
+        string $reason,
+    ): self {
+        return new self($read, $write, $manage, $reason);
+    }
+
+    public static function all(string $reason): self
+    {
+        return new self(true, true, true, $reason);
+    }
+
+    public static function manage(string $reason): self
+    {
+        return new self(false, false, true, $reason);
+    }
+
+    public static function none(string $reason): self
+    {
+        return new self(false, false, false, $reason);
     }
 
-    public static function all(): self
+    public function getReason(): string
     {
-        return new self(true, true, true);
+        return $this->reason;
     }
 }
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
index 898c961f5a0..a36269e22b8 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
@@ -12,6 +12,11 @@
  */
 enum WorkspaceRole : string
 {
+    /**
+     * Can read from the workspace
+     */
+    case VIEWER = 'VIEWER';
+
     /**
      * Can read from and write to the workspace
      */
@@ -30,8 +35,9 @@ public function isAtLeast(self $role): bool
     private function specificity(): int
     {
         return match ($this) {
-            self::COLLABORATOR => 1,
-            self::MANAGER => 2,
+            self::VIEWER => 1,
+            self::COLLABORATOR => 2,
+            self::MANAGER => 3,
         };
     }
 }
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php
index fd7d5a7896f..f8206c8d2ce 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php
@@ -15,25 +15,22 @@
 final readonly class WorkspaceRoleAssignment
 {
     private function __construct(
-        public WorkspaceRoleSubjectType $subjectType,
         public WorkspaceRoleSubject $subject,
         public WorkspaceRole $role,
     ) {
     }
 
     public static function create(
-        WorkspaceRoleSubjectType $subjectType,
         WorkspaceRoleSubject $subject,
         WorkspaceRole $role,
     ): self {
-        return new self($subjectType, $subject, $role);
+        return new self($subject, $role);
     }
 
     public static function createForUser(UserId $userId, WorkspaceRole $role): self
     {
         return new self(
-            WorkspaceRoleSubjectType::USER,
-            WorkspaceRoleSubject::fromString($userId->value),
+            WorkspaceRoleSubject::createForUser($userId),
             $role
         );
     }
@@ -41,8 +38,7 @@ public static function createForUser(UserId $userId, WorkspaceRole $role): self
     public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role): self
     {
         return new self(
-            WorkspaceRoleSubjectType::GROUP,
-            WorkspaceRoleSubject::fromString($flowRoleIdentifier),
+            WorkspaceRoleSubject::createForGroup($flowRoleIdentifier),
             $role
         );
     }
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php
index 82dc1eb4a3f..a63eb23b899 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php
@@ -5,7 +5,6 @@
 namespace Neos\Neos\Domain\Model;
 
 use Neos\Flow\Annotations as Flow;
-use Traversable;
 
 /**
  * A set of {@see WorkspaceRoleAssignment} instances
@@ -39,7 +38,7 @@ public function isEmpty(): bool
         return $this->assignments === [];
     }
 
-    public function getIterator(): Traversable
+    public function getIterator(): \Traversable
     {
         yield from $this->assignments;
     }
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
index fb80329b09d..c9255bcfe5f 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
@@ -12,28 +12,47 @@
  * @api
  */
 #[Flow\Proxy(false)]
-final readonly class WorkspaceRoleSubject implements \JsonSerializable
+final readonly class WorkspaceRoleSubject
 {
-    public function __construct(
-        public string $value
+    private function __construct(
+        public WorkspaceRoleSubjectType $type,
+        public string $value,
     ) {
         if (preg_match('/^[\p{L}\p{P}\d .]{1,200}$/u', $this->value) !== 1) {
             throw new \InvalidArgumentException(sprintf('"%s" is not a valid workspace role subject.', $value), 1728384932);
         }
     }
 
-    public static function fromString(string $value): self
+    public static function createForUser(UserId $userId): self
     {
-        return new self($value);
+        return new self(
+            WorkspaceRoleSubjectType::USER,
+            $userId->value,
+        );
     }
 
-    public function jsonSerialize(): string
+    public static function createForGroup(string $flowRoleIdentifier): self
     {
-        return $this->value;
+        return new self(
+            WorkspaceRoleSubjectType::GROUP,
+            $flowRoleIdentifier,
+        );
+    }
+
+    public static function create(
+        WorkspaceRoleSubjectType $type,
+        string $value,
+    ): self {
+        return new self($type, $value);
     }
 
     public function equals(self $other): bool
     {
-        return $this->value === $other->value;
+        return $this->type === $other->type && $this->value === $other->value;
+    }
+
+    public function __toString(): string
+    {
+        return "{$this->type->value}: {$this->value}";
     }
 }
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php
new file mode 100644
index 00000000000..5af2a3b2b36
--- /dev/null
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjects.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Domain\Model;
+
+use Neos\Flow\Annotations as Flow;
+
+/**
+ * A set of {@see WorkspaceRoleSubject} instances
+ *
+ * @implements \IteratorAggregate<WorkspaceRoleSubject>
+ * @api
+ */
+#[Flow\Proxy(false)]
+final readonly class WorkspaceRoleSubjects implements \IteratorAggregate, \Countable
+{
+    /**
+     * @var array<WorkspaceRoleSubject>
+     */
+    private array $subjects;
+
+    private function __construct(WorkspaceRoleSubject ...$subjects)
+    {
+        $this->subjects = $subjects;
+    }
+
+    /**
+     * @param array<WorkspaceRoleSubject> $subjects
+     */
+    public static function fromArray(array $subjects): self
+    {
+        return new self(...$subjects);
+    }
+
+    public function isEmpty(): bool
+    {
+        return $this->subjects === [];
+    }
+
+    public function getIterator(): \Traversable
+    {
+        yield from $this->subjects;
+    }
+
+    public function count(): int
+    {
+        return count($this->subjects);
+    }
+}
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 1431181f52e..4e9b2e892e8 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -28,19 +28,19 @@
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Flow\Security\Exception\NoSuchRoleException;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceClassification;
 use Neos\Neos\Domain\Model\WorkspaceDescription;
 use Neos\Neos\Domain\Model\WorkspaceMetadata;
-use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignment;
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignments;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType;
 use Neos\Neos\Domain\Model\WorkspaceTitle;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
  * Central authority to interact with Content Repository Workspaces within Neos
@@ -57,7 +57,6 @@ final class WorkspaceService
 
     public function __construct(
         private readonly ContentRepositoryRegistry $contentRepositoryRegistry,
-        private readonly UserService $userService,
         private readonly Connection $dbal,
         private readonly SecurityContext $securityContext,
     ) {
@@ -87,7 +86,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
-        // TODO check workspace permissions -> $this->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceName, $this->userService->getCurrentUser());
+        // TODO check workspace permissions
         $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
             'title' => $newWorkspaceTitle->value,
         ]);
@@ -98,6 +97,7 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work
      */
     public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void
     {
+        // TODO check workspace permissions
         $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
             'description' => $newWorkspaceDescription->value,
         ]);
@@ -201,7 +201,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo
             $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [
                 'content_repository_id' => $contentRepositoryId->value,
                 'workspace_name' => $workspaceName->value,
-                'subject_type' => $assignment->subjectType->value,
+                'subject_type' => $assignment->subject->type->value,
                 'subject' => $assignment->subject->value,
                 'role' => $assignment->role->value,
             ]);
@@ -217,14 +217,14 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo
      *
      * @see self::assignWorkspaceRole()
      */
-    public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject): void
+    public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void
     {
         $this->requireWorkspace($contentRepositoryId, $workspaceName);
         try {
             $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [
                 'content_repository_id' => $contentRepositoryId->value,
                 'workspace_name' => $workspaceName->value,
-                'subject_type' => $subjectType->value,
+                'subject_type' => $subject->type->value,
                 'subject' => $subject->value,
             ]);
         } catch (DbalException $e) {
@@ -238,7 +238,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId,
     /**
      * Get all role assignments for the specified workspace
      *
-     * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used!
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used!
      */
     public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments
     {
@@ -262,37 +262,73 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito
         }
         return WorkspaceRoleAssignments::fromArray(
             array_map(static fn (array $row) => WorkspaceRoleAssignment::create(
-                WorkspaceRoleSubjectType::from($row['subject_type']),
-                WorkspaceRoleSubject::fromString($row['subject']),
+                WorkspaceRoleSubject::create(
+                    WorkspaceRoleSubjectType::from($row['subject_type']),
+                    $row['subject'],
+                ),
                 WorkspaceRole::from($row['role']),
             ), $rows)
         );
     }
 
     /**
-     * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions}
+     * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName
+     *
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used!
      */
-    public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions
+    public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole
     {
-        try {
-            $userRoles = array_keys($this->userService->getAllRoles($user));
-        } catch (NoSuchRoleException $e) {
-            throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e);
+        $tableRole = self::TABLE_NAME_WORKSPACE_ROLE;
+        $query = <<<SQL
+            SELECT
+                role
+            FROM
+                {$tableRole}
+            WHERE
+                content_repository_id = :contentRepositoryId
+                AND workspace_name = :workspaceName
+                AND (
+                    (subject_type = :userSubjectType AND subject IN (:userSubjectValues))
+                    OR
+                    (subject_type = :groupSubjectType AND subject IN (:groupSubjectValues))
+                )
+            ORDER BY
+                /* We only want to return the most specific role so we order them and return the first row */
+                CASE
+                    WHEN role='MANAGER' THEN 1
+                    WHEN role='COLLABORATOR' THEN 2
+                    WHEN role='VIEWER' THEN 3
+                END
+            LIMIT 1
+        SQL;
+        $userSubjectValues = [];
+        $groupSubjectValues = [];
+        foreach ($subjects as $subject) {
+            if ($subject->type ===  WorkspaceRoleSubjectType::GROUP) {
+                $groupSubjectValues[] = $subject->value;
+            } else {
+                $userSubjectValues[] = $subject->value;
+            }
         }
-        $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName);
-        if ($workspaceMetadata !== null && $workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) {
-            return WorkspacePermissions::all();
+        try {
+            $role = $this->dbal->fetchOne($query, [
+                'contentRepositoryId' => $contentRepositoryId->value,
+                'workspaceName' => $workspaceName->value,
+                'userSubjectType' => WorkspaceRoleSubjectType::USER->value,
+                'userSubjectValues' => $userSubjectValues,
+                'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value,
+                'groupSubjectValues' => $groupSubjectValues,
+            ], [
+                'userSubjectValues' => ArrayParameterType::STRING,
+                'groupSubjectValues' => ArrayParameterType::STRING,
+            ]);
+        } catch (DbalException $e) {
+            throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e);
         }
-        $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), $userRoles);
-        $userIsAdministrator = in_array('Neos.Neos:Administrator', $userRoles, true);
-        if ($userWorkspaceRole === null) {
-            return WorkspacePermissions::create(false, false, $userIsAdministrator);
+        if ($role === false) {
+            return null;
         }
-        return WorkspacePermissions::create(
-            read: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
-            write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
-            manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
-        );
+        return WorkspaceRole::from($role);
     }
 
     /**
@@ -437,49 +473,6 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep
         return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName);
     }
 
-    /**
-     * @param array<string> $userRoles
-     */
-    private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): ?WorkspaceRole
-    {
-        $tableRole = self::TABLE_NAME_WORKSPACE_ROLE;
-        $query = <<<SQL
-            SELECT
-                role
-            FROM
-                {$tableRole}
-            WHERE
-                content_repository_id = :contentRepositoryId
-                AND workspace_name = :workspaceName
-                AND (
-                    (subject_type = :userSubjectType AND subject = :userId)
-                    OR
-                    (subject_type = :groupSubjectType AND subject IN (:groupSubjects))
-                )
-            ORDER BY
-                /* We only want to return the most specific role so we order them and return the first row */
-                CASE
-                    WHEN role='MANAGER' THEN 1
-                    WHEN role='COLLABORATOR' THEN 2
-                END
-            LIMIT 1
-        SQL;
-        $role = $this->dbal->fetchOne($query, [
-            'contentRepositoryId' => $contentRepositoryId->value,
-            'workspaceName' => $workspaceName->value,
-            'userSubjectType' => WorkspaceRoleSubjectType::USER->value,
-            'userId' => $userId->value,
-            'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value,
-            'groupSubjects' => $userRoles,
-        ], [
-            'groupSubjects' => ArrayParameterType::STRING,
-        ]);
-        if ($role === false) {
-            return null;
-        }
-        return WorkspaceRole::from($role);
-    }
-
     private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace
     {
         $workspace = $this->contentRepositoryRegistry
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
new file mode 100644
index 00000000000..3d056cae5a3
--- /dev/null
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\Authorization;
+
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Annotations as Flow;
+use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
+use Neos\Flow\Security\Exception\NoSuchRoleException;
+use Neos\Flow\Security\Policy\PolicyService;
+use Neos\Flow\Security\Policy\Role;
+use Neos\Neos\Domain\Model\User;
+use Neos\Neos\Domain\Model\WorkspacePermissions;
+use Neos\Neos\Domain\Model\WorkspaceRole;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
+use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege;
+use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject;
+
+/**
+ * @api
+ */
+#[Flow\Scope('singleton')]
+final readonly class ContentRepositoryAuthorizationService
+{
+    private const FLOW_ROLE_EVERYBODY = 'Neos.Flow:Everybody';
+    private const FLOW_ROLE_ANONYMOUS = 'Neos.Flow:Anonymous';
+    private const FLOW_ROLE_ADMINISTRATOR = 'Neos.Neos:Administrator';
+
+
+    public function __construct(
+        private UserService $userService,
+        private WorkspaceService $workspaceService,
+        private PolicyService $policyService,
+        private PrivilegeManagerInterface $privilegeManager,
+    ) {
+    }
+
+    public function getWorkspacePermissionsForAnonymousUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspacePermissions
+    {
+        $subjects = [WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_EVERYBODY), WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_ANONYMOUS)];
+        $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
+        if ($userWorkspaceRole === null) {
+            return WorkspacePermissions::none("Anonymous user has no explicit role for workspace '{$workspaceName->value}'");
+        }
+        return WorkspacePermissions::create(
+            read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
+            write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
+            manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
+            reason: "Anonymous user has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'",
+        );
+    }
+
+    /**
+     * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions}
+     */
+    public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions
+    {
+        $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName);
+        if ($workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) {
+            return WorkspacePermissions::all("User '{$user->getLabel()}' (id: {$user->getId()->value} is the owner of workspace '{$workspaceName->value}'");
+        }
+        $userRoles = $this->rolesForUser($user);
+        $userIsAdministrator = array_key_exists(self::FLOW_ROLE_ADMINISTRATOR, $userRoles);
+        $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles));
+        $subjects[] = WorkspaceRoleSubject::createForUser($user->getId());
+        $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
+        if ($userWorkspaceRole === null) {
+            if ($userIsAdministrator) {
+                return WorkspacePermissions::manage("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' but is an Administrator");
+            }
+            return WorkspacePermissions::none("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' and is no Administrator");
+        }
+        return WorkspacePermissions::create(
+            read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
+            write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
+            manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
+            reason: "User '{$user->getLabel()}' (id: '{$user->getId()->value}') has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'" . ($userIsAdministrator ? ' and is an Administrator' : ' and is no Administrator'),
+        );
+    }
+
+    public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
+    {
+        $roles = array_map($this->policyService->getRole(...), [self::FLOW_ROLE_EVERYBODY, self::FLOW_ROLE_ANONYMOUS]);
+        return $this->visibilityConstraintsForRoles($contentRepositoryId, $roles);
+    }
+
+    public function getVisibilityConstraintsForUser(ContentRepositoryId $contentRepositoryId, User $user): VisibilityConstraints
+    {
+        $userRoles = $this->rolesForUser($user);
+        return $this->visibilityConstraintsForRoles($contentRepositoryId, $userRoles);
+    }
+
+    /**
+     * @param array<Role> $roles
+     */
+    private function visibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
+    {
+        $restrictedSubtreeTags = [];
+        /** @var SubtreeTagPrivilege $privilege */
+        foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) {
+            if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) {
+                $restrictedSubtreeTags[] = $privilege->getSubtreeTag();
+            }
+        }
+        return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags));
+    }
+
+    /**
+     * @return array<Role>
+     */
+    private function rolesForUser(User $user): array
+    {
+        try {
+            $userRoles = $this->userService->getAllRoles($user);
+        } catch (NoSuchRoleException $e) {
+            throw new \RuntimeException("Failed to determine roles for user '{$user->getLabel()}' (id: '{$user->getId()->value}'), check your package dependencies: {$e->getMessage()}", 1727084881, $e);
+        }
+        return $userRoles;
+    }
+}
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
index 2b3d1f157d0..da4eb47357c 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
@@ -14,6 +14,8 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
 use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
 use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
@@ -23,6 +25,22 @@
  */
 class SubtreeTagPrivilege extends AbstractPrivilege
 {
+    private SubtreeTag|null $subtreeTagRuntimeCache = null;
+    private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null;
+
+    private function initialize(): void
+    {
+        if ($this->subtreeTagRuntimeCache !== null) {
+            return;
+        }
+        $subtreeTag = $this->getParsedMatcher();
+        if (str_contains($subtreeTag, ':')) {
+            [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag);
+            $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId);
+        }
+        $this->subtreeTagRuntimeCache = SubtreeTag::fromString($subtreeTag);
+    }
+
     /**
      * Returns true, if this privilege covers the given subject
      *
@@ -35,6 +53,23 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool
         if (!$subject instanceof SubtreeTagPrivilegeSubject) {
             throw new InvalidPrivilegeTypeException(sprintf('Privileges of type "%s" only support subjects of type "%s" but we got a subject of type: "%s".', self::class, SubtreeTagPrivilegeSubject::class, get_class($subject)), 1729173985);
         }
-        return false;
+        $contentRepositoryId = $this->getContentRepositoryId();
+        if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) {
+            return false;
+        }
+        return $subject->subTreeTag->equals($this->getSubtreeTag());
+    }
+
+    public function getSubtreeTag(): SubtreeTag
+    {
+        $this->initialize();
+        assert($this->subtreeTagRuntimeCache !== null);
+        return $this->subtreeTagRuntimeCache;
+    }
+
+    public function getContentRepositoryId(): ?ContentRepositoryId
+    {
+        $this->initialize();
+        return $this->contentRepositoryIdRuntimeCache;
     }
 }
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
index df2cd33c5b6..a08a0a090aa 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
@@ -14,6 +14,8 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
 
 /**
@@ -22,8 +24,8 @@
 final readonly class SubtreeTagPrivilegeSubject implements PrivilegeSubjectInterface
 {
     public function __construct(
-        public string $subTreeTag,
-        public string|null $contentRepository = null,
+        public SubtreeTag $subTreeTag,
+        public ContentRepositoryId|null $contentRepositoryId = null,
     ) {
     }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 7b073144476..6179994e815 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -6,34 +6,39 @@
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
 use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
+use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
+use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
 use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
-use Neos\ContentRepository\Core\SharedModel\Auth\WorkspacePrivilegeType;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Service\UserService;
-use Neos\Neos\Domain\Service\WorkspaceService;
-use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
  * @api
  */
 final class ContentRepositoryAuthProvider implements AuthProviderInterface
 {
+    private const WORKSPACE_PERMISSION_WRITE = 'write';
+    private const WORKSPACE_PERMISSION_MANAGE = 'manage';
+
     public function __construct(
         private readonly ContentRepositoryId $contentRepositoryId,
         private readonly UserService $userService,
-        private readonly WorkspaceService $workspaceService,
+        private readonly ContentRepositoryAuthorizationService $authorizationService,
         private readonly SecurityContext $securityContext,
-        private readonly PolicyService $policyService,
     ) {
     }
 
@@ -51,85 +56,82 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return VisibilityConstraints::default();
         }
-        $restrictedSubtreeTags = [SubtreeTag::disabled()];
-        try {
-            /** @var array<SubtreeTagPrivilege> $subtreeTagPrivileges */
-            $subtreeTagPrivileges = $this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class);
-        } catch (\Exception $e) {
-            throw new \RuntimeException(sprintf('Failed to determine SubtreeTag privileges: %s', $e->getMessage()), 1729180655, $e);
-        }
-        foreach ($subtreeTagPrivileges as $privilege) {
-            if ($privilege->isGranted()) {
-                continue;
-            }
-            $subtreeTag = $privilege->getParsedMatcher();
-            if (str_contains($subtreeTag, ':')) {
-                [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag);
-                if ($this->contentRepositoryId->value !== $contentRepositoryId) {
-                    continue;
-                }
-            }
-            $restrictedSubtreeTags[] = SubtreeTag::fromString($subtreeTag);
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId);
         }
-        return new VisibilityConstraints(
-            SubtreeTags::fromArray($restrictedSubtreeTags)
-        );
+        return $this->authorizationService->getVisibilityConstraintsForUser($this->contentRepositoryId, $user);
     }
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
     {
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
-            return Privilege::granted();
+            return Privilege::granted('Authorization checks are disabled');
         }
         $user = $this->userService->getCurrentUser();
         if ($user === null) {
-            return $workspaceName->isLive() ? Privilege::granted() : Privilege::denied('No user is authenticated');
+            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
+        } else {
+            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
         }
-        $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
-        return $workspacePermissions->read ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no read permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $workspaceName->value));
+        return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason());
     }
 
     public function getCommandPrivilege(CommandInterface $command): Privilege
     {
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
-            return Privilege::granted();
-        }
-        // TODO handle:
-        // ChangeBaseWorkspace
-        // CreateRootWorkspace
-        // DeleteWorkspace
-        // DiscardIndividualNodesFromWorkspace
-        // DiscardWorkspace
-        // PublishWorkspace
-        // PublishIndividualNodesFromWorkspace
-        // RebaseWorkspace
-        if ($command instanceof CreateWorkspace) {
-            $baseWorkspacePermissions = $this->getWorkspacePermissionsForAuthenticatedUser($command->baseWorkspaceName);
-            if ($baseWorkspacePermissions === null || !$baseWorkspacePermissions->write) {
-                return Privilege::denied(sprintf('no write permissions on base workspace "%s"', $command->baseWorkspaceName->value));
-            }
-            return Privilege::granted();
+            return Privilege::granted('Authorization checks are disabled');
         }
+
         // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all
         // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the
         // code if we were to add new commands in the future
-        if (!$command instanceof RebasableToOtherWorkspaceInterface) {
-            return Privilege::granted();
+        if ($command instanceof RebasableToOtherWorkspaceInterface) {
+            return $this->requireWorkspacePermission($command->getWorkspaceName(), self::WORKSPACE_PERMISSION_WRITE);
         }
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
-            return Privilege::denied('No user is authenticated');
+
+        if ($command instanceof CreateRootWorkspace) {
+            return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks');
+        }
+
+        if ($command instanceof ChangeBaseWorkspace) {
+            $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName);
+            if (!$workspacePermissions->manage) {
+                return Privilege::denied("Missing 'manage' permissions for workspace '{$command->workspaceName->value}': {$workspacePermissions->getReason()}");
+            }
+            $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName);
+            if (!$baseWorkspacePermissions->write) {
+                return Privilege::denied("Missing 'write' permissions for base workspace '{$command->baseWorkspaceName->value}': {$baseWorkspacePermissions->getReason()}");
+            }
+            return Privilege::granted("User has 'manage' permissions for workspace '{$command->workspaceName->value}' and 'write' permissions for base workspace '{$command->baseWorkspaceName->value}'");
         }
-        $workspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $command->getWorkspaceName(), $user);
-        return $workspacePermissions->write ? Privilege::granted() : Privilege::denied(sprintf('User "%s" (id: %s) has no write permission for workspace "%s"', $user->getLabel(), $user->getId()->value, $command->getWorkspaceName()->value));
+        return match ($command::class) {
+            CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE),
+            DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE),
+            DiscardWorkspace::class,
+            DiscardIndividualNodesFromWorkspace::class,
+            PublishWorkspace::class,
+            PublishIndividualNodesFromWorkspace::class,
+            RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE),
+            default => Privilege::granted('Command not restricted'),
+        };
     }
 
-    private function getWorkspacePermissionsForAuthenticatedUser(WorkspaceName $workspaceName): ?WorkspacePermissions
+    private function requireWorkspacePermission(WorkspaceName $workspaceName, string $permission): Privilege
+    {
+        $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName);
+        if (!$workspacePermissions->{$permission}) {
+            return Privilege::denied("Missing '{$permission}' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}");
+        }
+        return Privilege::granted("User has '{$permission}' permissions for workspace '{$workspaceName->value}'");
+    }
+
+    private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions
     {
         $user = $this->userService->getCurrentUser();
         if ($user === null) {
-            return null;
+            return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
         }
-        return $this->workspaceService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+        return $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
     }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index d708cb18ac8..54bfc97294c 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -8,9 +8,8 @@
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Neos\Domain\Service\UserService;
-use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
  * Implementation of the {@see AuthProviderFactoryInterface} in order to provide authentication and authorization for Content Repositories
@@ -22,9 +21,8 @@
 {
     public function __construct(
         private UserService $userService,
-        private WorkspaceService $workspaceService,
+        private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService,
         private SecurityContext $securityContext,
-        private PolicyService $policyService,
     ) {
     }
 
@@ -33,6 +31,6 @@ public function __construct(
      */
     public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider
     {
-        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->workspaceService, $this->securityContext, $this->policyService);
+        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryAuthorizationService, $this->securityContext);
     }
 }
diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml
index 8ccf44e7ce0..1cd85ab83c2 100644
--- a/Neos.Neos/Configuration/Policy.yaml
+++ b/Neos.Neos/Configuration/Policy.yaml
@@ -142,6 +142,12 @@ privilegeTargets:
       label: General access to the dimensions module
       matcher: 'administration/dimensions'
 
+
+  'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege':
+
+    'Neos.Neos:ContentRepository.ReadDisabledNodes':
+      matcher: 'disabled'
+
 roles:
 
   'Neos.Flow:Everybody':
@@ -229,6 +235,11 @@ roles:
         privilegeTarget: 'Neos.Neos:Backend.Module.Management'
         permission: GRANT
 
+      -
+        privilegeTarget: 'Neos.Neos:ContentRepository.ReadDisabledNodes'
+        permission: GRANT
+
+
 
   'Neos.Neos:RestrictedEditor':
     label: Restricted Editor
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 06916378f00..8106b291e13 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -27,6 +27,7 @@
 use Neos\Neos\Domain\Model\WorkspaceTitle;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 use PHPUnit\Framework\Assert;
 
 /**
@@ -165,16 +166,15 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table
 
     /**
      * @When the role :role is assigned to workspace :workspaceName for group :groupName
-     * @When the role :role is assigned to workspace :workspaceName for user :username
+     * @When the role :role is assigned to workspace :workspaceName for user :userId
      */
-    public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void
+    public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void
     {
         $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
             WorkspaceRoleAssignment::create(
-                $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER,
-                WorkspaceRoleSubject::fromString($groupName ?? $username),
+                $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
                 WorkspaceRole::from($role)
             )
         ));
@@ -182,15 +182,14 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string
 
     /**
      * @When the role for group :groupName is unassigned from workspace :workspaceName
-     * @When the role for user :username is unassigned from workspace :workspaceName
+     * @When the role for user :userId is unassigned from workspace :workspaceName
      */
-    public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void
+    public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void
     {
         $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER,
-            WorkspaceRoleSubject::fromString($groupName ?? $username),
+            $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
         ));
     }
 
@@ -201,7 +200,7 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName
     {
         $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName));
         $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [
-            'Subject type' => $assignment->subjectType->value,
+            'Subject type' => $assignment->subject->type->value,
             'Subject' => $assignment->subject->value,
             'Role' => $assignment->role->value,
         ], iterator_to_array($workspaceAssignments));
@@ -214,12 +213,12 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName
     public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void
     {
         $user = $this->getObject(UserService::class)->getUser($username);
-        $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser(
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
             $user,
         );
-        Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter((array)$permissions))));
+        Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions)))));
     }
 
     /**
@@ -228,7 +227,7 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username
     public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void
     {
         $user = $this->getObject(UserService::class)->getUser($username);
-        $permissions = $this->getObject(WorkspaceService::class)->getWorkspacePermissionsForUser(
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
             $user,
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index dac01978f21..72cd21ac82b 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -62,6 +62,7 @@
 use Neos\Neos\FrontendRouting\NodeUriBuilderFactory;
 use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult;
 use Neos\Neos\PendingChangesProjection\ChangeFinder;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 use Neos\Neos\Utility\NodeTypeWithFallbackProvider;
 use Neos\Workspace\Ui\ViewModel\PendingChanges;
 use Neos\Workspace\Ui\ViewModel\WorkspaceListItem;
@@ -103,6 +104,9 @@ class WorkspaceController extends AbstractModuleController
     #[Flow\Inject]
     protected WorkspaceService $workspaceService;
 
+    #[Flow\Inject]
+    protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService;
+
     /**
      * Display a list of unpublished content
      */
@@ -135,7 +139,7 @@ public function indexAction(): void
         $allWorkspaces = $contentRepository->findWorkspaces();
         foreach ($allWorkspaces as $workspace) {
             $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName);
-            $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser);
+            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser);
             if (!$permissions->read) {
                 continue;
             }
@@ -174,7 +178,7 @@ public function showAction(WorkspaceName $workspace): void
             $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName);
             assert($baseWorkspace !== null);
             $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName);
-            $baseWorkspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser);
+            $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser);
         }
         $this->view->assignMultiple([
             'selectedWorkspace' => $workspaceObj,
@@ -1030,7 +1034,7 @@ protected function prepareBaseWorkspaceOptions(
             if ($user === null) {
                 continue;
             }
-            $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user);
+            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user);
             if (!$permissions->manage) {
                 continue;
             }

From b79e0bae53d2c0e66841ca596025358d0e204d64 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 22 Oct 2024 18:34:59 +0200
Subject: [PATCH 09/58] Move auth related classes to `Feature/Security`

and add dedicated `AccessDenied` exception
---
 .../Behavior/FakeAuthProviderFactory.php      |  2 +-
 .../Classes/ContentRepository.php             | 13 ++++---
 .../Factory/ContentRepositoryFactory.php      |  2 +-
 .../Factory/ProjectionFactoryDependencies.php |  2 +-
 .../Security}/AuthProviderInterface.php       |  4 ++-
 .../Security/Dto}/Privilege.php               |  2 +-
 .../Auth => Feature/Security/Dto}/UserId.php  |  2 +-
 .../Security/Exception/AccessDenied.php       | 34 +++++++++++++++++++
 .../Security}/StaticAuthProvider.php          |  4 ++-
 .../Bootstrap/CRTestSuiteRuntimeVariables.php |  7 ++--
 .../Bootstrap/Helpers/FakeAuthProvider.php    |  6 ++--
 .../Classes/ContentRepositoryRegistry.php     |  4 +--
 .../AuthProviderFactoryInterface.php          |  2 +-
 .../StaticAuthProviderFactory.php             |  6 ++--
 .../Controller/Frontend/NodeController.php    |  6 +++-
 .../ContentRepositoryAuthProvider.php         |  6 ++--
 16 files changed, 73 insertions(+), 29 deletions(-)
 rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security}/AuthProviderInterface.php (78%)
 rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security/Dto}/Privilege.php (92%)
 rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security/Dto}/UserId.php (96%)
 create mode 100644 Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php
 rename Neos.ContentRepository.Core/Classes/{SharedModel/Auth => Feature/Security}/StaticAuthProvider.php (86%)

diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
index df59e7f80c8..f018a5ac958 100644
--- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
+++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
@@ -4,8 +4,8 @@
 
 namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior;
 
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 
diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index bbc21fa9c81..a73d7a42aa3 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -26,6 +26,9 @@
 use Neos\ContentRepository\Core\EventStore\Events;
 use Neos\ContentRepository\Core\EventStore\EventsToPublish;
 use Neos\ContentRepository\Core\Factory\ContentRepositoryFactory;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
+use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied;
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
 use Neos\ContentRepository\Core\Projection\CatchUp;
 use Neos\ContentRepository\Core\Projection\CatchUpOptions;
@@ -36,8 +39,6 @@
 use Neos\ContentRepository\Core\Projection\ProjectionStateInterface;
 use Neos\ContentRepository\Core\Projection\ProjectionStatuses;
 use Neos\ContentRepository\Core\Projection\WithMarkStaleInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryStatus;
 use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist;
@@ -97,12 +98,13 @@ public function __construct(
      * The only API to send commands (mutation intentions) to the system.
      *
      * @param CommandInterface $command
+     * @throws AccessDenied
      */
     public function handle(CommandInterface $command): void
     {
         $privilege = $this->authProvider->getCommandPrivilege($command);
         if (!$privilege->granted) {
-            throw new \RuntimeException(sprintf('Command "%s" was denied: %s', $command::class, $privilege->reason), 1729086686);
+            throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason);
         }
         // the commands only calculate which events they want to have published, but do not do the
         // publishing themselves
@@ -253,19 +255,20 @@ public function resetProjectionState(string $projectionClassName): void
 
     /**
      * @throws WorkspaceDoesNotExist if the workspace does not exist
+     * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface})
      */
     public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface
     {
         $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName);
         if (!$privilege->granted) {
-            // TODO more specific exception
-            throw new \RuntimeException(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $privilege->reason ?? ''), 1729014760);
+            throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason);
         }
         return $this->getContentRepositoryReadModel()->getContentGraphByWorkspaceName($workspaceName);
     }
 
     /**
      * @throws WorkspaceDoesNotExist if the workspace does not exist
+     * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface})
      */
     public function getContentSubgraph(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): ContentSubgraphInterface
     {
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
index 333b7e0a472..a942fe9d155 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
@@ -21,6 +21,7 @@
 use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph;
 use Neos\ContentRepository\Core\EventStore\EventNormalizer;
 use Neos\ContentRepository\Core\EventStore\EventPersister;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Feature\ContentStreamCommandHandler;
 use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\DimensionSpaceCommandHandler;
 use Neos\ContentRepository\Core\Feature\NodeAggregateCommandHandler;
@@ -30,7 +31,6 @@
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
 use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\EventStore\EventStoreInterface;
 use Psr\Clock\ClockInterface;
 use Symfony\Component\Serializer\Serializer;
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
index 36b614ba623..0b78e572cc4 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
@@ -18,9 +18,9 @@
 use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper;
 use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph;
 use Neos\ContentRepository\Core\EventStore\EventNormalizer;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter;
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\EventStore\EventStoreInterface;
 
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
similarity index 78%
rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
rename to Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
index 84890012d9d..2d2bca17bfc 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/AuthProviderInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
@@ -2,9 +2,11 @@
 
 declare(strict_types=1);
 
-namespace Neos\ContentRepository\Core\SharedModel\Auth;
+namespace Neos\ContentRepository\Core\Feature\Security;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
similarity index 92%
rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
index cfe8798273b..0fcef1552fe 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/Privilege.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
@@ -12,7 +12,7 @@
 
 declare(strict_types=1);
 
-namespace Neos\ContentRepository\Core\SharedModel\Auth;
+namespace Neos\ContentRepository\Core\Feature\Security\Dto;
 
 /**
  * A privilege that is returned by the {@see AuthProviderInterface}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php
similarity index 96%
rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php
rename to Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php
index 80228a031d0..b43e9b5feb1 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/UserId.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/UserId.php
@@ -12,7 +12,7 @@
 
 declare(strict_types=1);
 
-namespace Neos\ContentRepository\Core\SharedModel\Auth;
+namespace Neos\ContentRepository\Core\Feature\Security\Dto;
 
 use Neos\ContentRepository\Core\SharedModel\Id\UuidFactory;
 
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php
new file mode 100644
index 00000000000..21528122e84
--- /dev/null
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Exception/AccessDenied.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Neos.ContentRepository package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+namespace Neos\ContentRepository\Core\Feature\Security\Exception;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+
+/**
+ * @api
+ */
+final class AccessDenied extends \Exception
+{
+    public static function becauseCommandIsNotGranted(CommandInterface $command, string $reason): self
+    {
+        return new self(sprintf('Command "%s" was denied: %s', $command::class, $reason), 1729086686);
+    }
+
+    public static function becauseWorkspaceCantBeRead(WorkspaceName $workspaceName, string $reason): self
+    {
+        return new self(sprintf('Read access denied for workspace "%s": %s', $workspaceName->value, $reason), 1729014760);
+    }
+}
diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
similarity index 86%
rename from Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
rename to Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
index a44a4e61db1..5c5b72e1223 100644
--- a/Neos.ContentRepository.Core/Classes/SharedModel/Auth/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
@@ -2,9 +2,11 @@
 
 declare(strict_types=1);
 
-namespace Neos\ContentRepository\Core\SharedModel\Auth;
+namespace Neos\ContentRepository\Core\Feature\Security;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
index 66b1e76d28d..15706af6806 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
@@ -14,9 +14,10 @@
 
 namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap;
 
-use Neos\ContentRepository\Core\ContentRepositoryReadModel;
 use Neos\ContentRepository\Core\ContentRepository;
+use Neos\ContentRepository\Core\ContentRepositoryReadModel;
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate;
@@ -24,11 +25,9 @@
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
-use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock;
 
 /**
  * The node creation trait for behavioral tests
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index c2cb6b8a84d..ac7def3d8d0 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -5,10 +5,10 @@
 namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
-use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
 final class FakeAuthProvider implements AuthProviderInterface
diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
index 3e901aa404e..95e5c1887ab 100644
--- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
+++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
@@ -10,6 +10,7 @@
 use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface;
 use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface;
 use Neos\ContentRepository\Core\Factory\ProjectionsAndCatchUpHooksFactory;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
 use Neos\ContentRepository\Core\Projection\CatchUpHookFactoryInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface;
@@ -17,14 +18,13 @@
 use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 use Neos\ContentRepositoryRegistry\Exception\ContentRepositoryNotFoundException;
 use Neos\ContentRepositoryRegistry\Exception\InvalidConfigurationException;
+use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\Clock\ClockFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\ContentDimensionSource\ContentDimensionSourceFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\EventStore\EventStoreFactoryInterface;
 use Neos\ContentRepositoryRegistry\Factory\NodeTypeManager\NodeTypeManagerFactoryInterface;
-use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\ContentSubgraphWithRuntimeCaches;
 use Neos\ContentRepositoryRegistry\SubgraphCachingInMemory\SubgraphCachePool;
 use Neos\EventStore\EventStoreInterface;
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
index 9aebd57e688..809efdf65db 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
@@ -4,8 +4,8 @@
 
 namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider;
 
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 
 /**
  * @api
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
index 4cc8b9a7ebd..870f2e5ace2 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
@@ -2,10 +2,10 @@
 declare(strict_types=1);
 namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider;
 
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
+use Neos\ContentRepository\Core\Feature\Security\StaticAuthProvider;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Auth\StaticAuthProvider;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
 
 /**
  * @api
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index 9d5756d5fba..53d98b82c94 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -197,8 +197,12 @@ public function showAction(string $node): void
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
         $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
-        // todo document
+
+        // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
+        // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
+        // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated
         $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled()));
+
         $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool);
 
         $nodeInstance = $subgraph->findNodeById($nodeAddress->aggregateId);
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 6179994e815..293ff100e32 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -6,6 +6,9 @@
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
 use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
@@ -16,9 +19,6 @@
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
-use Neos\ContentRepository\Core\SharedModel\Auth\AuthProviderInterface;
-use Neos\ContentRepository\Core\SharedModel\Auth\Privilege;
-use Neos\ContentRepository\Core\SharedModel\Auth\UserId;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\Flow\Security\Context as SecurityContext;

From d58fb9809f9f6446b1bc1a209a21b20ff258a90b Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 22 Oct 2024 19:27:24 +0200
Subject: [PATCH 10/58] `VisibilityConstraints` not only for Neos users

---
 .../src/Domain/Repository/ContentSubgraph.php     | 15 ---------------
 .../Domain/Repository/ContentSubhypergraph.php    | 15 ---------------
 .../ContentGraph/ContentSubgraphInterface.php     |  2 --
 .../ContentSubgraphWithRuntimeCaches.php          |  5 -----
 .../Controller/Frontend/NodeController.php        | 12 ++++++++----
 .../ContentRepositoryAuthorizationService.php     | 14 +-------------
 .../ContentRepositoryAuthProvider.php             |  6 +-----
 7 files changed, 10 insertions(+), 59 deletions(-)

diff --git a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
index 72b74631867..810a095069f 100644
--- a/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
+++ b/Neos.ContentGraph.DoctrineDbalAdapter/src/Domain/Repository/ContentSubgraph.php
@@ -122,21 +122,6 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->visibilityConstraints;
     }
 
-    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
-    {
-        return new self(
-            $this->contentRepositoryId,
-            $this->workspaceName,
-            $this->contentStreamId,
-            $this->dimensionSpacePoint,
-            $newVisibilityConstraints,
-            $this->dbal,
-            $this->nodeFactory,
-            $this->nodeTypeManager,
-            $this->nodeQueryBuilder->tableNames,
-        );
-    }
-
     public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes
     {
         $queryBuilder = $this->buildChildNodesQuery($parentNodeAggregateId, $filter);
diff --git a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
index 994a880dae7..8b312166d2a 100644
--- a/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
+++ b/Neos.ContentGraph.PostgreSQLAdapter/src/Domain/Repository/ContentSubhypergraph.php
@@ -106,21 +106,6 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->visibilityConstraints;
     }
 
-    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
-    {
-        return new self(
-            $this->contentRepositoryId,
-            $this->contentStreamId,
-            $this->workspaceName,
-            $this->dimensionSpacePoint,
-            $newVisibilityConstraints,
-            $this->dbal,
-            $this->nodeFactory,
-            $this->nodeTypeManager,
-            $this->tableNamePrefix,
-        );
-    }
-
     public function findNodeById(NodeAggregateId $nodeAggregateId): ?Node
     {
         $query = HypergraphQuery::create($this->contentStreamId, $this->tableNamePrefix);
diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
index d2df7ce2dd7..c47c0771932 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentSubgraphInterface.php
@@ -57,8 +57,6 @@ public function getDimensionSpacePoint(): DimensionSpacePoint;
 
     public function getVisibilityConstraints(): VisibilityConstraints;
 
-    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self;
-
     /**
      * Find a single node by its aggregate id
      *
diff --git a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
index f9f88c2e164..c26bcef38d1 100644
--- a/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
+++ b/Neos.ContentRepositoryRegistry/Classes/SubgraphCachingInMemory/ContentSubgraphWithRuntimeCaches.php
@@ -77,11 +77,6 @@ public function getVisibilityConstraints(): VisibilityConstraints
         return $this->wrappedContentSubgraph->getVisibilityConstraints();
     }
 
-    public function withVisibilityConstraints(VisibilityConstraints $newVisibilityConstraints): self
-    {
-        return new self($this->wrappedContentSubgraph->withVisibilityConstraints($newVisibilityConstraints), $this->subgraphCachePool);
-    }
-
     public function findChildNodes(NodeAggregateId $parentNodeAggregateId, FindChildNodesFilter $filter): Nodes
     {
         if (!self::isFilterEmpty($filter)) {
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index 53d98b82c94..e96a0ea4fd7 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -36,6 +36,7 @@
 use Neos\Flow\Session\SessionInterface;
 use Neos\Flow\Utility\Now;
 use Neos\Neos\Domain\Model\RenderingMode;
+use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Service\NodeTypeNameFactory;
 use Neos\Neos\Domain\Service\RenderingModeService;
 use Neos\Neos\FrontendRouting\Exception\InvalidShortcutException;
@@ -43,6 +44,7 @@
 use Neos\Neos\FrontendRouting\NodeShortcutResolver;
 use Neos\Neos\FrontendRouting\NodeUriBuilderFactory;
 use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 use Neos\Neos\Utility\NodeTypeWithFallbackProvider;
 use Neos\Neos\View\FusionView;
 
@@ -111,6 +113,9 @@ class NodeController extends ActionController
     #[Flow\Inject]
     protected NodeUriBuilderFactory $nodeUriBuilderFactory;
 
+    #[Flow\Inject]
+    protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService;
+
     /**
      * @param string $node
      * @throws NodeNotFoundException
@@ -189,19 +194,18 @@ public function previewAction(string $node): void
     public function showAction(string $node): void
     {
         $nodeAddress = NodeAddress::fromJsonString($node);
-        unset($node);
 
         if (!$nodeAddress->workspaceName->isLive()) {
             throw new NodeNotFoundException('The requested node isn\'t accessible to the current user', 1430218623);
         }
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        $uncachedSubgraph = $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint);
-
         // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
         // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
         // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated
-        $uncachedSubgraph = $uncachedSubgraph->withVisibilityConstraints($uncachedSubgraph->getVisibilityConstraints()->withAddedSubtreeTag(SubtreeTag::disabled()));
+        $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForRoles($contentRepository->id, $this->securityContext->getRoles());
+        $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled());
+        $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints);
 
         $subgraph = new ContentSubgraphWithRuntimeCaches($uncachedSubgraph, $this->subgraphCachePool);
 
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 3d056cae5a3..f37a03a33b3 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -85,22 +85,10 @@ public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepos
         );
     }
 
-    public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
-    {
-        $roles = array_map($this->policyService->getRole(...), [self::FLOW_ROLE_EVERYBODY, self::FLOW_ROLE_ANONYMOUS]);
-        return $this->visibilityConstraintsForRoles($contentRepositoryId, $roles);
-    }
-
-    public function getVisibilityConstraintsForUser(ContentRepositoryId $contentRepositoryId, User $user): VisibilityConstraints
-    {
-        $userRoles = $this->rolesForUser($user);
-        return $this->visibilityConstraintsForRoles($contentRepositoryId, $userRoles);
-    }
-
     /**
      * @param array<Role> $roles
      */
-    private function visibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
+    public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
     {
         $restrictedSubtreeTags = [];
         /** @var SubtreeTagPrivilege $privilege */
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 293ff100e32..bd8b020c877 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -56,11 +56,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return VisibilityConstraints::default();
         }
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
-            return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId);
-        }
-        return $this->authorizationService->getVisibilityConstraintsForUser($this->contentRepositoryId, $user);
+        return $this->authorizationService->getVisibilityConstraintsForRoles($this->contentRepositoryId, $this->securityContext->getRoles());
     }
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege

From cab0bd6bca905000b35b9e16d80eefcda1d8c5c6 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 22 Oct 2024 20:05:35 +0200
Subject: [PATCH 11/58] Tweaks

---
 .../Classes/ContentRepository.php             |  1 -
 .../Domain/Service/WorkspaceService.php       | 20 +++++++++----------
 .../Controller/WorkspaceController.php        | 10 ++++++++++
 3 files changed, 19 insertions(+), 12 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index b2ccf9f21ee..e3eb1b81dec 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -112,7 +112,6 @@ public function handle(CommandInterface $command): void
         // publishing themselves
         $eventsToPublish = $this->commandBus->handle($command, $this->commandHandlingDependencies);
 
-        // TODO meaningful exception message
         $initiatingUserId = $this->authProvider->getAuthenticatedUserId() ?? UserId::forSystemUser();
         $initiatingTimestamp = $this->clock->now()->format(\DateTimeInterface::ATOM);
 
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 37d3b09e1ad..5d31865c781 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -27,7 +27,6 @@
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
-use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceClassification;
@@ -45,20 +44,17 @@
 /**
  * Central authority to interact with Content Repository Workspaces within Neos
  *
- * TODO evaluate permissions for workspace changes
- *
  * @api
  */
 #[Flow\Scope('singleton')]
-final class WorkspaceService
+final readonly class WorkspaceService
 {
     private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata';
     private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role';
 
     public function __construct(
-        private readonly ContentRepositoryRegistry $contentRepositoryRegistry,
-        private readonly Connection $dbal,
-        private readonly SecurityContext $securityContext,
+        private ContentRepositoryRegistry $contentRepositoryRegistry,
+        private Connection $dbal,
     ) {
     }
 
@@ -83,10 +79,11 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
 
     /**
      * Update/set title metadata for the specified workspace
+     *
+     * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
-        // TODO check workspace permissions
         $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
             'title' => $newWorkspaceTitle->value,
         ]);
@@ -94,10 +91,11 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work
 
     /**
      * Update/set description metadata for the specified workspace
+     *
+     * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed
      */
     public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void
     {
-        // TODO check workspace permissions
         $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
             'description' => $newWorkspaceDescription->value,
         ]);
@@ -178,14 +176,14 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con
             return;
         }
         $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel());
-        $this->securityContext->withoutAuthorizationChecks(fn () => $this->createPersonalWorkspace(
+        $this->createPersonalWorkspace(
             $contentRepositoryId,
             $workspaceName,
             WorkspaceTitle::fromString($user->getLabel()),
             WorkspaceDescription::empty(),
             WorkspaceName::forLive(),
             $user->getId(),
-        ));
+        );
     }
 
     /**
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index 494b6900c8e..ea63307c93a 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -43,6 +43,7 @@
 use Neos\Flow\Package\PackageManager;
 use Neos\Flow\Property\PropertyMapper;
 use Neos\Flow\Security\Context;
+use Neos\Flow\Security\Exception\AccessDeniedException;
 use Neos\Media\Domain\Model\AssetInterface;
 use Neos\Media\Domain\Model\ImageInterface;
 use Neos\Neos\Controller\Module\AbstractModuleController;
@@ -288,6 +289,15 @@ public function updateAction(
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
 
+        $user = $this->userService->getCurrentUser();
+        if ($user === null) {
+            throw new AccessDeniedException('No user is authenticated', 1729620262);
+        }
+        $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspaceName, $user);
+        if (!$workspacePermissions->manage) {
+            throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297);
+        }
+
         if ($title->value === '') {
             $title = WorkspaceTitle::fromString($workspaceName->value);
         }

From 7ef315c4ac0a99fa38b1563edd9a3c3bbfc841cd Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 23 Oct 2024 11:31:53 +0200
Subject: [PATCH 12/58] Fix doc comments for `workspace:assignRole` command

---
 Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
index d4d9f2688ef..de14714430f 100644
--- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php
+++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
@@ -269,7 +269,7 @@ public function setDescriptionCommand(string $workspace, string $newDescription,
      *
      * @param string $workspace Name of the workspace, for example "some-workspace"
      * @param string $subject The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user
-     * @param string $role Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself
+     * @param string $role Role to assign, either 'viewer', 'collaborator' or 'manager' – a viewer can only read from the workspace, a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself
      * @param string $contentRepository Identifier of the content repository. (Default: 'default')
      * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user
      * @throws StopCommandException

From 35ff7db46de4ecabbcdfffc6ef3f0eb4fa7261bb Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 23 Oct 2024 11:45:55 +0200
Subject: [PATCH 13/58] Determine privileges based on the (authenticated)
 `Account`

..instead of the Neos `User`.
Reasoning:
- A Neos `User` can have multiple accounts assigned
- It makes sense to evaluate all authenticated roles e.g. for frontend authentication)
---
 .../Classes/Controller/UsageController.php    | 18 +++-
 .../Controller/Frontend/NodeController.php    |  7 +-
 .../Domain/Service/WorkspaceService.php       |  4 +-
 .../ContentRepositoryAuthorizationService.php | 93 ++++++++++++++-----
 .../ContentRepositoryAuthProvider.php         | 18 ++--
 .../Controller/WorkspaceController.php        | 33 +++----
 6 files changed, 118 insertions(+), 55 deletions(-)

diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php
index 5d11138f12c..a62d36b6298 100644
--- a/Neos.Media.Browser/Classes/Controller/UsageController.php
+++ b/Neos.Media.Browser/Classes/Controller/UsageController.php
@@ -19,6 +19,7 @@
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Mvc\Controller\ActionController;
+use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Media\Domain\Model\AssetInterface;
 use Neos\Media\Domain\Service\AssetService;
 use Neos\Neos\Domain\Repository\SiteRepository;
@@ -66,6 +67,12 @@ class UsageController extends ActionController
      */
     protected $workspaceService;
 
+    /**
+     * @Flow\Inject
+     * @var SecurityContext
+     */
+    protected $securityContext;
+
     /**
      * @Flow\Inject
      * @var ContentRepositoryAuthorizationService
@@ -110,11 +117,12 @@ public function relatedNodesAction(AssetInterface $asset)
             );
             $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null;
 
-            $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser(
-                $currentContentRepositoryId,
-                $usage->getWorkspaceName(),
-                $currentUser
-            );
+            $authenticatedAccount = $this->securityContext->getAccount();
+            if ($authenticatedAccount !== null) {
+                $workspacePermissions =  $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($currentContentRepositoryId, $usage->getWorkspaceName(), $authenticatedAccount);
+            } else {
+                $workspacePermissions =  $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($currentContentRepositoryId, $usage->getWorkspaceName());
+            }
 
             $workspace = $contentRepository->findWorkspaceByName($usage->getWorkspaceName());
 
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index e96a0ea4fd7..db146184bdb 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -203,7 +203,12 @@ public function showAction(string $node): void
         // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
         // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
         // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated
-        $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForRoles($contentRepository->id, $this->securityContext->getRoles());
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount !== null) {
+            $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount);
+        } else {
+            $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id);
+        }
         $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled());
         $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints);
 
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 5d31865c781..6cc3ecfe4ac 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -236,7 +236,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId,
     /**
      * Get all role assignments for the specified workspace
      *
-     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used!
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used!
      */
     public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments
     {
@@ -272,7 +272,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito
     /**
      * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName
      *
-     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForUser()} should be used!
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used!
      */
     public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole
     {
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index f37a03a33b3..6bb13616c79 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -9,8 +9,8 @@
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\Flow\Annotations as Flow;
+use Neos\Flow\Security\Account;
 use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
-use Neos\Flow\Security\Exception\NoSuchRoleException;
 use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Flow\Security\Policy\Role;
 use Neos\Neos\Domain\Model\User;
@@ -18,10 +18,10 @@
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
-use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Domain\Service\WorkspaceService;
 use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege;
 use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject;
+use Neos\Party\Domain\Service\PartyService;
 
 /**
  * @api
@@ -31,65 +31,77 @@
 {
     private const FLOW_ROLE_EVERYBODY = 'Neos.Flow:Everybody';
     private const FLOW_ROLE_ANONYMOUS = 'Neos.Flow:Anonymous';
-    private const FLOW_ROLE_ADMINISTRATOR = 'Neos.Neos:Administrator';
+    private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser';
+    private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator';
 
 
     public function __construct(
-        private UserService $userService,
+        private PartyService $partyService,
         private WorkspaceService $workspaceService,
         private PolicyService $policyService,
         private PrivilegeManagerInterface $privilegeManager,
     ) {
     }
 
+    /**
+     * Determines the {@see WorkspacePermissions} an anonymous user has for the specified workspace (aka "public access")
+     */
     public function getWorkspacePermissionsForAnonymousUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspacePermissions
     {
         $subjects = [WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_EVERYBODY), WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_ANONYMOUS)];
         $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
         if ($userWorkspaceRole === null) {
-            return WorkspacePermissions::none("Anonymous user has no explicit role for workspace '{$workspaceName->value}'");
+            return WorkspacePermissions::none(sprintf('Anonymous user has no explicit role for workspace "%s"', $workspaceName->value));
         }
         return WorkspacePermissions::create(
             read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
             write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
             manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
-            reason: "Anonymous user has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'",
+            reason: sprintf('Anonymous user has role "%s" for workspace "%s"', $userWorkspaceRole->value, $workspaceName->value),
         );
     }
 
     /**
-     * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions}
+     * Determines the {@see WorkspacePermissions} the given user has for the specified workspace
      */
-    public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions
+    public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, Account $account): WorkspacePermissions
     {
         $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName);
-        if ($workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) {
-            return WorkspacePermissions::all("User '{$user->getLabel()}' (id: {$user->getId()->value} is the owner of workspace '{$workspaceName->value}'");
+        $neosUser = $this->neosUserFromAccount($account);
+        if ($workspaceMetadata->ownerUserId !== null && $neosUser !== null && $neosUser->getId()->equals($workspaceMetadata->ownerUserId)) {
+            return WorkspacePermissions::all(sprintf('User "%s" (id: %s is the owner of workspace "%s"', $neosUser->getLabel(), $neosUser->getId()->value, $workspaceName->value));
         }
-        $userRoles = $this->rolesForUser($user);
-        $userIsAdministrator = array_key_exists(self::FLOW_ROLE_ADMINISTRATOR, $userRoles);
+        $userRoles = $this->expandAccountRoles($account);
+        $userIsAdministrator = array_key_exists(self::FLOW_ROLE_NEOS_ADMINISTRATOR, $userRoles);
         $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles));
-        $subjects[] = WorkspaceRoleSubject::createForUser($user->getId());
+
+        if ($neosUser !== null) {
+            $subjects[] = WorkspaceRoleSubject::createForUser($neosUser->getId());
+        }
         $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
         if ($userWorkspaceRole === null) {
             if ($userIsAdministrator) {
-                return WorkspacePermissions::manage("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' but is an Administrator");
+                return WorkspacePermissions::manage(sprintf('Account "%s" is a Neos Administrator without explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value));
             }
-            return WorkspacePermissions::none("User '{$user->getLabel()}' (id: '{$user->getId()->value}') has no explicit role for workspace '{$workspaceName->value}' and is no Administrator");
+            return WorkspacePermissions::none(sprintf('Account "%s" is no Neos Administrator and has no explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value));
         }
         return WorkspacePermissions::create(
             read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
             write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
             manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
-            reason: "User '{$user->getLabel()}' (id: '{$user->getId()->value}') has role '{$userWorkspaceRole->value}' for workspace '{$workspaceName->value}'" . ($userIsAdministrator ? ' and is an Administrator' : ' and is no Administrator'),
+            reason: sprintf('Account "%s" is %s Neos Administrator and has role "%s" for workspace "%s"', $account->getAccountIdentifier(), $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value),
         );
     }
 
     /**
-     * @param array<Role> $roles
+     * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access")
      */
-    public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
+    public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
     {
+        $roles = [
+            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
+            self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS),
+        ];
         $restrictedSubtreeTags = [];
         /** @var SubtreeTagPrivilege $privilege */
         foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) {
@@ -100,16 +112,49 @@ public function getVisibilityConstraintsForRoles(ContentRepositoryId $contentRep
         return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags));
     }
 
+    /**
+     * Determines the default {@see VisibilityConstraints} for the specified account
+     */
+    public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints
+    {
+        $roles = $this->expandAccountRoles($account);
+        $restrictedSubtreeTags = [];
+        /** @var SubtreeTagPrivilege $privilege */
+        foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) {
+            if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) {
+                $restrictedSubtreeTags[] = $privilege->getSubtreeTag();
+            }
+        }
+        return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags));
+    }
+
+    // ------------------------------
+
     /**
      * @return array<Role>
      */
-    private function rolesForUser(User $user): array
+    private function expandAccountRoles(Account $account): array
     {
-        try {
-            $userRoles = $this->userService->getAllRoles($user);
-        } catch (NoSuchRoleException $e) {
-            throw new \RuntimeException("Failed to determine roles for user '{$user->getLabel()}' (id: '{$user->getId()->value}'), check your package dependencies: {$e->getMessage()}", 1727084881, $e);
+        $roles = [
+            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
+            self::FLOW_ROLE_AUTHENTICATED_USER => $this->policyService->getRole(self::FLOW_ROLE_AUTHENTICATED_USER),
+        ];
+        foreach ($account->getRoles() as $currentRole) {
+            if (!array_key_exists($currentRole->getIdentifier(), $roles)) {
+                $roles[$currentRole->getIdentifier()] = $currentRole;
+            }
+            foreach ($currentRole->getAllParentRoles() as $currentParentRole) {
+                if (!array_key_exists($currentParentRole->getIdentifier(), $roles)) {
+                    $roles[$currentParentRole->getIdentifier()] = $currentParentRole;
+                }
+            }
         }
-        return $userRoles;
+        return $roles;
+    }
+
+    private function neosUserFromAccount(Account $account): ?User
+    {
+        $user = $this->partyService->getAssignedPartyOfAccount($account);
+        return $user instanceof User ? $user : null;
     }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index bd8b020c877..60f73360eb2 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -56,7 +56,11 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return VisibilityConstraints::default();
         }
-        return $this->authorizationService->getVisibilityConstraintsForRoles($this->contentRepositoryId, $this->securityContext->getRoles());
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount) {
+            return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount);
+        }
+        return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId);
     }
 
     public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
@@ -64,11 +68,11 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName)
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
         }
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
             $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
         } else {
-            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
         }
         return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason());
     }
@@ -124,10 +128,10 @@ private function requireWorkspacePermission(WorkspaceName $workspaceName, string
 
     private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions
     {
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
             return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
         }
-        return $this->authorizationService->getWorkspacePermissionsForUser($this->contentRepositoryId, $workspaceName, $user);
+        return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
     }
 }
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index ea63307c93a..e3a2276cc9a 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -113,9 +113,9 @@ class WorkspaceController extends AbstractModuleController
      */
     public function indexAction(): void
     {
-        $currentUser = $this->userService->getCurrentUser();
-        if ($currentUser === null) {
-            throw new \RuntimeException('No user authenticated', 1718308216);
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
+            throw new AccessDeniedException('No user authenticated', 1718308216);
         }
 
         $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds();
@@ -140,7 +140,7 @@ public function indexAction(): void
         $allWorkspaces = $contentRepository->findWorkspaces();
         foreach ($allWorkspaces as $workspace) {
             $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName);
-            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser);
+            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $workspace->workspaceName, $authenticatedAccount);
             if (!$permissions->read) {
                 continue;
             }
@@ -160,9 +160,9 @@ classification: $workspaceMetadata->classification->name,
 
     public function showAction(WorkspaceName $workspace): void
     {
-        $currentUser = $this->userService->getCurrentUser();
-        if ($currentUser === null) {
-            throw new \RuntimeException('No user authenticated', 1720371024);
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
+            throw new AccessDeniedException('No user authenticated', 1720371024);
         }
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
@@ -179,7 +179,7 @@ public function showAction(WorkspaceName $workspace): void
             $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName);
             assert($baseWorkspace !== null);
             $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName);
-            $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser);
+            $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $baseWorkspace->workspaceName, $authenticatedAccount);
         }
         $this->view->assignMultiple([
             'selectedWorkspace' => $workspaceObj,
@@ -208,7 +208,7 @@ public function createAction(
     ): void {
         $currentUser = $this->userService->getCurrentUser();
         if ($currentUser === null) {
-            throw new \RuntimeException('No user authenticated', 1718303756);
+            throw new AccessDeniedException('No user authenticated', 1718303756);
         }
         $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value);
         try {
@@ -289,11 +289,11 @@ public function updateAction(
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
 
-        $user = $this->userService->getCurrentUser();
-        if ($user === null) {
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
             throw new AccessDeniedException('No user is authenticated', 1729620262);
         }
-        $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspaceName, $user);
+        $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspaceName, $authenticatedAccount);
         if (!$workspacePermissions->manage) {
             throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297);
         }
@@ -1023,7 +1023,7 @@ protected function prepareBaseWorkspaceOptions(
         ContentRepository $contentRepository,
         WorkspaceName $excludedWorkspace = null,
     ): array {
-        $user = $this->userService->getCurrentUser();
+        $authenticatedAccount = $this->securityContext->getAccount();
         $baseWorkspaceOptions = [];
         $workspaces = $contentRepository->findWorkspaces();
         foreach ($workspaces as $workspace) {
@@ -1039,10 +1039,11 @@ protected function prepareBaseWorkspaceOptions(
             if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) {
                 continue;
             }
-            if ($user === null) {
-                continue;
+            if ($authenticatedAccount !== null) {
+                $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspace->workspaceName, $authenticatedAccount);
+            } else {
+                $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($contentRepository->id, $workspace->workspaceName);
             }
-            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user);
             if (!$permissions->manage) {
                 continue;
             }

From 65a0ab5bd6f5722cf22a107e0cfce770142a039f Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 23 Oct 2024 12:45:35 +0200
Subject: [PATCH 14/58] Adjust behat tests to account based ACL

---
 .../Features/Bootstrap/WorkspaceServiceTrait.php   | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 8106b291e13..416b6d4a8d3 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -213,10 +213,13 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName
     public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void
     {
         $user = $this->getObject(UserService::class)->getUser($username);
-        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser(
+        Assert::assertNotNull($user);
+        $account = $user->getAccounts()->first();
+        Assert::assertNotNull($account);
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $user,
+            $account,
         );
         Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions)))));
     }
@@ -227,10 +230,13 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username
     public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void
     {
         $user = $this->getObject(UserService::class)->getUser($username);
-        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForUser(
+        Assert::assertNotNull($user);
+        $account = $user->getAccounts()->first();
+        Assert::assertNotNull($account);
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $user,
+            $account,
         );
         Assert::assertFalse($permissions->read);
         Assert::assertFalse($permissions->write);

From c0536d305bfacc318183243f7a60aa1a12d11b21 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Thu, 24 Oct 2024 16:30:07 +0200
Subject: [PATCH 15/58] Tweak `AuthProvideInterface` method names

and add doc comments
---
 .../Classes/ContentRepository.php                        | 7 +++++--
 .../Classes/Feature/Security/AuthProviderInterface.php   | 6 ++++--
 .../Classes/Feature/Security/StaticAuthProvider.php      | 4 ++--
 .../Projection/ContentGraph/ContentGraphInterface.php    | 3 ++-
 .../Features/Bootstrap/Helpers/FakeAuthProvider.php      | 4 ++--
 Neos.Neos/Classes/Controller/Frontend/NodeController.php | 7 ++++---
 .../ContentRepositoryAuthorizationService.php            | 2 ++
 .../ContentRepositoryAuthProvider.php                    | 9 ++++++---
 Neos.Neos/Configuration/Policy.yaml                      | 2 ++
 9 files changed, 29 insertions(+), 15 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index e3eb1b81dec..e698ba424f7 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -104,7 +104,7 @@ public function __construct(
      */
     public function handle(CommandInterface $command): void
     {
-        $privilege = $this->authProvider->getCommandPrivilege($command);
+        $privilege = $this->authProvider->canExecuteCommand($command);
         if (!$privilege->granted) {
             throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason);
         }
@@ -266,7 +266,7 @@ public function resetProjectionState(string $projectionClassName): void
      */
     public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInterface
     {
-        $privilege = $this->authProvider->getReadNodesFromWorkspacePrivilege($workspaceName);
+        $privilege = $this->authProvider->canReadNodesFromWorkspace($workspaceName);
         if (!$privilege->granted) {
             throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason);
         }
@@ -278,6 +278,9 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter
     }
 
     /**
+     * Main API to retrieve a content subgraph, taking VisibilityConstraints of the current user
+     * into account ({@see AuthProviderInterface::getVisibilityConstraints()})
+     *
      * @throws WorkspaceDoesNotExist if the workspace does not exist
      * @throws AccessDenied if no read access is granted to the workspace ({@see AuthProviderInterface})
      */
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
index 2d2bca17bfc..e6f8524e3e1 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/AuthProviderInterface.php
@@ -11,15 +11,17 @@
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 
 /**
+ * Provides authorization decisions for the current user, for one Content Repository.
+ *
  * @internal except for CR factory implementations
  */
 interface AuthProviderInterface
 {
     public function getAuthenticatedUserId(): ?UserId;
 
-    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege;
+    public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege;
 
     public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints;
 
-    public function getCommandPrivilege(CommandInterface $command): Privilege;
+    public function canExecuteCommand(CommandInterface $command): Privilege;
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
index 5c5b72e1223..2d44bcf63fe 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/StaticAuthProvider.php
@@ -32,12 +32,12 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         return VisibilityConstraints::default();
     }
 
-    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
+    public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege
     {
         return Privilege::granted(self::class . ' always grants privileges');
     }
 
-    public function getCommandPrivilege(CommandInterface $command): Privilege
+    public function canExecuteCommand(CommandInterface $command): Privilege
     {
         return Privilege::granted(self::class . ' always grants privileges');
     }
diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php
index e8cac17eb67..c3de5f221d9 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/ContentGraphInterface.php
@@ -14,6 +14,7 @@
 
 namespace Neos\ContentRepository\Core\Projection\ContentGraph;
 
+use Neos\ContentRepository\Core\ContentRepository;
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet;
 use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint;
@@ -50,7 +51,7 @@ public function getContentRepositoryId(): ContentRepositoryId;
     public function getWorkspaceName(): WorkspaceName;
 
     /**
-     * @api main API method of ContentGraph
+     * @api You most likely want to use {@see ContentRepository::getContentSubgraph()} because it automatically determines VisibilityConstraints for the current user.
      */
     public function getSubgraph(
         DimensionSpacePoint $dimensionSpacePoint,
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
index ac7def3d8d0..916e98ffee3 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
@@ -30,12 +30,12 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         return VisibilityConstraints::withoutRestrictions();
     }
 
-    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
+    public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege
     {
         return Privilege::granted(self::class . ' always grants privileges');
     }
 
-    public function getCommandPrivilege(CommandInterface $command): Privilege
+    public function canExecuteCommand(CommandInterface $command): Privilege
     {
         return Privilege::granted(self::class . ' always grants privileges');
     }
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index db146184bdb..7191c4e496f 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -200,15 +200,16 @@ public function showAction(string $node): void
         }
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
-        // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
-        // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated
         $authenticatedAccount = $this->securityContext->getAccount();
         if ($authenticatedAccount !== null) {
             $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount);
         } else {
             $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id);
         }
+        // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
+        // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
+        // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated,
+        // to ensure that disabled nodes are NEVER shown recursively.
         $visibilityConstraints = $visibilityConstraints->withAddedSubtreeTag(SubtreeTag::disabled());
         $uncachedSubgraph = $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph($nodeAddress->dimensionSpacePoint, $visibilityConstraints);
 
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 6bb13616c79..58717299d95 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -24,6 +24,8 @@
 use Neos\Party\Domain\Service\PartyService;
 
 /**
+ * Central point which does ContentRepository authorization decisions within Neos.
+ *
  * @api
  */
 #[Flow\Scope('singleton')]
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 60f73360eb2..980f645caac 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -27,7 +27,10 @@
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
- * @api
+ * Implementation of Content Repository {@see AuthProviderInterface} which ties the authorization
+ * to Neos.
+ *
+ * @internal use {@see ContentRepositoryAuthorizationService} to ask for specific authorization decisions
  */
 final class ContentRepositoryAuthProvider implements AuthProviderInterface
 {
@@ -63,7 +66,7 @@ public function getVisibilityConstraints(WorkspaceName $workspaceName): Visibili
         return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId);
     }
 
-    public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName): Privilege
+    public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege
     {
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
@@ -77,7 +80,7 @@ public function getReadNodesFromWorkspacePrivilege(WorkspaceName $workspaceName)
         return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason());
     }
 
-    public function getCommandPrivilege(CommandInterface $command): Privilege
+    public function canExecuteCommand(CommandInterface $command): Privilege
     {
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml
index 1cd85ab83c2..32fed1bd759 100644
--- a/Neos.Neos/Configuration/Policy.yaml
+++ b/Neos.Neos/Configuration/Policy.yaml
@@ -146,6 +146,8 @@ privilegeTargets:
   'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege':
 
     'Neos.Neos:ContentRepository.ReadDisabledNodes':
+      # !!! matcher payload in this case is a ContentRepository SubtreeTag,
+      # i.e. nodes with ths specified tag are only read if the user has the corresponding privilegeTarget assigned.
       matcher: 'disabled'
 
 roles:

From 38bd6f3c969592f493d3c07e2e89c674bba58d5d Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Thu, 24 Oct 2024 16:30:36 +0200
Subject: [PATCH 16/58] Ignore disabled authorization checks when evaluating
 visibility constraints

---
 .../ContentRepositoryAuthProvider.php                          | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 980f645caac..67ece65438a 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -56,9 +56,6 @@ public function getAuthenticatedUserId(): ?UserId
 
     public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
     {
-        if ($this->securityContext->areAuthorizationChecksDisabled()) {
-            return VisibilityConstraints::default();
-        }
         $authenticatedAccount = $this->securityContext->getAccount();
         if ($authenticatedAccount) {
             return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount);

From fb4a3e889a94e39683fe05af7e656cef587a5887 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Sat, 26 Oct 2024 13:20:34 +0200
Subject: [PATCH 17/58] FEATURE: EditNodePrivilege!

---
 .../Classes/Domain/Model/NodePermissions.php  |  60 +++++++++++
 .../ContentRepositoryAuthorizationService.php | 100 ++++++++++++++----
 ...p => AbstractSubtreeTagBasedPrivilege.php} |  19 ++--
 .../Privilege/EditNodePrivilege.php           |  28 +++++
 .../Privilege/ReadNodePrivilege.php           |  28 +++++
 .../Privilege/SubtreeTagPrivilegeSubject.php  |  14 ++-
 .../ContentRepositoryAuthProvider.php         |  30 ++++++
 Neos.Neos/Configuration/Policy.yaml           |   2 +-
 8 files changed, 248 insertions(+), 33 deletions(-)
 create mode 100644 Neos.Neos/Classes/Domain/Model/NodePermissions.php
 rename Neos.Neos/Classes/Security/Authorization/Privilege/{SubtreeTagPrivilege.php => AbstractSubtreeTagBasedPrivilege.php} (78%)
 create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
 create mode 100644 Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php

diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
new file mode 100644
index 00000000000..760300720b2
--- /dev/null
+++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Domain\Model;
+
+use Neos\Flow\Annotations as Flow;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
+
+/**
+ * Evaluated permissions a specific user has on a node, usually evaluated by the {@see ContentRepositoryAuthorizationService}
+ *
+ * - read: Permission to read the node and its properties and references
+ * - edit: Permission to change the node
+ *
+ * @api
+ */
+#[Flow\Proxy(false)]
+final readonly class NodePermissions
+{
+    /**
+     * @param bool $read Permission to read data from the corresponding node
+     * @param bool $edit Permission to edit the corresponding node
+     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
+     */
+    private function __construct(
+        public bool $read,
+        public bool $edit,
+        private string $reason,
+    ) {
+    }
+
+    /**
+     * @param bool $read Permission to read data from the corresponding node
+     * @param bool $edit Permission to edit the corresponding node
+     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
+     */
+    public static function create(
+        bool $read,
+        bool $edit,
+        string $reason,
+    ): self {
+        return new self($read, $edit, $reason);
+    }
+
+    public static function all(string $reason): self
+    {
+        return new self(true, true, $reason);
+    }
+
+    public static function none(string $reason): self
+    {
+        return new self(false, false, $reason);
+    }
+
+    public function getReason(): string
+    {
+        return $this->reason;
+    }
+}
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 58717299d95..b6ebc7a8994 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -5,21 +5,26 @@
 namespace Neos\Neos\Security\Authorization;
 
 use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
+use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Account;
 use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
 use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Flow\Security\Policy\Role;
+use Neos\Neos\Domain\Model\NodePermissions;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
 use Neos\Neos\Domain\Service\WorkspaceService;
-use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege;
+use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege;
+use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
 use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject;
 use Neos\Party\Domain\Service\PartyService;
 
@@ -36,10 +41,10 @@
     private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser';
     private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator';
 
-
     public function __construct(
         private PartyService $partyService,
         private WorkspaceService $workspaceService,
+        private ContentRepositoryRegistry $contentRepositoryRegistry,
         private PolicyService $policyService,
         private PrivilegeManagerInterface $privilegeManager,
     ) {
@@ -95,23 +100,25 @@ public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRe
         );
     }
 
+    public function getNodePermissionsForAnonymousUser(Node|NodeAddress $node): NodePermissions
+    {
+        $roles = $this->rolesOfAnonymousUser();
+        return $this->nodePermissionsForRoles($node, $roles);
+    }
+
+    public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $account): NodePermissions
+    {
+        $roles = $this->expandAccountRoles($account);
+        return $this->nodePermissionsForRoles($node, $roles);
+    }
+
     /**
      * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access")
      */
     public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
     {
-        $roles = [
-            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
-            self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS),
-        ];
-        $restrictedSubtreeTags = [];
-        /** @var SubtreeTagPrivilege $privilege */
-        foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) {
-            if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) {
-                $restrictedSubtreeTags[] = $privilege->getSubtreeTag();
-            }
-        }
-        return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags));
+        $roles = $this->rolesOfAnonymousUser();
+        return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles));
     }
 
     /**
@@ -120,18 +127,22 @@ public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $co
     public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints
     {
         $roles = $this->expandAccountRoles($account);
-        $restrictedSubtreeTags = [];
-        /** @var SubtreeTagPrivilege $privilege */
-        foreach ($this->policyService->getAllPrivilegesByType(SubtreeTagPrivilege::class) as $privilege) {
-            if (!$this->privilegeManager->isGrantedForRoles($roles, SubtreeTagPrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTag(), $contentRepositoryId))) {
-                $restrictedSubtreeTags[] = $privilege->getSubtreeTag();
-            }
-        }
-        return new VisibilityConstraints(SubtreeTags::fromArray($restrictedSubtreeTags));
+        return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles));
     }
 
     // ------------------------------
 
+    /**
+     * @return array<Role>
+     */
+    private function rolesOfAnonymousUser(): array
+    {
+        return [
+            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
+            self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS),
+        ];
+    }
+
     /**
      * @return array<Role>
      */
@@ -154,9 +165,54 @@ private function expandAccountRoles(Account $account): array
         return $roles;
     }
 
+    /**
+     * @param array<Role> $roles
+     */
+    private function restrictedSubtreeTagsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags
+    {
+        $restrictedSubtreeTags = SubtreeTags::createEmpty();
+        /** @var ReadNodePrivilege $privilege */
+        foreach ($this->policyService->getAllPrivilegesByType(ReadNodePrivilege::class) as $privilege) {
+            if (!$this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, new SubtreeTagPrivilegeSubject($privilege->getSubtreeTags(), $contentRepositoryId))) {
+                $restrictedSubtreeTags = $restrictedSubtreeTags->merge($privilege->getSubtreeTags());
+            }
+        }
+        return $restrictedSubtreeTags;
+    }
+
     private function neosUserFromAccount(Account $account): ?User
     {
         $user = $this->partyService->getAssignedPartyOfAccount($account);
         return $user instanceof User ? $user : null;
     }
+
+    /**
+     * @param array<Role> $roles
+     */
+    private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles): NodePermissions
+    {
+        if ($node instanceof NodeAddress) {
+            $converted = $this->nodeForNodeAddress($node);
+            if ($converted === null) {
+                return NodePermissions::none(sprintf('Node "%s" not found in Content Repository "%s"', $node->aggregateId->value, $node->contentRepositoryId->value));
+            }
+            $node = $converted;
+        }
+        $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId);
+        $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason);
+        $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason);
+        return NodePermissions::create(
+            read: $readGranted,
+            edit: $writeGranted,
+            reason: $readReason . "\n" . $writeReason,
+        );
+    }
+
+    private function nodeForNodeAddress(NodeAddress $nodeAddress): ?Node
+    {
+        return $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId)
+            ->getContentGraph($nodeAddress->workspaceName)
+            ->getSubgraph($nodeAddress->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
+            ->findNodeById($nodeAddress->aggregateId);
+    }
 }
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
similarity index 78%
rename from Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
rename to Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
index da4eb47357c..8aaea9f1d35 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
@@ -15,6 +15,7 @@
 namespace Neos\Neos\Security\Authorization\Privilege;
 
 use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
 use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
@@ -23,14 +24,15 @@
 /**
  * TODO docs
  */
-class SubtreeTagPrivilege extends AbstractPrivilege
+abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege
 {
-    private SubtreeTag|null $subtreeTagRuntimeCache = null;
+    private bool $initialized = false;
+    private SubtreeTags|null $subtreeTagsRuntimeCache = null;
     private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null;
 
     private function initialize(): void
     {
-        if ($this->subtreeTagRuntimeCache !== null) {
+        if ($this->initialized) {
             return;
         }
         $subtreeTag = $this->getParsedMatcher();
@@ -38,7 +40,8 @@ private function initialize(): void
             [$contentRepositoryId, $subtreeTag] = explode(':', $subtreeTag);
             $this->contentRepositoryIdRuntimeCache = ContentRepositoryId::fromString($contentRepositoryId);
         }
-        $this->subtreeTagRuntimeCache = SubtreeTag::fromString($subtreeTag);
+        $this->subtreeTagsRuntimeCache = SubtreeTags::fromStrings($subtreeTag);
+        $this->initialized = true;
     }
 
     /**
@@ -57,14 +60,14 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool
         if ($contentRepositoryId !== null && $subject->contentRepositoryId !== null && !$contentRepositoryId->equals($subject->contentRepositoryId)) {
             return false;
         }
-        return $subject->subTreeTag->equals($this->getSubtreeTag());
+        return !$this->getSubtreeTags()->intersection($subject->subTreeTags)->isEmpty();
     }
 
-    public function getSubtreeTag(): SubtreeTag
+    public function getSubtreeTags(): SubtreeTags
     {
         $this->initialize();
-        assert($this->subtreeTagRuntimeCache !== null);
-        return $this->subtreeTagRuntimeCache;
+        assert($this->subtreeTagsRuntimeCache !== null);
+        return $this->subtreeTagsRuntimeCache;
     }
 
     public function getContentRepositoryId(): ?ContentRepositoryId
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
new file mode 100644
index 00000000000..52d94c0516b
--- /dev/null
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
@@ -0,0 +1,28 @@
+<?php
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\Authorization\Privilege;
+
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
+use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
+use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
+
+/**
+ * TODO docs
+ */
+class EditNodePrivilege extends AbstractSubtreeTagBasedPrivilege
+{
+}
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php
new file mode 100644
index 00000000000..f96f1a0ddaa
--- /dev/null
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php
@@ -0,0 +1,28 @@
+<?php
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Security\Authorization\Privilege;
+
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
+use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
+use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
+
+/**
+ * TODO docs
+ */
+class ReadNodePrivilege extends AbstractSubtreeTagBasedPrivilege
+{
+}
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
index a08a0a090aa..fb78ee75883 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
@@ -15,17 +15,27 @@
 namespace Neos\Neos\Security\Authorization\Privilege;
 
 use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
 
 /**
- * A subject for the {@see SubtreeTagPrivilege}
+ * A subject for the {@see ReadNodePrivilege}
  */
 final readonly class SubtreeTagPrivilegeSubject implements PrivilegeSubjectInterface
 {
     public function __construct(
-        public SubtreeTag $subTreeTag,
+        public SubtreeTags $subTreeTags,
         public ContentRepositoryId|null $contentRepositoryId = null,
     ) {
     }
+
+    public function __toString(): string
+    {
+        $label = 'tag' . ($this->subTreeTags->count() > 1 ? 's' : '') . ' "' . implode('", "', $this->subTreeTags->toStringArray()) . '"';
+        if ($this->contentRepositoryId !== null) {
+            $label .= ' in Content Repository "' . $this->contentRepositoryId->value . '"';
+        }
+        return $label;
+    }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 67ece65438a..c1fc1710a1a 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -5,10 +5,14 @@
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\ContentRepository;
 use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
+use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
+use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
 use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
 use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
@@ -20,8 +24,11 @@
 use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Neos\Domain\Model\NodePermissions;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
@@ -82,6 +89,20 @@ public function canExecuteCommand(CommandInterface $command): Privilege
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
         }
+        if ($command instanceof SetNodeProperties) {
+            $nodePermissions = $this->getNodePermissionsForCurrentUser(
+                NodeAddress::create(
+                    $this->contentRepositoryId,
+                    $command->workspaceName,
+                    $command->originDimensionSpacePoint->toDimensionSpacePoint(),
+                    $command->nodeAggregateId,
+                )
+            );
+            if (!$nodePermissions->edit) {
+                return Privilege::denied($nodePermissions->getReason());
+            }
+            return $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE);
+        }
 
         // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all
         // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the
@@ -134,4 +155,13 @@ private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceN
         }
         return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
     }
+
+    private function getNodePermissionsForCurrentUser(NodeAddress $nodeAddress): NodePermissions
+    {
+        $authenticatedAccount = $this->securityContext->getAccount();
+        if ($authenticatedAccount === null) {
+            return $this->authorizationService->getNodePermissionsForAnonymousUser($nodeAddress);
+        }
+        return $this->authorizationService->getNodePermissionsForAccount($nodeAddress, $authenticatedAccount);
+    }
 }
diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml
index 32fed1bd759..c441d522255 100644
--- a/Neos.Neos/Configuration/Policy.yaml
+++ b/Neos.Neos/Configuration/Policy.yaml
@@ -143,7 +143,7 @@ privilegeTargets:
       matcher: 'administration/dimensions'
 
 
-  'Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilege':
+  'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege':
 
     'Neos.Neos:ContentRepository.ReadDisabledNodes':
       # !!! matcher payload in this case is a ContentRepository SubtreeTag,

From c05e53d94a77fb997d23011b667eec901babe4b9 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Sun, 27 Oct 2024 19:01:15 +0100
Subject: [PATCH 18/58] TASK: Dont expose `$visibilityConstraints` in
 `SiteNodeUtility`

---
 .../Domain/Service/SiteNodeUtility.php        | 27 +++++--------------
 .../Classes/View/FusionExceptionView.php      |  3 +--
 2 files changed, 8 insertions(+), 22 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php
index 262d24e4461..bbcfbee80cd 100644
--- a/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php
+++ b/Neos.Neos/Classes/Domain/Service/SiteNodeUtility.php
@@ -17,19 +17,15 @@
 
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
-use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Neos\Domain\Model\Site;
 use Neos\Neos\Domain\Repository\SiteRepository;
-use Neos\Neos\Utility\NodeTypeWithFallbackProvider;
 
 #[Flow\Scope('singleton')]
 final class SiteNodeUtility
 {
-    use NodeTypeWithFallbackProvider;
-
     public function __construct(
         private readonly ContentRepositoryRegistry $contentRepositoryRegistry
     ) {
@@ -44,8 +40,7 @@ public function __construct(
      * $siteNode = $this->siteNodeUtility->findSiteNodeBySite(
      *     $site,
      *     WorkspaceName::forLive(),
-     *     DimensionSpacePoint::createWithoutDimensions(),
-     *     VisibilityConstraints::frontend()
+     *     DimensionSpacePoint::createWithoutDimensions()
      * );
      * ```
      *
@@ -54,26 +49,18 @@ public function __construct(
     public function findSiteNodeBySite(
         Site $site,
         WorkspaceName $workspaceName,
-        DimensionSpacePoint $dimensionSpacePoint,
-        VisibilityConstraints $visibilityConstraints
+        DimensionSpacePoint $dimensionSpacePoint
     ): Node {
         $contentRepository = $this->contentRepositoryRegistry->get($site->getConfiguration()->contentRepositoryId);
 
-        $contentGraph = $contentRepository->getContentGraph($workspaceName);
-        $subgraph = $contentGraph->getSubgraph(
-            $dimensionSpacePoint,
-            $visibilityConstraints,
-        );
+        $subgraph = $contentRepository->getContentSubgraph($workspaceName, $dimensionSpacePoint);
 
-        $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType(
-            NodeTypeNameFactory::forSites()
-        );
-        if (!$rootNodeAggregate) {
+        $rootNode = $subgraph->findRootNodeByType(NodeTypeNameFactory::forSites());
+
+        if (!$rootNode) {
             throw new \RuntimeException(sprintf('No sites root node found in content repository "%s", while fetching site node "%s"', $contentRepository->id->value, $site->getNodeName()), 1719046570);
         }
 
-        $rootNode = $rootNodeAggregate->getNodeByCoveredDimensionSpacePoint($dimensionSpacePoint);
-
         $siteNode = $subgraph->findNodeByPath(
             $site->getNodeName()->toNodeName(),
             $rootNode->aggregateId
@@ -83,7 +70,7 @@ public function findSiteNodeBySite(
             throw new \RuntimeException(sprintf('No site node found for site "%s"', $site->getNodeName()), 1697140379);
         }
 
-        if (!$this->getNodeType($siteNode)->isOfType(NodeTypeNameFactory::NAME_SITE)) {
+        if (!$contentRepository->getNodeTypeManager()->getNodeType($siteNode->nodeTypeName)?->isOfType(NodeTypeNameFactory::NAME_SITE)) {
             throw new \RuntimeException(sprintf(
                 'The site node "%s" (type: "%s") must be of type "%s"',
                 $siteNode->aggregateId->value,
diff --git a/Neos.Neos/Classes/View/FusionExceptionView.php b/Neos.Neos/Classes/View/FusionExceptionView.php
index 041ec02cb67..abdd8fd7d95 100644
--- a/Neos.Neos/Classes/View/FusionExceptionView.php
+++ b/Neos.Neos/Classes/View/FusionExceptionView.php
@@ -122,8 +122,7 @@ public function render(): ResponseInterface|StreamInterface
             $currentSiteNode = $this->siteNodeUtility->findSiteNodeBySite(
                 $site,
                 WorkspaceName::forLive(),
-                $arbitraryRootDimensionSpacePoint,
-                VisibilityConstraints::default()
+                $arbitraryRootDimensionSpacePoint
             );
         } catch (WorkspaceDoesNotExist | \RuntimeException) {
             return $this->renderErrorWelcomeScreen();

From 0bf97e2779edc863178ac1882a0c6328fa5a62b5 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Sun, 27 Oct 2024 19:03:04 +0100
Subject: [PATCH 19/58] TASK: Use current user visibility constraints for data
 source controller

---
 .../Service/Controller/DataSourceController.php       | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/Neos.Neos/Classes/Service/Controller/DataSourceController.php b/Neos.Neos/Classes/Service/Controller/DataSourceController.php
index db29b994b7b..265d151c766 100644
--- a/Neos.Neos/Classes/Service/Controller/DataSourceController.php
+++ b/Neos.Neos/Classes/Service/Controller/DataSourceController.php
@@ -15,7 +15,6 @@
 namespace Neos\Neos\Service\Controller;
 
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
-use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
@@ -68,12 +67,12 @@ public function indexAction($dataSourceIdentifier, string $node = null): void
         unset($arguments['dataSourceIdentifier']);
         unset($arguments['node']);
 
-        $values = $dataSource->getData($this->deserializeNodeFromLegacyAddress($node), $arguments);
+        $values = $dataSource->getData($this->deserializeNodeFromNodeAddress($node), $arguments);
 
         $this->view->assign('value', $values);
     }
 
-    private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAddress): ?Node
+    private function deserializeNodeFromNodeAddress(?string $stringFormattedNodeAddress): ?Node
     {
         if (!$stringFormattedNodeAddress) {
             return null;
@@ -82,10 +81,8 @@ private function deserializeNodeFromLegacyAddress(?string $stringFormattedNodeAd
         $nodeAddress = NodeAddress::fromJsonString($stringFormattedNodeAddress);
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        return $contentRepository->getContentGraph($nodeAddress->workspaceName)->getSubgraph(
-            $nodeAddress->dimensionSpacePoint,
-            VisibilityConstraints::withoutRestrictions()
-        )->findNodeById($nodeAddress->aggregateId);
+        return $contentRepository->getContentSubgraph($nodeAddress->workspaceName, $nodeAddress->dimensionSpacePoint)
+            ->findNodeById($nodeAddress->aggregateId);
     }
 
     /**

From 14c9f84fd0b899d84ab17dc837d356f7a5494581 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 29 Oct 2024 14:51:52 +0100
Subject: [PATCH 20/58] Introduce named `VisibilityConstraints` constructor

---
 .../Projection/ContentGraph/VisibilityConstraints.php  | 10 +++++++++-
 .../ContentRepositoryAuthorizationService.php          |  6 +++---
 2 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
index 598b9945a15..f1c4f3b93ae 100644
--- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
+++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/VisibilityConstraints.php
@@ -29,11 +29,19 @@
     /**
      * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query
      */
-    public function __construct(
+    private function __construct(
         public SubtreeTags $tagConstraints,
     ) {
     }
 
+    /**
+     * @param SubtreeTags $tagConstraints A set of {@see SubtreeTag} instances that will be _excluded_ from the results of any content graph query
+     */
+    public static function fromTagConstraints(SubtreeTags $tagConstraints): self
+    {
+        return new self($tagConstraints);
+    }
+
     public function getHash(): string
     {
         return md5(implode('|', $this->tagConstraints->toStringArray()));
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index b6ebc7a8994..12bc6b5e0bb 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -118,7 +118,7 @@ public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $ac
     public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
     {
         $roles = $this->rolesOfAnonymousUser();
-        return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles));
+        return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles));
     }
 
     /**
@@ -127,7 +127,7 @@ public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $co
     public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints
     {
         $roles = $this->expandAccountRoles($account);
-        return new VisibilityConstraints($this->restrictedSubtreeTagsForRoles($contentRepositoryId, $roles));
+        return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles));
     }
 
     // ------------------------------
@@ -168,7 +168,7 @@ private function expandAccountRoles(Account $account): array
     /**
      * @param array<Role> $roles
      */
-    private function restrictedSubtreeTagsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags
+    private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags
     {
         $restrictedSubtreeTags = SubtreeTags::createEmpty();
         /** @var ReadNodePrivilege $privilege */

From ac70e83fd8d9889858586526c87e7aa595c4bb2c Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 29 Oct 2024 14:53:10 +0100
Subject: [PATCH 21/58] Adjust Behat tests to refer to Visibility constraints
 "default" instead of "frontend"

---
 ...bleNodeAggregate_WithoutDimensions.feature |  2 +-
 ...isableNodeAggregate_WithDimensions.feature | 20 ++++++-------
 ...bleNodeAggregate_WithoutDimensions.feature |  6 ++--
 ...EnableNodeAggregate_WithDimensions.feature | 30 +++++++++----------
 ...ithDisabledAncestor_WithDimensions.feature |  2 +-
 ...09-CreateNodeVariantOfDisabledNode.feature |  2 +-
 ...WithDisabledNodesWithoutDimensions.feature |  2 +-
 .../AddDimensionShineThrough.feature          |  6 ++--
 .../Migration/MoveDimensionSpacePoint.feature |  6 ++--
 .../RemoveNodeAggregateAfterDisabling.feature |  2 +-
 .../Bootstrap/CRTestSuiteRuntimeVariables.php |  4 +--
 11 files changed, 41 insertions(+), 41 deletions(-)

diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature
index 67181a191e0..5f847014041 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/02-DisableNodeAggregate_WithoutDimensions.feature
@@ -109,7 +109,7 @@ Feature: Disable a node aggregate
     And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                        |
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature
index 356eccb0f7d..9574d8303d1 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/03-DisableNodeAggregate_WithDimensions.feature
@@ -121,7 +121,7 @@ Feature: Disable a node aggregate
     And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -149,7 +149,7 @@ Feature: Disable a node aggregate
 
     # Tests for the generalization
     When I am in dimension space point {"language":"mul"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -196,7 +196,7 @@ Feature: Disable a node aggregate
 
     # Tests for the virtual specialization
     When I am in dimension space point {"language":"gsw"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -225,7 +225,7 @@ Feature: Disable a node aggregate
 
     # Tests for the real specialization
     When I am in dimension space point {"language":"ltz"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -254,7 +254,7 @@ Feature: Disable a node aggregate
 
     # Tests for the peer variant
     When I am in dimension space point {"language":"en"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -372,7 +372,7 @@ Feature: Disable a node aggregate
     And I expect node aggregate identifier "nody-mc-nodeface" and node path "document/child-document" to lead to node cs-identifier;nody-mc-nodeface;{"language":"mul"}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -400,7 +400,7 @@ Feature: Disable a node aggregate
 
     # Tests for the generalization
     When I am in dimension space point {"language":"mul"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -429,7 +429,7 @@ Feature: Disable a node aggregate
 
     # Tests for the virtual specialization
     When I am in dimension space point {"language":"gsw"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -458,7 +458,7 @@ Feature: Disable a node aggregate
 
     # Tests for the real specialization
     When I am in dimension space point {"language":"ltz"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -487,7 +487,7 @@ Feature: Disable a node aggregate
 
     # Tests for the peer variant
     When I am in dimension space point {"language":"en"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature
index 6a77ec9a4d5..63f7fe64cf7 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/05-EnableNodeAggregate_WithoutDimensions.feature
@@ -69,7 +69,7 @@ Feature: Enable a node aggregate
     And I expect this node aggregate to disable dimension space points []
 
     When I am in workspace "live" and dimension space point {}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                        |
@@ -144,7 +144,7 @@ Feature: Enable a node aggregate
     And I expect this node aggregate to disable dimension space points [{}]
 
     When I am in workspace "live" and dimension space point {}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                        |
@@ -219,7 +219,7 @@ Feature: Enable a node aggregate
     And I expect this node aggregate to disable dimension space points []
 
     When I am in workspace "live" and dimension space point {}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                        |
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature
index 49c6a27f297..34ff1cdcbd0 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/06-EnableNodeAggregate_WithDimensions.feature
@@ -144,7 +144,7 @@ Feature: Enable a node aggregate
     And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -194,7 +194,7 @@ Feature: Enable a node aggregate
 
     # Tests for the generalization
     When I am in dimension space point {"language":"mul"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -224,7 +224,7 @@ Feature: Enable a node aggregate
 
     # Tests for the virtual specialization
     When I am in dimension space point {"language":"gsw"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -275,7 +275,7 @@ Feature: Enable a node aggregate
 
     # Tests for the real specialization
     When I am in dimension space point {"language":"ltz"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -326,7 +326,7 @@ Feature: Enable a node aggregate
 
     # Tests for the peer variant
     When I am in dimension space point {"language":"en"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -439,7 +439,7 @@ Feature: Enable a node aggregate
     And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -489,7 +489,7 @@ Feature: Enable a node aggregate
 
     # Tests for the generalization
     When I am in dimension space point {"language":"mul"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -539,7 +539,7 @@ Feature: Enable a node aggregate
 
     # Tests for the virtual specialization
     When I am in dimension space point {"language":"gsw"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -590,7 +590,7 @@ Feature: Enable a node aggregate
 
     # Tests for the real specialization
     When I am in dimension space point {"language":"ltz"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -641,7 +641,7 @@ Feature: Enable a node aggregate
 
     # Tests for the peer variant
     When I am in dimension space point {"language":"en"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have the following child nodes:
       | Name                | NodeDiscriminator                                        |
@@ -717,25 +717,25 @@ Feature: Enable a node aggregate
     And I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to node cs-identifier;the-great-nodini;{"language":"mul"}
     And I expect this node to be a child of node cs-identifier;sir-david-nodenborough;{"language":"mul"}
 
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node
 
     # Tests for the generalization
     When I am in dimension space point {"language":"mul"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node
 
     # Tests for the virtual specialization
     When I am in dimension space point {"language":"gsw"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node
 
     # Tests for the real specialization
     When I am in dimension space point {"language":"ltz"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node
 
     # Tests for the peer variant
     When I am in dimension space point {"language":"en"}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "the-great-nodini" and node path "document/court-magician" to lead to no node
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature
index 0ae1ae2cdae..c3b0ef4813e 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/08-CreateNodeAggregateWithNodeWithDisabledAncestor_WithDimensions.feature
@@ -35,7 +35,7 @@ Feature: Creation of nodes underneath disabled nodes
       | nodeAggregateId | "the-great-nodini" |
       | sourceOrigin    | {"language":"mul"} |
       | targetOrigin    | {"language":"ltz"} |
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
 
   Scenario: Create a new node with parent disabled with strategy allSpecializations
     Given the command DisableNodeAggregate is executed with payload:
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature
index be26f2395e0..be6c01a921c 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/06-NodeDisabling/09-CreateNodeVariantOfDisabledNode.feature
@@ -24,7 +24,7 @@ Feature: Variation of hidden nodes
       | Key             | Value                         |
       | nodeAggregateId | "lady-eleonode-rootford"      |
       | nodeTypeName    | "Neos.ContentRepository:Root" |
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
 
   Scenario: Specialize a node where the specialization target is enabled
     Given I am in dimension space point {"language":"de"}
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature
index 780ef1d28ad..de97506aa49 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/ContentStreamForking/ForkContentStreamWithDisabledNodesWithoutDimensions.feature
@@ -75,7 +75,7 @@ Feature: On forking a content stream, hidden nodes should be correctly copied as
       | 1     | the-great-nodini       |
       | 2     | nodingers-cat          |
 
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then I expect node aggregate identifier "lady-eleonode-rootford" to lead to node user-cs-identifier;lady-eleonode-rootford;{}
     And I expect this node to have no child nodes
     And the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be:
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature
index 3f6180cc362..8387081b091 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/AddDimensionShineThrough.feature
@@ -142,7 +142,7 @@ Feature: Add Dimension Specialization
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     # we change the dimension configuration
     When I change the content dimensions in content repository "default" to:
@@ -166,14 +166,14 @@ Feature: Add Dimension Specialization
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     # The visibility edges were modified
     When I am in workspace "migration-workspace" and dimension space point {"language": "ch"}
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language":"de"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     When I run integrity violation detection
     Then I expect the integrity violation detection result to contain exactly 0 errors
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature
index f9a70788f8b..53dfbb58e86 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/EventSourced/Migration/MoveDimensionSpacePoint.feature
@@ -94,7 +94,7 @@ Feature: Move dimension space point
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     # we change the dimension configuration
     When I change the content dimensions in content repository "default" to:
@@ -118,14 +118,14 @@ Feature: Move dimension space point
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by cs-identifier;sir-david-nodenborough;{"language": "de"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     # The visibility edges were modified
     When I am in workspace "migration-workspace" and dimension space point {"language": "de_DE"}
     Then I expect node aggregate identifier "sir-david-nodenborough" to lead to no node
     When VisibilityConstraints are set to "withoutRestrictions"
     Then I expect a node identified by migration-cs;sir-david-nodenborough;{"language": "de_DE"} to exist in the content graph
-    When VisibilityConstraints are set to "frontend"
+    When VisibilityConstraints are set to "default"
 
     When I run integrity violation detection
     Then I expect the integrity violation detection result to contain exactly 0 errors
diff --git a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature
index d583afa77fa..a6d60d05c84 100644
--- a/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature
+++ b/Neos.ContentRepository.BehavioralTests/Tests/Behavior/Features/NodeRemoval/RemoveNodeAggregateAfterDisabling.feature
@@ -71,7 +71,7 @@ Feature: Disable a node aggregate
     And I expect this node aggregate to disable dimension space points []
 
     When I am in workspace "live" and dimension space point {}
-    And VisibilityConstraints are set to "frontend"
+    And VisibilityConstraints are set to "default"
     Then the subtree for node aggregate "lady-eleonode-rootford" with node types "" and 2 levels deep should be:
       | Level | nodeAggregateId         |
       | 0     | lady-eleonode-rootford  |
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
index 31a28356a93..b6ca95a29b6 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
@@ -111,13 +111,13 @@ public function iAmInWorkspaceAndDimensionSpacePoint(string $workspaceName, stri
     }
 
     /**
-     * @When /^VisibilityConstraints are set to "(withoutRestrictions|frontend)"$/
+     * @When /^VisibilityConstraints are set to "(withoutRestrictions|default)"$/
      */
     public function visibilityConstraintsAreSetTo(string $restrictionType): void
     {
         $this->currentVisibilityConstraints = match ($restrictionType) {
             'withoutRestrictions' => VisibilityConstraints::withoutRestrictions(),
-            'frontend' => VisibilityConstraints::default(),
+            'default' => VisibilityConstraints::default(),
             default => throw new \InvalidArgumentException('Visibility constraint "' . $restrictionType . '" not supported.'),
         };
     }

From abca5bbce0c375c1e4a3400dd97b087a568e7233 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 29 Oct 2024 15:11:53 +0100
Subject: [PATCH 22/58] Mark `NodePermissions` and `WorkspacePermissions`
 internal

---
 Neos.Neos/Classes/Domain/Model/NodePermissions.php      | 2 +-
 Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
index 760300720b2..5a2ff8733a8 100644
--- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
@@ -13,7 +13,7 @@
  * - read: Permission to read the node and its properties and references
  * - edit: Permission to change the node
  *
- * @api
+ * @internal
  */
 #[Flow\Proxy(false)]
 final readonly class NodePermissions
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
index cdb2461f6bb..421ed38c2cb 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
@@ -14,7 +14,7 @@
  * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it
  * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles)
  *
- * @api
+ * @internal
  */
 #[Flow\Proxy(false)]
 final readonly class WorkspacePermissions

From 1a4629701eab78ec9a7c58da5863e65479f16ae4 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 29 Oct 2024 15:12:22 +0100
Subject: [PATCH 23/58] Deduplicate workspace role specificity determination

---
 Neos.Neos/Classes/Domain/Model/WorkspaceRole.php      | 2 +-
 Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
index a36269e22b8..0b487fb03f9 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php
@@ -32,7 +32,7 @@ public function isAtLeast(self $role): bool
         return $this->specificity() >= $role->specificity();
     }
 
-    private function specificity(): int
+    public function specificity(): int
     {
         return match ($this) {
             self::VIEWER => 1,
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 6cc3ecfe4ac..3f7523c94e1 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -277,6 +277,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito
     public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole
     {
         $tableRole = self::TABLE_NAME_WORKSPACE_ROLE;
+        $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases()));
         $query = <<<SQL
             SELECT
                 role
@@ -293,10 +294,9 @@ public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $c
             ORDER BY
                 /* We only want to return the most specific role so we order them and return the first row */
                 CASE
-                    WHEN role='MANAGER' THEN 1
-                    WHEN role='COLLABORATOR' THEN 2
-                    WHEN role='VIEWER' THEN 3
+                    {$roleCasesBySpecificity}
                 END
+                DESC
             LIMIT 1
         SQL;
         $userSubjectValues = [];

From fedfac7da910fd1886aed2327bb9d27aca946966 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 30 Oct 2024 12:56:25 +0100
Subject: [PATCH 24/58] Tweak resolution of current node in
 `ContentRepositoryAuthProvider`

---
 .../ContentRepositoryAuthorizationService.php | 24 ++---------
 .../ContentRepositoryAuthProvider.php         | 41 +++++++++++--------
 .../ContentRepositoryAuthProviderFactory.php  |  4 +-
 3 files changed, 31 insertions(+), 38 deletions(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 12bc6b5e0bb..2f6cc32cf1f 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -8,9 +8,7 @@
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Account;
 use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
@@ -44,7 +42,6 @@
     public function __construct(
         private PartyService $partyService,
         private WorkspaceService $workspaceService,
-        private ContentRepositoryRegistry $contentRepositoryRegistry,
         private PolicyService $policyService,
         private PrivilegeManagerInterface $privilegeManager,
     ) {
@@ -100,13 +97,13 @@ public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRe
         );
     }
 
-    public function getNodePermissionsForAnonymousUser(Node|NodeAddress $node): NodePermissions
+    public function getNodePermissionsForAnonymousUser(Node $node): NodePermissions
     {
         $roles = $this->rolesOfAnonymousUser();
         return $this->nodePermissionsForRoles($node, $roles);
     }
 
-    public function getNodePermissionsForAccount(Node|NodeAddress $node, Account $account): NodePermissions
+    public function getNodePermissionsForAccount(Node $node, Account $account): NodePermissions
     {
         $roles = $this->expandAccountRoles($account);
         return $this->nodePermissionsForRoles($node, $roles);
@@ -189,15 +186,8 @@ private function neosUserFromAccount(Account $account): ?User
     /**
      * @param array<Role> $roles
      */
-    private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles): NodePermissions
+    private function nodePermissionsForRoles(Node $node, array $roles): NodePermissions
     {
-        if ($node instanceof NodeAddress) {
-            $converted = $this->nodeForNodeAddress($node);
-            if ($converted === null) {
-                return NodePermissions::none(sprintf('Node "%s" not found in Content Repository "%s"', $node->aggregateId->value, $node->contentRepositoryId->value));
-            }
-            $node = $converted;
-        }
         $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId);
         $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason);
         $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason);
@@ -207,12 +197,4 @@ private function nodePermissionsForRoles(Node|NodeAddress $node, array $roles):
             reason: $readReason . "\n" . $writeReason,
         );
     }
-
-    private function nodeForNodeAddress(NodeAddress $nodeAddress): ?Node
-    {
-        return $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId)
-            ->getContentGraph($nodeAddress->workspaceName)
-            ->getSubgraph($nodeAddress->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
-            ->findNodeById($nodeAddress->aggregateId);
-    }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 75e1bd1ad3e..fc624112a4f 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -6,6 +6,7 @@
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
 use Neos\ContentRepository\Core\ContentRepository;
+use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
 use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
 use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
 use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
@@ -22,9 +23,11 @@
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
+use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
+use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Security\Context as SecurityContext;
@@ -39,16 +42,17 @@
  *
  * @internal use {@see ContentRepositoryAuthorizationService} to ask for specific authorization decisions
  */
-final class ContentRepositoryAuthProvider implements AuthProviderInterface
+final readonly class ContentRepositoryAuthProvider implements AuthProviderInterface
 {
     private const WORKSPACE_PERMISSION_WRITE = 'write';
     private const WORKSPACE_PERMISSION_MANAGE = 'manage';
 
     public function __construct(
-        private readonly ContentRepositoryId $contentRepositoryId,
-        private readonly UserService $userService,
-        private readonly ContentRepositoryAuthorizationService $authorizationService,
-        private readonly SecurityContext $securityContext,
+        private ContentRepositoryId $contentRepositoryId,
+        private UserService $userService,
+        private ContentRepositoryRegistry $contentRepositoryRegistry,
+        private ContentRepositoryAuthorizationService $authorizationService,
+        private SecurityContext $securityContext,
     ) {
     }
 
@@ -90,14 +94,11 @@ public function canExecuteCommand(CommandInterface $command): Privilege
             return Privilege::granted('Authorization checks are disabled');
         }
         if ($command instanceof SetNodeProperties) {
-            $nodePermissions = $this->getNodePermissionsForCurrentUser(
-                NodeAddress::create(
-                    $this->contentRepositoryId,
-                    $command->workspaceName,
-                    $command->originDimensionSpacePoint->toDimensionSpacePoint(),
-                    $command->nodeAggregateId,
-                )
-            );
+            $node = $this->getNode($command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId);
+            if ($node === null) {
+                return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $command->nodeAggregateId->value, $command->workspaceName->value));
+            }
+            $nodePermissions = $this->getNodePermissionsForCurrentUser($node);
             if (!$nodePermissions->edit) {
                 return Privilege::denied($nodePermissions->getReason());
             }
@@ -156,12 +157,20 @@ private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceN
         return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
     }
 
-    private function getNodePermissionsForCurrentUser(NodeAddress $nodeAddress): NodePermissions
+    private function getNodePermissionsForCurrentUser(Node $node): NodePermissions
     {
         $authenticatedAccount = $this->securityContext->getAccount();
         if ($authenticatedAccount === null) {
-            return $this->authorizationService->getNodePermissionsForAnonymousUser($nodeAddress);
+            return $this->authorizationService->getNodePermissionsForAnonymousUser($node);
         }
-        return $this->authorizationService->getNodePermissionsForAccount($nodeAddress, $authenticatedAccount);
+        return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount);
+    }
+
+    private function getNode(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint, NodeAggregateId $nodeAggregateId): ?Node
+    {
+        return $this->contentRepositoryRegistry->get($this->contentRepositoryId)
+            ->getContentGraph($workspaceName)
+            ->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
+            ->findNodeById($nodeAggregateId);
     }
 }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index 54bfc97294c..54db08a2c36 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -5,6 +5,7 @@
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Context as SecurityContext;
@@ -21,6 +22,7 @@
 {
     public function __construct(
         private UserService $userService,
+        private ContentRepositoryRegistry $contentRepositoryRegistry,
         private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService,
         private SecurityContext $securityContext,
     ) {
@@ -31,6 +33,6 @@ public function __construct(
      */
     public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider
     {
-        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryAuthorizationService, $this->securityContext);
+        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryRegistry, $this->contentRepositoryAuthorizationService, $this->securityContext);
     }
 }

From 7311ca9b35ff729f4c79c0fd33949fef74e4051d Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 30 Oct 2024 13:43:44 +0100
Subject: [PATCH 25/58] Revert `RebasableToOtherWorkspaceInterface` extension

and check against concrete command classes
---
 .../RebasableToOtherWorkspaceInterface.php    |  2 -
 .../Command/AddDimensionShineThrough.php      |  5 -
 .../Command/MoveDimensionSpacePoint.php       |  5 -
 ...gregateWithNodeAndSerializedProperties.php |  5 -
 .../Command/DisableNodeAggregate.php          |  5 -
 .../Command/EnableNodeAggregate.php           |  5 -
 .../Command/CopyNodesRecursively.php          |  5 -
 .../Command/SetSerializedNodeProperties.php   |  5 -
 .../NodeMove/Command/MoveNodeAggregate.php    |  5 -
 .../Command/SetSerializedNodeReferences.php   |  5 -
 .../Command/RemoveNodeAggregate.php           |  5 -
 .../Command/ChangeNodeAggregateName.php       |  5 -
 .../Command/ChangeNodeAggregateType.php       |  5 -
 .../Command/CreateNodeVariant.php             |  5 -
 .../CreateRootNodeAggregateWithNode.php       |  5 -
 .../UpdateRootNodeAggregateDimensions.php     |  5 -
 .../SubtreeTagging/Command/TagSubtree.php     |  5 -
 .../SubtreeTagging/Command/UntagSubtree.php   |  5 -
 .../ContentRepositoryAuthProvider.php         | 91 +++++++++++++------
 19 files changed, 61 insertions(+), 117 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
index 84c0540e372..c7b3d5cbc59 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Common/RebasableToOtherWorkspaceInterface.php
@@ -31,8 +31,6 @@ public function createCopyForWorkspace(
         WorkspaceName $targetWorkspaceName,
     ): self;
 
-    public function getWorkspaceName(): WorkspaceName;
-
     /**
      * called during deserialization from metadata
      * @param array<string,mixed> $array
diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
index e3143635b15..936505d278d 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/AddDimensionShineThrough.php
@@ -84,11 +84,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
-
     /**
      * @return array<string,\JsonSerializable>
      */
diff --git a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
index e6ae20eb2b5..498ba261e49 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/DimensionSpaceAdjustment/Command/MoveDimensionSpacePoint.php
@@ -80,11 +80,6 @@ public function createCopyForWorkspace(
         );
     }
 
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
-
     /**
      * @return array<string,\JsonSerializable>
      */
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
index fa3cac5be87..79f5412eaa5 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeCreation/Command/CreateNodeAggregateWithNodeAndSerializedProperties.php
@@ -176,9 +176,4 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
index bb153eb5f7e..f92f938df9c 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/DisableNodeAggregate.php
@@ -99,9 +99,4 @@ public function createCopyForWorkspace(
             $this->nodeVariantSelectionStrategy
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
index 41d70615b29..de0ad11d57d 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDisabling/Command/EnableNodeAggregate.php
@@ -99,9 +99,4 @@ public function createCopyForWorkspace(
             $this->nodeVariantSelectionStrategy
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
index 2f9be09c884..ba31a07c601 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeDuplication/Command/CopyNodesRecursively.php
@@ -183,9 +183,4 @@ public function createCopyForWorkspace(
             $this->nodeAggregateIdMapping
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
index 666b2fd2faa..a2d684f6035 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeModification/Command/SetSerializedNodeProperties.php
@@ -119,9 +119,4 @@ public function createCopyForWorkspace(
             $this->propertiesToUnset,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
index ca2fe213734..3a756163488 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeMove/Command/MoveNodeAggregate.php
@@ -131,9 +131,4 @@ public function createCopyForWorkspace(
             $this->newSucceedingSiblingNodeAggregateId
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
index 3b20699994a..1edf8409040 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeReferencing/Command/SetSerializedNodeReferences.php
@@ -107,9 +107,4 @@ public function createCopyForWorkspace(
             $this->references,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
index 654edc63dd5..085af255b8c 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRemoval/Command/RemoveNodeAggregate.php
@@ -119,9 +119,4 @@ public function createCopyForWorkspace(
             $this->removalAttachmentPoint,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
index 5df67f01181..38d1f195ed6 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeRenaming/Command/ChangeNodeAggregateName.php
@@ -93,9 +93,4 @@ public function createCopyForWorkspace(
             $this->newNodeName,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
index ede35595343..e979e4d25c1 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeTypeChange/Command/ChangeNodeAggregateType.php
@@ -118,9 +118,4 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
index 0aa0f248109..54873f489dc 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/NodeVariation/Command/CreateNodeVariant.php
@@ -97,9 +97,4 @@ public function createCopyForWorkspace(
             $this->targetOrigin,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
index b2fe51dc5c6..9bbb1f320cc 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/CreateRootNodeAggregateWithNode.php
@@ -135,9 +135,4 @@ public function createCopyForWorkspace(
             $this->tetheredDescendantNodeAggregateIds
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
index 489c0d96ca5..302c05ed895 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/RootNodeCreation/Command/UpdateRootNodeAggregateDimensions.php
@@ -78,9 +78,4 @@ public function createCopyForWorkspace(
             $this->nodeAggregateId,
         );
     }
-
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
 }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
index b77d199e155..d4d37c8b8cb 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/TagSubtree.php
@@ -89,11 +89,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
-
     public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool
     {
         return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId)
diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
index 7c2009b55d5..1ae9b4624a2 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Command/UntagSubtree.php
@@ -90,11 +90,6 @@ public function createCopyForWorkspace(WorkspaceName $targetWorkspaceName): self
         );
     }
 
-    public function getWorkspaceName(): WorkspaceName
-    {
-        return $this->workspaceName;
-    }
-
     public function matchesNodeId(NodeIdToPublishOrDiscard $nodeIdToPublish): bool
     {
         return $this->nodeAggregateId->equals($nodeIdToPublish->nodeAggregateId)
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index fc624112a4f..c12a78ba41d 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -5,15 +5,31 @@
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
-use Neos\ContentRepository\Core\ContentRepository;
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
 use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
+use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough;
+use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint;
+use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
+use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties;
+use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively;
 use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
 use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
+use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences;
+use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences;
+use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate;
+use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName;
+use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType;
+use Neos\ContentRepository\Core\Feature\NodeVariation\Command\CreateNodeVariant;
+use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\CreateRootNodeAggregateWithNode;
+use Neos\ContentRepository\Core\Feature\RootNodeCreation\Command\UpdateRootNodeAggregateDimensions;
 use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
 use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\TagSubtree;
+use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace;
@@ -93,48 +109,71 @@ public function canExecuteCommand(CommandInterface $command): Privilege
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
         }
-        if ($command instanceof SetNodeProperties) {
-            $node = $this->getNode($command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId);
+
+        /** @var NodeAddress|null $nodeThatRequiresEditPrivilege */
+        $nodeThatRequiresEditPrivilege = match ($command::class) {
+            CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId),
+            CreateNodeAggregateWithNode::class,
+            CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId),
+            CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId),
+            DisableNodeAggregate::class,
+            EnableNodeAggregate::class,
+            RemoveNodeAggregate::class,
+            TagSubtree::class,
+            UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId),
+            MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId),
+            SetNodeProperties::class,
+            SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId),
+            SetNodeReferences::class,
+            SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId),
+            default => null,
+        };
+        if ($nodeThatRequiresEditPrivilege !== null) {
+            $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName);
+            if ($workspacePermissions->write) {
+                return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason()));
+            }
+            $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId)
+                ->getContentGraph($nodeThatRequiresEditPrivilege->workspaceName)
+                ->getSubgraph($nodeThatRequiresEditPrivilege->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
+                ->findNodeById($nodeThatRequiresEditPrivilege->aggregateId);
             if ($node === null) {
-                return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $command->nodeAggregateId->value, $command->workspaceName->value));
+                return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value));
             }
             $nodePermissions = $this->getNodePermissionsForCurrentUser($node);
             if (!$nodePermissions->edit) {
-                return Privilege::denied($nodePermissions->getReason());
+                return Privilege::denied(sprintf('No edit permissions for node "%s" in workspace "%s": %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason()));
             }
-            return $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE);
+            return Privilege::granted(sprintf('Edit permissions for node "%s" in workspace "%s" granted: %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason()));
         }
-
-        // Note: We check against the {@see RebasableToOtherWorkspaceInterface} because that is implemented by all
-        // commands that interact with nodes on a content stream. With that it's likely that we don't have to adjust the
-        // code if we were to add new commands in the future
-        if ($command instanceof RebasableToOtherWorkspaceInterface) {
-            return $this->requireWorkspacePermission($command->getWorkspaceName(), self::WORKSPACE_PERMISSION_WRITE); // @phpstan-ignore-line
-        }
-
         if ($command instanceof CreateRootWorkspace) {
             return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks');
         }
-
         if ($command instanceof ChangeBaseWorkspace) {
             $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName);
             if (!$workspacePermissions->manage) {
-                return Privilege::denied("Missing 'manage' permissions for workspace '{$command->workspaceName->value}': {$workspacePermissions->getReason()}");
+                return Privilege::denied(sprintf('Missing "manage" permissions for workspace "%s": %s', $command->workspaceName->value, $workspacePermissions->getReason()));
             }
             $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName);
-            if (!$baseWorkspacePermissions->write) {
-                return Privilege::denied("Missing 'write' permissions for base workspace '{$command->baseWorkspaceName->value}': {$baseWorkspacePermissions->getReason()}");
+            if (!$baseWorkspacePermissions->read) {
+                return Privilege::denied(sprintf('Missing "read" permissions for base workspace "%s": %s', $command->baseWorkspaceName->value, $baseWorkspacePermissions->getReason()));
             }
-            return Privilege::granted("User has 'manage' permissions for workspace '{$command->workspaceName->value}' and 'write' permissions for base workspace '{$command->baseWorkspaceName->value}'");
+            return Privilege::granted(sprintf('User has "manage" permissions for workspace "%s" and "read" permissions for base workspace "%s"', $command->workspaceName->value, $command->baseWorkspaceName->value));
         }
         return match ($command::class) {
-            CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE),
-            DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE),
+            AddDimensionShineThrough::class,
+            ChangeNodeAggregateName::class,
+            ChangeNodeAggregateType::class,
+            CreateRootNodeAggregateWithNode::class,
+            MoveDimensionSpacePoint::class,
+            UpdateRootNodeAggregateDimensions::class,
             DiscardWorkspace::class,
             DiscardIndividualNodesFromWorkspace::class,
             PublishWorkspace::class,
             PublishIndividualNodesFromWorkspace::class,
             RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE),
+            CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE),
+            DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE),
             default => Privilege::granted('Command not restricted'),
         };
     }
@@ -165,12 +204,4 @@ private function getNodePermissionsForCurrentUser(Node $node): NodePermissions
         }
         return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount);
     }
-
-    private function getNode(WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint, NodeAggregateId $nodeAggregateId): ?Node
-    {
-        return $this->contentRepositoryRegistry->get($this->contentRepositoryId)
-            ->getContentGraph($workspaceName)
-            ->getSubgraph($dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
-            ->findNodeById($nodeAggregateId);
-    }
 }

From 5d17a70946937f35a9b1358f551db070f92b59e2 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 30 Oct 2024 16:46:54 +0100
Subject: [PATCH 26/58] Fix workspace permissions check

---
 .../ContentRepositoryAuthProvider.php                           | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index c12a78ba41d..d2ed098a3ed 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -130,7 +130,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege
         };
         if ($nodeThatRequiresEditPrivilege !== null) {
             $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName);
-            if ($workspacePermissions->write) {
+            if (!$workspacePermissions->write) {
                 return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason()));
             }
             $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId)

From 3c6ab69eee4599b1e985ee8c966adbcd237485da Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 5 Nov 2024 14:37:11 +0100
Subject: [PATCH 27/58] Remove `WorkspaceRoleSubject::__toString()`

because it led to [debates](https://github.com/neos/neos-development-collection/pull/5298#discussion_r1818140741)
---
 Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
index c9255bcfe5f..c53388bb23c 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php
@@ -50,9 +50,4 @@ public function equals(self $other): bool
     {
         return $this->type === $other->type && $this->value === $other->value;
     }
-
-    public function __toString(): string
-    {
-        return "{$this->type->value}: {$this->value}";
-    }
 }

From 0571abf6fec480ef08ae9352cf1875c8dfe3f91e Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 5 Nov 2024 14:55:56 +0100
Subject: [PATCH 28/58] Split
 `ContentRepositoryAuthProvider::requireWorkspaceWritePermission()` into two
 methods

---
 .../ContentRepositoryAuthProvider.php         | 26 ++++++++++++-------
 1 file changed, 16 insertions(+), 10 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index d2ed098a3ed..147a6136f9a 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -60,9 +60,6 @@
  */
 final readonly class ContentRepositoryAuthProvider implements AuthProviderInterface
 {
-    private const WORKSPACE_PERMISSION_WRITE = 'write';
-    private const WORKSPACE_PERMISSION_MANAGE = 'manage';
-
     public function __construct(
         private ContentRepositoryId $contentRepositoryId,
         private UserService $userService,
@@ -171,20 +168,29 @@ public function canExecuteCommand(CommandInterface $command): Privilege
             DiscardIndividualNodesFromWorkspace::class,
             PublishWorkspace::class,
             PublishIndividualNodesFromWorkspace::class,
-            RebaseWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_WRITE),
-            CreateWorkspace::class => $this->requireWorkspacePermission($command->baseWorkspaceName, self::WORKSPACE_PERMISSION_WRITE),
-            DeleteWorkspace::class => $this->requireWorkspacePermission($command->workspaceName, self::WORKSPACE_PERMISSION_MANAGE),
+            RebaseWorkspace::class => $this->requireWorkspaceWritePermission($command->workspaceName),
+            CreateWorkspace::class => $this->requireWorkspaceWritePermission($command->baseWorkspaceName),
+            DeleteWorkspace::class => $this->requireWorkspaceManagePermission($command->workspaceName),
             default => Privilege::granted('Command not restricted'),
         };
     }
 
-    private function requireWorkspacePermission(WorkspaceName $workspaceName, string $permission): Privilege
+    private function requireWorkspaceWritePermission(WorkspaceName $workspaceName): Privilege
+    {
+        $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName);
+        if (!$workspacePermissions->write) {
+            return Privilege::denied("Missing 'write' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}");
+        }
+        return Privilege::granted("User has 'write' permissions for workspace '{$workspaceName->value}'");
+    }
+
+    private function requireWorkspaceManagePermission(WorkspaceName $workspaceName): Privilege
     {
         $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName);
-        if (!$workspacePermissions->{$permission}) {
-            return Privilege::denied("Missing '{$permission}' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}");
+        if (!$workspacePermissions->manage) {
+            return Privilege::denied("Missing 'manage' permissions for workspace '{$workspaceName->value}': {$workspacePermissions->getReason()}");
         }
-        return Privilege::granted("User has '{$permission}' permissions for workspace '{$workspaceName->value}'");
+        return Privilege::granted("User has 'manage' permissions for workspace '{$workspaceName->value}'");
     }
 
     private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions

From b4e0c7829b61bc962986a898fcc10d929d0632a6 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Tue, 5 Nov 2024 17:58:22 +0100
Subject: [PATCH 29/58] Tweak AuthProvider wiring

---
 .../TestSuite/Behavior/FakeAuthProviderFactory.php    |  6 ++----
 .../Classes/Factory/ContentRepositoryFactory.php      |  7 ++++---
 .../Classes/Factory/ProjectionFactoryDependencies.php |  2 --
 .../Classes/ContentRepositoryRegistry.php             |  6 +++---
 .../AuthProvider/AuthProviderFactoryInterface.php     |  4 ++--
 .../AuthProvider/StaticAuthProviderFactory.php        |  4 ++--
 .../ContentRepositoryAuthProvider.php                 |  9 +++------
 .../ContentRepositoryAuthProviderFactory.php          | 11 ++++-------
 8 files changed, 20 insertions(+), 29 deletions(-)

diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
index f018a5ac958..f239fb410d7 100644
--- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
+++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
@@ -5,16 +5,14 @@
 namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior;
 
 use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 
 final class FakeAuthProviderFactory implements AuthProviderFactoryInterface
 {
-    /**
-     * @param array<string,mixed> $options
-     */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface
+    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface
     {
         return new FakeAuthProvider();
     }
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
index a55d94fe826..8517f51c1c3 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ContentRepositoryFactory.php
@@ -32,6 +32,7 @@
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
 use Neos\ContentRepository\Core\Projection\ProjectionsAndCatchUpHooks;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\EventStore\EventStoreInterface;
 use Psr\Clock\ClockInterface;
 use Symfony\Component\Serializer\Serializer;
@@ -53,7 +54,7 @@ public function __construct(
         ContentDimensionSourceInterface $contentDimensionSource,
         Serializer $propertySerializer,
         ProjectionsAndCatchUpHooksFactory $projectionsAndCatchUpHooksFactory,
-        private readonly AuthProviderInterface $authProvider,
+        private readonly AuthProviderFactoryInterface $authProviderFactory,
         private readonly ClockInterface $clock,
     ) {
         $contentDimensionZookeeper = new ContentDimensionZookeeper($contentDimensionSource);
@@ -70,7 +71,6 @@ public function __construct(
             $contentDimensionZookeeper,
             $interDimensionalVariationGraph,
             new PropertyConverter($propertySerializer),
-            $this->authProvider,
         );
         $this->projectionsAndCatchUpHooks = $projectionsAndCatchUpHooksFactory->build($this->projectionFactoryDependencies);
     }
@@ -128,6 +128,7 @@ public function getOrBuild(): ContentRepository
             )
         );
 
+        $authProvider = $this->authProviderFactory->build($this->contentRepositoryId, $contentGraphReadModel);
         return $this->contentRepository = new ContentRepository(
             $this->contentRepositoryId,
             $publicCommandBus,
@@ -138,7 +139,7 @@ public function getOrBuild(): ContentRepository
             $this->projectionFactoryDependencies->nodeTypeManager,
             $this->projectionFactoryDependencies->interDimensionalVariationGraph,
             $this->projectionFactoryDependencies->contentDimensionSource,
-            $this->authProvider,
+            $authProvider,
             $this->clock,
             $contentGraphReadModel
         );
diff --git a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
index 0b78e572cc4..9bb2f0cc31f 100644
--- a/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
+++ b/Neos.ContentRepository.Core/Classes/Factory/ProjectionFactoryDependencies.php
@@ -18,7 +18,6 @@
 use Neos\ContentRepository\Core\DimensionSpace\ContentDimensionZookeeper;
 use Neos\ContentRepository\Core\DimensionSpace\InterDimensionalVariationGraph;
 use Neos\ContentRepository\Core\EventStore\EventNormalizer;
-use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Infrastructure\Property\PropertyConverter;
 use Neos\ContentRepository\Core\NodeType\NodeTypeManager;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
@@ -38,7 +37,6 @@ public function __construct(
         public ContentDimensionZookeeper $contentDimensionZookeeper,
         public InterDimensionalVariationGraph $interDimensionalVariationGraph,
         public PropertyConverter $propertyConverter,
-        public AuthProviderInterface $authProvider,
     ) {
     }
 }
diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
index b5e952f4270..75b0b2ca151 100644
--- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
+++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php
@@ -175,7 +175,7 @@ private function buildFactory(ContentRepositoryId $contentRepositoryId): Content
                 $this->buildContentDimensionSource($contentRepositoryId, $contentRepositorySettings),
                 $this->buildPropertySerializer($contentRepositoryId, $contentRepositorySettings),
                 $this->buildProjectionsFactory($contentRepositoryId, $contentRepositorySettings),
-                $this->buildAuthProvider($contentRepositoryId, $contentRepositorySettings),
+                $this->buildAuthProviderFactory($contentRepositoryId, $contentRepositorySettings),
                 $clock
             );
         } catch (\Exception $exception) {
@@ -293,14 +293,14 @@ private function registerCatchupHookForProjection(mixed $projectionOptions, Proj
     }
 
     /** @param array<string, mixed> $contentRepositorySettings */
-    private function buildAuthProvider(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderInterface
+    private function buildAuthProviderFactory(ContentRepositoryId $contentRepositoryId, array $contentRepositorySettings): AuthProviderFactoryInterface
     {
         isset($contentRepositorySettings['authProvider']['factoryObjectName']) || throw InvalidConfigurationException::fromMessage('Content repository "%s" does not have authProvider.factoryObjectName configured.', $contentRepositoryId->value);
         $authProviderFactory = $this->objectManager->get($contentRepositorySettings['authProvider']['factoryObjectName']);
         if (!$authProviderFactory instanceof AuthProviderFactoryInterface) {
             throw InvalidConfigurationException::fromMessage('authProvider.factoryObjectName for content repository "%s" is not an instance of %s but %s.', $contentRepositoryId->value, AuthProviderFactoryInterface::class, get_debug_type($authProviderFactory));
         }
-        return $authProviderFactory->build($contentRepositoryId, $contentRepositorySettings['authProvider']['options'] ?? []);
+        return $authProviderFactory;
     }
 
     /** @param array<string, mixed> $contentRepositorySettings */
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
index 809efdf65db..5b8593b7954 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/AuthProviderFactoryInterface.php
@@ -5,6 +5,7 @@
 namespace Neos\ContentRepositoryRegistry\Factory\AuthProvider;
 
 use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 
 /**
@@ -12,6 +13,5 @@
  */
 interface AuthProviderFactoryInterface
 {
-    /** @param array<string, mixed> $options */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface;
+    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface;
 }
diff --git a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
index 870f2e5ace2..e8ff314c9a2 100644
--- a/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
+++ b/Neos.ContentRepositoryRegistry/Classes/Factory/AuthProvider/StaticAuthProviderFactory.php
@@ -5,6 +5,7 @@
 use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
 use Neos\ContentRepository\Core\Feature\Security\StaticAuthProvider;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 
 /**
@@ -12,8 +13,7 @@
  */
 final class StaticAuthProviderFactory implements AuthProviderFactoryInterface
 {
-    /** @param array<string, mixed> $options */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): AuthProviderInterface
+    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface
     {
         return new StaticAuthProvider(UserId::forSystemUser());
     }
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 147a6136f9a..c4bae9036a4 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -5,8 +5,6 @@
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
 use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
-use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint;
-use Neos\ContentRepository\Core\Feature\Common\RebasableToOtherWorkspaceInterface;
 use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough;
 use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint;
 use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
@@ -39,13 +37,12 @@
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
-use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Neos\Domain\Model\NodePermissions;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
@@ -63,7 +60,7 @@
     public function __construct(
         private ContentRepositoryId $contentRepositoryId,
         private UserService $userService,
-        private ContentRepositoryRegistry $contentRepositoryRegistry,
+        private ContentGraphReadModelInterface $contentGraphReadModel,
         private ContentRepositoryAuthorizationService $authorizationService,
         private SecurityContext $securityContext,
     ) {
@@ -130,7 +127,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege
             if (!$workspacePermissions->write) {
                 return Privilege::denied(sprintf('No write permissions on workspace "%s": %s', $nodeThatRequiresEditPrivilege->workspaceName->value, $workspacePermissions->getReason()));
             }
-            $node = $this->contentRepositoryRegistry->get($this->contentRepositoryId)
+            $node = $this->contentGraphReadModel
                 ->getContentGraph($nodeThatRequiresEditPrivilege->workspaceName)
                 ->getSubgraph($nodeThatRequiresEditPrivilege->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions())
                 ->findNodeById($nodeThatRequiresEditPrivilege->aggregateId);
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index 54db08a2c36..cf3d2fb9ac5 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -4,8 +4,9 @@
 
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Context as SecurityContext;
@@ -22,17 +23,13 @@
 {
     public function __construct(
         private UserService $userService,
-        private ContentRepositoryRegistry $contentRepositoryRegistry,
         private ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService,
         private SecurityContext $securityContext,
     ) {
     }
 
-    /**
-     * @param array<string, mixed> $options
-     */
-    public function build(ContentRepositoryId $contentRepositoryId, array $options): ContentRepositoryAuthProvider
+    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): ContentRepositoryAuthProvider
     {
-        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $this->contentRepositoryRegistry, $this->contentRepositoryAuthorizationService, $this->securityContext);
+        return new ContentRepositoryAuthProvider($contentRepositoryId, $this->userService, $contentGraphReadModel, $this->contentRepositoryAuthorizationService, $this->securityContext);
     }
 }

From 61848a2202cc2a83606230f288c1dab12718c700 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 11:19:42 +0100
Subject: [PATCH 30/58] Make `Privilege::reason` private

to be in sync with `WorkspacePermissions`
---
 Neos.ContentRepository.Core/Classes/ContentRepository.php  | 4 ++--
 .../Classes/Feature/Security/Dto/Privilege.php             | 7 ++++++-
 2 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/ContentRepository.php b/Neos.ContentRepository.Core/Classes/ContentRepository.php
index 90005f03169..3d584e056b7 100644
--- a/Neos.ContentRepository.Core/Classes/ContentRepository.php
+++ b/Neos.ContentRepository.Core/Classes/ContentRepository.php
@@ -101,7 +101,7 @@ public function handle(CommandInterface $command): void
     {
         $privilege = $this->authProvider->canExecuteCommand($command);
         if (!$privilege->granted) {
-            throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->reason);
+            throw AccessDenied::becauseCommandIsNotGranted($command, $privilege->getReason());
         }
         // the commands only calculate which events they want to have published, but do not do the
         // publishing themselves
@@ -250,7 +250,7 @@ public function getContentGraph(WorkspaceName $workspaceName): ContentGraphInter
     {
         $privilege = $this->authProvider->canReadNodesFromWorkspace($workspaceName);
         if (!$privilege->granted) {
-            throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->reason);
+            throw AccessDenied::becauseWorkspaceCantBeRead($workspaceName, $privilege->getReason());
         }
         return $this->contentGraphReadModel->getContentGraph($workspaceName);
     }
diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
index 0fcef1552fe..6712ab9279f 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
@@ -22,7 +22,7 @@
 {
     private function __construct(
         public bool $granted,
-        public string $reason,
+        private string $reason,
     ) {
     }
 
@@ -35,4 +35,9 @@ public static function denied(string $reason): self
     {
         return new self(false, $reason);
     }
+
+    public function getReason(): string
+    {
+        return $this->reason;
+    }
 }

From 643ce497df50c1f37ab711c35f81442639579c80 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 11:21:46 +0100
Subject: [PATCH 31/58] Remove `SubtreeTagPrivilegeSubject::__toString()`

---
 .../Privilege/SubtreeTagPrivilegeSubject.php           | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
index fb78ee75883..2a4c9eeb247 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/SubtreeTagPrivilegeSubject.php
@@ -14,7 +14,6 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
 use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
@@ -29,13 +28,4 @@ public function __construct(
         public ContentRepositoryId|null $contentRepositoryId = null,
     ) {
     }
-
-    public function __toString(): string
-    {
-        $label = 'tag' . ($this->subTreeTags->count() > 1 ? 's' : '') . ' "' . implode('", "', $this->subTreeTags->toStringArray()) . '"';
-        if ($this->contentRepositoryId !== null) {
-            $label .= ' in Content Repository "' . $this->contentRepositoryId->value . '"';
-        }
-        return $label;
-    }
 }

From da297ece279536aa9911711d55ffdfce2034c400 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 11:25:48 +0100
Subject: [PATCH 32/58] Replace PHP assert by phpstan assert in
 `AbstractSubtreeTagBasedPrivilege`

---
 .../Privilege/AbstractSubtreeTagBasedPrivilege.php              | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
index 8aaea9f1d35..ee5e41f0f66 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
@@ -30,6 +30,7 @@ abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege
     private SubtreeTags|null $subtreeTagsRuntimeCache = null;
     private ContentRepositoryId|null $contentRepositoryIdRuntimeCache = null;
 
+    /** @phpstan-assert !null $this->subtreeTagsRuntimeCache */
     private function initialize(): void
     {
         if ($this->initialized) {
@@ -66,7 +67,6 @@ public function matchesSubject(PrivilegeSubjectInterface $subject): bool
     public function getSubtreeTags(): SubtreeTags
     {
         $this->initialize();
-        assert($this->subtreeTagsRuntimeCache !== null);
         return $this->subtreeTagsRuntimeCache;
     }
 

From 187b80ea94840e5e75cc5162c22f6cd50738ec20 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 11:31:39 +0100
Subject: [PATCH 33/58] Add inline docs

---
 .../Privilege/AbstractSubtreeTagBasedPrivilege.php       | 4 ++--
 .../Authorization/Privilege/EditNodePrivilege.php        | 9 ++-------
 .../Authorization/Privilege/ReadNodePrivilege.php        | 9 ++-------
 3 files changed, 6 insertions(+), 16 deletions(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
index ee5e41f0f66..e42051d4bc9 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
@@ -14,7 +14,6 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
 use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTags;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
@@ -22,7 +21,8 @@
 use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
 
 /**
- * TODO docs
+ * Common base class for privileges that evaluate {@see SubtreeTagPrivilegeSubject}s
+ * @see ReadNodePrivilege, EditNodePrivilege
  */
 abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege
 {
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
index 52d94c0516b..83c9f53b88a 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/EditNodePrivilege.php
@@ -14,14 +14,9 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
-use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
-use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
-use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
-
 /**
- * TODO docs
+ * The privilege to edit any matching node in the Content Repository.
+ * This includes creation, setting properties or references, disabling/enabling, tagging and moving corresponding nodes
  */
 class EditNodePrivilege extends AbstractSubtreeTagBasedPrivilege
 {
diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php
index f96f1a0ddaa..dfbe9f138f3 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/ReadNodePrivilege.php
@@ -14,14 +14,9 @@
 
 namespace Neos\Neos\Security\Authorization\Privilege;
 
-use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag;
-use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege;
-use Neos\Flow\Security\Authorization\Privilege\PrivilegeSubjectInterface;
-use Neos\Flow\Security\Exception\InvalidPrivilegeTypeException;
-
 /**
- * TODO docs
+ * The privilege to read any matching node from the Content Repository.
+ * This includes all properties, references and metadata
  */
 class ReadNodePrivilege extends AbstractSubtreeTagBasedPrivilege
 {

From 41e6b57ba8917ef394e97e4ab511db37453032d6 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 11:37:18 +0100
Subject: [PATCH 34/58] Extract
 `ContentRepositoryAuthProvider::nodeThatRequiresEditPrivilegeForCommand()`

---
 .../ContentRepositoryAuthProvider.php         | 45 +++++++++++--------
 1 file changed, 26 insertions(+), 19 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index c4bae9036a4..95ff0a58ece 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -48,6 +48,7 @@
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
+use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege;
 
 /**
  * Implementation of Content Repository {@see AuthProviderInterface} which ties the authorization
@@ -103,25 +104,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
         }
-
-        /** @var NodeAddress|null $nodeThatRequiresEditPrivilege */
-        $nodeThatRequiresEditPrivilege = match ($command::class) {
-            CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId),
-            CreateNodeAggregateWithNode::class,
-            CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId),
-            CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId),
-            DisableNodeAggregate::class,
-            EnableNodeAggregate::class,
-            RemoveNodeAggregate::class,
-            TagSubtree::class,
-            UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId),
-            MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId),
-            SetNodeProperties::class,
-            SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId),
-            SetNodeReferences::class,
-            SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId),
-            default => null,
-        };
+        $nodeThatRequiresEditPrivilege = $this->nodeThatRequiresEditPrivilegeForCommand($command);
         if ($nodeThatRequiresEditPrivilege !== null) {
             $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($nodeThatRequiresEditPrivilege->workspaceName);
             if (!$workspacePermissions->write) {
@@ -172,6 +155,30 @@ public function canExecuteCommand(CommandInterface $command): Privilege
         };
     }
 
+    /**
+     * For a given command, determine the node (represented as {@see NodeAddress}) that needs {@see EditNodePrivilege} to be granted
+     */
+    private function nodeThatRequiresEditPrivilegeForCommand(CommandInterface $command): ?NodeAddress
+    {
+        return match ($command::class) {
+            CopyNodesRecursively::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->targetDimensionSpacePoint->toDimensionSpacePoint(), $command->targetParentNodeAggregateId),
+            CreateNodeAggregateWithNode::class,
+            CreateNodeAggregateWithNodeAndSerializedProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->parentNodeAggregateId),
+            CreateNodeVariant::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOrigin->toDimensionSpacePoint(), $command->nodeAggregateId),
+            DisableNodeAggregate::class,
+            EnableNodeAggregate::class,
+            RemoveNodeAggregate::class,
+            TagSubtree::class,
+            UntagSubtree::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->coveredDimensionSpacePoint, $command->nodeAggregateId),
+            MoveNodeAggregate::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->dimensionSpacePoint, $command->nodeAggregateId),
+            SetNodeProperties::class,
+            SetSerializedNodeProperties::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->originDimensionSpacePoint->toDimensionSpacePoint(), $command->nodeAggregateId),
+            SetNodeReferences::class,
+            SetSerializedNodeReferences::class => NodeAddress::create($this->contentRepositoryId, $command->workspaceName, $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), $command->sourceNodeAggregateId),
+            default => null,
+        };
+    }
+
     private function requireWorkspaceWritePermission(WorkspaceName $workspaceName): Privilege
     {
         $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($workspaceName);

From 7c64c2c6c3f9c4a12cb1104ddd8f74645bfd5115 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 6 Nov 2024 16:50:45 +0100
Subject: [PATCH 35/58] TASK: Tests for "Content Repository Privileges"

Related: #3732
Related: #5298
---
 ...ory.php => TestingAuthProviderFactory.php} |   9 +-
 .../Settings.ContentRepositoryRegistry.yaml   |   2 +-
 .../Bootstrap/CRTestSuiteRuntimeVariables.php |   4 +-
 .../Bootstrap/Helpers/FakeAuthProvider.php    |  42 ----
 .../Bootstrap/Helpers/TestingAuthProvider.php |  70 ++++++
 .../ContentRepositorySecurityTrait.php        | 206 ++++++++++++++++++
 .../Features/Bootstrap/ExceptionsTrait.php    |   9 +
 .../Features/Bootstrap/FeatureContext.php     |   1 +
 .../Features/Bootstrap/UserServiceTrait.php   |   2 +
 .../ContentRepository/Security.feature        |  75 +++++++
 .../Security/NodeTreePrivilege.__feature      | 148 -------------
 11 files changed, 370 insertions(+), 198 deletions(-)
 rename Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/{FakeAuthProviderFactory.php => TestingAuthProviderFactory.php} (63%)
 delete mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
 create mode 100644 Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/TestingAuthProvider.php
 create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
 create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature
 delete mode 100644 Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature

diff --git a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php
similarity index 63%
rename from Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
rename to Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php
index f239fb410d7..0fb820d5f03 100644
--- a/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/FakeAuthProviderFactory.php
+++ b/Neos.ContentRepository.BehavioralTests/Classes/TestSuite/Behavior/TestingAuthProviderFactory.php
@@ -4,16 +4,15 @@
 
 namespace Neos\ContentRepository\BehavioralTests\TestSuite\Behavior;
 
-use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
-use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;
 
-final class FakeAuthProviderFactory implements AuthProviderFactoryInterface
+final class TestingAuthProviderFactory implements AuthProviderFactoryInterface
 {
-    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): AuthProviderInterface
+    public function build(ContentRepositoryId $contentRepositoryId, ContentGraphReadModelInterface $contentGraphReadModel): TestingAuthProvider
     {
-        return new FakeAuthProvider();
+        return new TestingAuthProvider();
     }
 }
diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
index 74387df65ff..c7bd8303a73 100644
--- a/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
+++ b/Neos.ContentRepository.BehavioralTests/Configuration/Testing/Behat/Settings.ContentRepositoryRegistry.yaml
@@ -3,7 +3,7 @@ Neos:
     presets:
       default:
         authProvider:
-          factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeAuthProviderFactory'
+          factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\TestingAuthProviderFactory'
         clock:
           factoryObjectName: 'Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\FakeClockFactory'
         nodeTypeManager:
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
index b6ca95a29b6..d8ff69457b2 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/CRTestSuiteRuntimeVariables.php
@@ -26,7 +26,7 @@
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeAuthProvider;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\FakeClock;
 
 /**
@@ -73,7 +73,7 @@ abstract protected function getContentRepository(ContentRepositoryId $id): Conte
      */
     public function iAmUserIdentifiedBy(string $userId): void
     {
-        FakeAuthProvider::setUserId(UserId::fromString($userId));
+        TestingAuthProvider::setDefaultUserId(UserId::fromString($userId));
     }
 
     /**
diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
deleted file mode 100644
index 916e98ffee3..00000000000
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeAuthProvider.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
-
-use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
-use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
-use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
-use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
-use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
-use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-
-final class FakeAuthProvider implements AuthProviderInterface
-{
-    public static ?UserId $userId = null;
-
-    public static function setUserId(UserId $userId): void
-    {
-        self::$userId = $userId;
-    }
-
-    public function getAuthenticatedUserId(): ?UserId
-    {
-        return self::$userId ?? null;
-    }
-
-    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
-    {
-        return VisibilityConstraints::withoutRestrictions();
-    }
-
-    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.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/TestingAuthProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/TestingAuthProvider.php
new file mode 100644
index 00000000000..604402b7bf6
--- /dev/null
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/TestingAuthProvider.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers;
+
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
+use Neos\ContentRepository\Core\Feature\Security\AuthProviderInterface;
+use Neos\ContentRepository\Core\Feature\Security\Dto\Privilege;
+use Neos\ContentRepository\Core\Feature\Security\Dto\UserId;
+use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProvider;
+
+/**
+ * Content Repository AuthProvider implementation for tests
+ * This is a mutable class in order to allow to replace the implementation during a request, e.g. for behat tests
+ */
+final class TestingAuthProvider implements AuthProviderInterface
+{
+    private static ?UserId $userId = null;
+    private static ?ContentRepositoryAuthProvider $contentRepositoryAuthProvider = null;
+
+    public static function setDefaultUserId(UserId $userId): void
+    {
+        self::$userId = $userId;
+    }
+
+    public static function replaceAuthProvider(ContentRepositoryAuthProvider $contentRepositoryAuthProvider): void
+    {
+        self::$contentRepositoryAuthProvider = $contentRepositoryAuthProvider;
+    }
+
+    public static function resetAuthProvider(): void
+    {
+        self::$contentRepositoryAuthProvider = null;
+    }
+
+    public function getAuthenticatedUserId(): ?UserId
+    {
+        if (self::$contentRepositoryAuthProvider !== null) {
+            return self::$contentRepositoryAuthProvider->getAuthenticatedUserId();
+        }
+        return self::$userId ?? null;
+    }
+
+    public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
+    {
+        if (self::$contentRepositoryAuthProvider !== null) {
+            return self::$contentRepositoryAuthProvider->getVisibilityConstraints($workspaceName);
+        }
+        return VisibilityConstraints::withoutRestrictions();
+    }
+
+    public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege
+    {
+        if (self::$contentRepositoryAuthProvider !== null) {
+            return self::$contentRepositoryAuthProvider->canReadNodesFromWorkspace($workspaceName);
+        }
+        return Privilege::granted(self::class . ' always grants privileges');
+    }
+
+    public function canExecuteCommand(CommandInterface $command): Privilege
+    {
+        if (self::$contentRepositoryAuthProvider !== null) {
+            return self::$contentRepositoryAuthProvider->canExecuteCommand($command);
+        }
+        return Privilege::granted(self::class . ' always grants privileges');
+    }
+}
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
new file mode 100644
index 00000000000..6d950404a80
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -0,0 +1,206 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+use Behat\Gherkin\Node\PyStringNode;
+use Behat\Hook\AfterFeature;
+use Behat\Hook\BeforeFeature;
+use Behat\Hook\BeforeScenario;
+use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider;
+use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies;
+use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryInterface;
+use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceInterface;
+use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface;
+use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider;
+use Neos\Flow\Configuration\ConfigurationManager;
+use Neos\Flow\Mvc\ActionRequest;
+use Neos\Flow\Security\Account;
+use Neos\Flow\Security\Authentication\AuthenticationProviderManager;
+use Neos\Flow\Security\Authentication\Provider\TestingProvider;
+use Neos\Flow\Security\Authentication\TokenAndProviderFactoryInterface;
+use Neos\Flow\Security\Authentication\TokenInterface;
+use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Flow\Security\Policy\PolicyService;
+use Neos\Flow\Utility\Environment;
+use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
+use Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory;
+use Neos\Utility\Arrays;
+use Neos\Utility\ObjectAccess;
+use PHPUnit\Framework\Assert;
+use Psr\Http\Message\ServerRequestFactoryInterface;
+use Symfony\Component\Yaml\Yaml;
+
+/**
+ * Step implementations and helper for Content Repository Security related tests inside Neos.Neos
+ *
+ * @internal only for behat tests within the Neos.Neos package
+ */
+trait ContentRepositorySecurityTrait
+{
+    use CRBehavioralTestsSubjectProvider;
+    use ExceptionsTrait;
+
+    private bool $flowSecurityEnabled = false;
+    private bool $contentRepositorySecurityEnabled = false;
+
+    private ?TestingProvider $testingProvider = null;
+
+    private ?ActionRequest $mockActionRequest = null;
+
+    private static ?string $testingPolicyPathAndFilename = null;
+
+    /**
+     * @template T of object
+     * @param class-string<T> $className
+     * @return T
+     */
+    abstract private function getObject(string $className): object;
+
+    #[BeforeScenario]
+    public function resetContentRepositorySecurity(): void
+    {
+        TestingAuthProvider::resetAuthProvider();
+        $this->contentRepositorySecurityEnabled = false;
+    }
+
+    #[BeforeFeature]
+    #[AfterFeature]
+    public static function resetPolicies(): void
+    {
+        if (self::$testingPolicyPathAndFilename !== null && file_exists(self::$testingPolicyPathAndFilename)) {
+            unlink(self::$testingPolicyPathAndFilename);
+        }
+    }
+
+    private function enableFlowSecurity(): void
+    {
+        if ($this->flowSecurityEnabled === true) {
+            return;
+        }
+        $this->getObject(PrivilegeManagerInterface::class)->reset();
+
+        $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class);
+
+        $this->testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider'];
+
+        $securityContext = $this->getObject(SecurityContext::class);
+        $securityContext->clearContext();
+        $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/');
+        $this->mockActionRequest = ActionRequest::fromHttpRequest($httpRequest);
+        $securityContext->setRequest($this->mockActionRequest);
+        $this->flowSecurityEnabled = true;
+    }
+
+    private function enableContentRepositorySecurity(): void
+    {
+        if ($this->contentRepositorySecurityEnabled === true) {
+            return;
+        }
+        $contentRepositoryAuthProviderFactory = $this->getObject(ContentRepositoryAuthProviderFactory::class);
+        $contentGraphProjection = $this->getContentRepositoryService(new class implements ContentRepositoryServiceFactoryInterface {
+            public function build(ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies): ContentRepositoryServiceInterface
+            {
+                $contentGraphProjection = $serviceFactoryDependencies->projectionsAndCatchUpHooks->contentGraphProjection;
+                return new class ($contentGraphProjection) implements ContentRepositoryServiceInterface {
+                    public function __construct(
+                        public ContentGraphProjectionInterface $contentGraphProjection,
+                    ) {
+                    }
+                };
+            }
+        })->contentGraphProjection;
+        $contentRepositoryAuthProvider = $contentRepositoryAuthProviderFactory->build($this->currentContentRepository->id, $contentGraphProjection->getState());
+
+        TestingAuthProvider::replaceAuthProvider($contentRepositoryAuthProvider);
+        $this->contentRepositorySecurityEnabled = true;
+    }
+
+    private function authenticateAccount(Account $account): void
+    {
+        $this->enableFlowSecurity();
+        $this->testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
+        $this->testingProvider->setAccount($account);
+
+        $securityContext = $this->getObject(SecurityContext::class);
+        $securityContext->clearContext();
+        $securityContext->setRequest($this->mockActionRequest);
+        $this->getObject(AuthenticationProviderManager::class)->authenticate();
+    }
+
+    /**
+     * @Given content repository security is enabled
+     */
+    public function contentRepositorySecurityIsEnabled(): void
+    {
+        $this->enableContentRepositorySecurity();
+    }
+
+
+    /**
+     * @Given The following additional policies are configured:
+     */
+    public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void
+    {
+        $policyService = $this->getObject(PolicyService::class);
+        $policyService->getRoles(); // force initialization
+        $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true);
+        $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw()));
+
+        self::$testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml';
+        file_put_contents(self::$testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration));
+
+        ObjectAccess::setProperty($policyService, 'initialized', false, true);
+        $this->getObject(ConfigurationManager::class)->flushConfigurationCache();
+    }
+
+    /**
+     * @When the user :username accesses the content graph for workspace :workspaceName
+     */
+    public function theUserAccessesTheContentGraphForWorkspace(string $username, string $workspaceName): void
+    {
+        $this->enableContentRepositorySecurity();
+        $user = $this->getObject(UserService::class)->getUser($username);
+        $this->authenticateAccount($user->getAccounts()->first());
+        $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName)));
+    }
+
+    /**
+     * @Then The user :username should not be able to read node :nodeAggregateId
+     */
+    public function theUserShouldNotBeAbleToReadNode(string $username, string $nodeAggregateId): void
+    {
+        $user = $this->getObject(UserService::class)->getUser($username);
+        $this->authenticateAccount($user->getAccounts()->first());
+        $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
+        if ($node !== null) {
+            Assert::fail(sprintf('Expected node "%s" to be inaccessible to user "%s" but it was loaded', $nodeAggregateId, $username));
+        }
+    }
+
+    /**
+     * @Then The user :username should be able to read node :nodeAggregateId
+     */
+    public function theUserShouldBeAbleToReadNode(string $username, string $nodeAggregateId): void
+    {
+        $user = $this->getObject(UserService::class)->getUser($username);
+        $this->authenticateAccount($user->getAccounts()->first());
+        $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
+        if ($node === null) {
+            Assert::fail(sprintf('Expected node "%s" to be accessible to user "%s" but it could not be loaded', $nodeAggregateId, $username));
+        }
+    }
+}
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
index fabfcd12608..228d4594bdc 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
@@ -46,6 +46,15 @@ public function anExceptionShouldBeThrown(string $exceptionMessage): void
         $this->lastCaughtException = null;
     }
 
+    /**
+     * @Then no exception should be thrown
+     */
+    public function noExceptionShouldBeThrown(): void
+    {
+        Assert::assertNull($this->lastCaughtException, 'Expected no exception but one was thrown');
+        $this->lastCaughtException = null;
+    }
+
     /**
      * @BeforeScenario
      * @AfterScenario
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php
index 9d06ce491eb..a522afdd352 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php
@@ -46,6 +46,7 @@ class FeatureContext implements BehatContext
     use AssetTrait;
 
     use WorkspaceServiceTrait;
+    use ContentRepositorySecurityTrait;
     use UserServiceTrait;
 
     protected Environment $environment;
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
index 4d8d153f64e..4c4118f8f57 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
@@ -16,8 +16,10 @@
 use Neos\Flow\Persistence\PersistenceManagerInterface;
 use Neos\Flow\Security\AccountFactory;
 use Neos\Flow\Security\Cryptography\HashService;
+use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Service\UserService;
+use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
 use Neos\Party\Domain\Model\PersonName;
 use Neos\Utility\ObjectAccess;
 
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature
new file mode 100644
index 00000000000..9b4e721c733
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature
@@ -0,0 +1,75 @@
+@flowEntities
+Feature: TODO
+
+  Background:
+    Given The following additional policies are configured:
+      """
+      privilegeTargets:
+        'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege':
+          'Neos.Neos:ReadBlog':
+            matcher: 'blog'
+      roles:
+
+        'Neos.Neos:Administrator':
+          privileges:
+            - privilegeTarget: 'Neos.Neos:ReadBlog'
+              permission: GRANT
+      """
+    And using no content dimensions
+    And using the following node types:
+    """yaml
+    'Neos.ContentRepository.Testing:Document': {}
+    """
+    And using identifier "default", I define a content repository
+    And I am in content repository "default"
+    And I am user identified by "initiating-user-identifier"
+    And the command CreateRootWorkspace is executed with payload:
+      | Key                | Value           |
+      | workspaceName      | "live"          |
+      | newContentStreamId | "cs-identifier" |
+    And I am in workspace "live" and dimension space point {}
+    And the command CreateRootNodeAggregateWithNode is executed with payload:
+      | Key             | Value                         |
+      | nodeAggregateId | "root"                        |
+      | nodeTypeName    | "Neos.ContentRepository:Root" |
+    And the following CreateNodeAggregateWithNode commands are executed:
+      | nodeAggregateId | nodeTypeName                            | parentNodeAggregateId | nodeName |
+      | a               | Neos.ContentRepository.Testing:Document | root                  | a        |
+      | a1              | Neos.ContentRepository.Testing:Document | a                     | a1       |
+      | a1a             | Neos.ContentRepository.Testing:Document | a1                    | a1a      |
+      | a1a1            | Neos.ContentRepository.Testing:Document | a1a                   | a1a1     |
+      | a1a1a           | Neos.ContentRepository.Testing:Document | a1a1                  | a1a1a    |
+      | a1a1b           | Neos.ContentRepository.Testing:Document | a1a1                  | a1a1b    |
+      | a1a2            | Neos.ContentRepository.Testing:Document | a1a                   | a1a2     |
+      | a1b             | Neos.ContentRepository.Testing:Document | a1                    | a1b      |
+      | a2              | Neos.ContentRepository.Testing:Document | a                     | a2       |
+      | b               | Neos.ContentRepository.Testing:Document | root                  | b        |
+      | b1              | Neos.ContentRepository.Testing:Document | b                     | b1       |
+    And the following Neos users exist:
+      | Id      | Username | First name | Last name | Roles                                            |
+      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
+      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
+      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
+
+  Scenario: Access content graph for root workspace without role assignments
+    Given I am in workspace "live"
+    And the command TagSubtree is executed with payload:
+      | Key                          | Value                |
+      | nodeAggregateId              | "a"                  |
+      | nodeVariantSelectionStrategy | "allSpecializations" |
+      | tag                          | "blog"               |
+    And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody"
+    When content repository security is enabled
+    Then The user "john.doe" should not be able to read node "a1"
+    Then The user "jane.doe" should be able to read node "a1"
+
+  Scenario: TODO
+    When content repository security is enabled
+    And the user "jane.doe" accesses the content graph for workspace "live"
+    Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown
+
+  Scenario: TODO
+    Given the role MANAGER is assigned to workspace "live" for user "janedoe"
+    When content repository security is enabled
+    And the user "jane.doe" accesses the content graph for workspace "live"
+    Then no exception should be thrown
diff --git a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature b/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature
deleted file mode 100644
index 2fa92e93581..00000000000
--- a/Neos.Neos/Tests/Behavior/Features/Security/NodeTreePrivilege.__feature
+++ /dev/null
@@ -1,148 +0,0 @@
-# TODO rewrite test after https://github.com/neos/neos-development-collection/issues/3732
-
-Feature: Privilege to restrict nodes shown in the node tree
-
-  Background:
-    Given I have the following policies:
-      """
-      privilegeTargets:
-
-        'Neos\Neos\Security\Authorization\Privilege\NodeTreePrivilege':
-          'Neos.ContentRepository:CompanySubtree':
-            matcher: 'isDescendantNodeOf("/sites/content-repository/company")'
-          'Neos.ContentRepository:ServiceSubtree':
-            matcher: 'isDescendantNodeOf("/sites/content-repository/service")'
-
-          'Neos.ContentRepository:NeosSite':
-            matcher: 'isDescendantNodeOf("/sites/neos")'
-          'Neos.ContentRepository:NeosTeams':
-            matcher: 'isAncestorOrDescendantNodeOf("/sites/neos/community/teams")'
-
-        'Neos\ContentRepository\Security\Authorization\Privilege\Node\EditNodePrivilege':
-          'Neos.ContentRepository:EditNeosTeamsPath':
-            matcher: 'isAncestorNodeOf("/sites/neos/community/teams")'
-
-      roles:
-        'Neos.Flow:Everybody':
-          privileges: []
-
-        'Neos.Flow:Anonymous':
-          privileges: []
-
-        'Neos.Flow:AuthenticatedUser':
-          privileges: []
-
-        'Neos.Neos:Editor':
-          privileges:
-            -
-              privilegeTarget: 'Neos.ContentRepository:CompanySubtree'
-              permission: GRANT
-
-        'Neos.Neos:Administrator':
-          parentRoles: ['Neos.Neos:Editor']
-          privileges:
-            -
-              privilegeTarget: 'Neos.ContentRepository:ServiceSubtree'
-              permission: GRANT
-            -
-              privilegeTarget: 'Neos.ContentRepository:NeosTeams'
-              permission: GRANT
-            -
-              privilegeTarget: 'Neos.ContentRepository:EditNeosTeamsPath'
-              permission: DENY
-
-      """
-
-    And I have the following nodes:
-      | Identifier                           | Path                              | Node Type                      | Properties              | Workspace |
-      | ecf40ad1-3119-0a43-d02e-55f8b5aa3c70 | /sites                            | unstructured                   |                         | live      |
-      | fd5ba6e1-4313-b145-1004-dad2f1173a35 | /sites/content-repository                    | Neos.ContentRepository.Testing:Document | {"title": "Home"}       | live      |
-      | 68ca0dcd-2afb-ef0e-1106-a5301e65b8a0 | /sites/content-repository/company            | Neos.ContentRepository.Testing:Document | {"title": "Company"}    | live      |
-      | 52540602-b417-11e3-9358-14109fd7a2dd | /sites/content-repository/service            | Neos.ContentRepository.Testing:Document | {"title": "Service"}    | live      |
-      | 3223481d-e11c-4db7-95de-b371411a2431 | /sites/content-repository/service/newsletter | Neos.ContentRepository.Testing:Document | {"title": "Newsletter"} | live      |
-      | 544e14a3-b21d-429a-9fdd-cbeccc8d2b0f | /sites/content-repository/about-us           | Neos.ContentRepository.Testing:Document | {"title": "About us"}   | live      |
-      | 56217c92-07e9-4554-ac35-03f86d278870 | /sites/neos                       | Neos.ContentRepository.Testing:Document | {"title": "Neos"}       | live      |
-      | 4be072fe-0738-4892-8a27-342a6ac96075 | /sites/neos/community             | Neos.ContentRepository.Testing:Document | {"title": "Community"}  | live      |
-      | c56d66e7-9c55-4eef-a2b1-c263b3261996 | /sites/neos/community/teams       | Neos.ContentRepository.Testing:Document | {"title": "Teams"}      | live      |
-      | 07902b2e-61d9-4ce4-9b90-1cf338830d2f | /sites/neos/community/teams/member| Neos.ContentRepository.Testing:Document | {"title": "Johannes"}   | live      |
-
-  @Isolated @fixtures
-  Scenario: Editors are granted to set properties on company node
-    Given I am authenticated with role "Neos.Neos:Editor"
-    And I get a node by path "/sites/content-repository/company" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should be granted to set the "title" property to "The company"
-    And I should get true when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Editors are not granted to set properties on service node
-    Given I am authenticated with role "Neos.Neos:Editor"
-    And I get a node by path "/sites/content-repository/service" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should not be granted to set the "title" property to "Our services"
-    And I should get false when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Editors are not granted to set properties on service sub node
-    Given I am authenticated with role "Neos.Neos:Editor"
-    And I get a node by path "/sites/content-repository/service/newsletter" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should not be granted to set the "title" property to "Our newsletter"
-    And I should get false when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Administrators are granted to set properties on company node
-    Given I am authenticated with role "Neos.Neos:Administrator"
-    And I get a node by path "/sites/content-repository/company" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should be granted to set the "title" property to "The company"
-    And I should get true when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Administrators are granted to set properties on service node
-    Given I am authenticated with role "Neos.Neos:Administrator"
-    And I get a node by path "/sites/content-repository/service" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should be granted to set the "title" property to "Our services"
-    And I should get true when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Administrators are granted to set properties on service sub node
-    Given I am authenticated with role "Neos.Neos:Administrator"
-    And I get a node by path "/sites/content-repository/service/newsletter" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should be granted to set the "title" property to "Our newsletter"
-    And I should get true when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Editors are not granted to set properties on a neos sub node
-    Given I am authenticated with role "Neos.Neos:Editor"
-    And I get a node by path "/sites/neos/community/teams" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should not be granted to set the "title" property to "The Teams"
-    And I should get false when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Administrators are granted to set properties on a neos sub node
-    Given I am authenticated with role "Neos.Neos:Administrator"
-    And I get a node by path "/sites/neos/community/teams/member" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should be granted to set the "title" property to "Basti"
-    And I should get true when asking the node authorization service if editing this node is granted
-
-  @Isolated @fixtures
-  Scenario: Administrators are not granted to set properties on an ancestor node of teams
-    Given I am authenticated with role "Neos.Neos:Administrator"
-    And I get a node by path "/sites/neos/community" with the following context:
-      | Workspace  |
-      | user-admin |
-    Then I should not be granted to set the "title" property to "The Community"
-    And I should get false when asking the node authorization service if editing this node is granted

From dec5667d4d6fce4974a45689879cc71ea63630ed Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Thu, 7 Nov 2024 08:38:40 +0100
Subject: [PATCH 36/58] moar tests

---
 .../ContentRepositorySecurityTrait.php        | 30 +++++----
 .../Bootstrap/WorkspaceServiceTrait.php       | 31 +++++++--
 .../Security/EditNodePrivilege.feature        | 65 ++++++++++++++++++
 .../ReadNodePrivilege.feature}                | 53 +++++++--------
 .../Security/WorkspaceAccess.feature          | 66 +++++++++++++++++++
 5 files changed, 196 insertions(+), 49 deletions(-)
 create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
 rename Neos.Neos/Tests/Behavior/Features/ContentRepository/{Security.feature => Security/ReadNodePrivilege.feature} (51%)
 create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
index 6d950404a80..76c71de0e68 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -146,6 +146,7 @@ private function authenticateAccount(Account $account): void
      */
     public function contentRepositorySecurityIsEnabled(): void
     {
+        $this->enableFlowSecurity();
         $this->enableContentRepositorySecurity();
     }
 
@@ -168,39 +169,42 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici
     }
 
     /**
-     * @When the user :username accesses the content graph for workspace :workspaceName
+     * @When the user :username is authenticated
      */
-    public function theUserAccessesTheContentGraphForWorkspace(string $username, string $workspaceName): void
+    public function theUserIsAuthenticated(string $username): void
     {
-        $this->enableContentRepositorySecurity();
         $user = $this->getObject(UserService::class)->getUser($username);
         $this->authenticateAccount($user->getAccounts()->first());
+    }
+
+
+    /**
+     * @When the current user accesses the content graph for workspace :workspaceName
+     */
+    public function theCurrentUserAccessesTheContentGraphForWorkspace(string $workspaceName): void
+    {
         $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName)));
     }
 
     /**
-     * @Then The user :username should not be able to read node :nodeAggregateId
+     * @Then The current user should not be able to read node :nodeAggregateId
      */
-    public function theUserShouldNotBeAbleToReadNode(string $username, string $nodeAggregateId): void
+    public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId): void
     {
-        $user = $this->getObject(UserService::class)->getUser($username);
-        $this->authenticateAccount($user->getAccounts()->first());
         $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
         if ($node !== null) {
-            Assert::fail(sprintf('Expected node "%s" to be inaccessible to user "%s" but it was loaded', $nodeAggregateId, $username));
+            Assert::fail(sprintf('Expected node "%s" to be inaccessible but it was loaded', $nodeAggregateId));
         }
     }
 
     /**
-     * @Then The user :username should be able to read node :nodeAggregateId
+     * @Then The current user should be able to read node :nodeAggregateId
      */
-    public function theUserShouldBeAbleToReadNode(string $username, string $nodeAggregateId): void
+    public function theCurrentUserShouldBeAbleToReadNode(string $nodeAggregateId): void
     {
-        $user = $this->getObject(UserService::class)->getUser($username);
-        $this->authenticateAccount($user->getAccounts()->first());
         $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
         if ($node === null) {
-            Assert::fail(sprintf('Expected node "%s" to be accessible to user "%s" but it could not be loaded', $nodeAggregateId, $username));
+            Assert::fail(sprintf('Expected node "%s" to be accessible but it could not be loaded', $nodeAggregateId));
         }
     }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 416b6d4a8d3..28fbdba1a00 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -14,10 +14,12 @@
 
 use Behat\Gherkin\Node\TableNode;
 use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider;
+use Neos\ContentRepository\Core\CommandHandler\CommandInterface;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Reflection\ReflectionService;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceDescription;
 use Neos\Neos\Domain\Model\WorkspaceRole;
@@ -166,15 +168,20 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table
 
     /**
      * @When the role :role is assigned to workspace :workspaceName for group :groupName
-     * @When the role :role is assigned to workspace :workspaceName for user :userId
+     * @When the role :role is assigned to workspace :workspaceName for user :username
      */
-    public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void
+    public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void
     {
+        if ($groupName !== null) {
+            $subject = WorkspaceRoleSubject::createForGroup($groupName);
+        } else {
+            $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username));
+        }
         $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
             WorkspaceRoleAssignment::create(
-                $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
+                $subject,
                 WorkspaceRole::from($role)
             )
         ));
@@ -182,14 +189,19 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string
 
     /**
      * @When the role for group :groupName is unassigned from workspace :workspaceName
-     * @When the role for user :userId is unassigned from workspace :workspaceName
+     * @When the role for user :username is unassigned from workspace :workspaceName
      */
-    public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void
+    public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void
     {
+        if ($groupName !== null) {
+            $subject = WorkspaceRoleSubject::createForGroup($groupName);
+        } else {
+            $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username));
+        }
         $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
+            $subject,
         ));
     }
 
@@ -242,4 +254,11 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username,
         Assert::assertFalse($permissions->write);
         Assert::assertFalse($permissions->manage);
     }
+
+    private function userIdForUsername(string $username): UserId
+    {
+        $user = $this->getObject(UserService::class)->getUser($username);
+        Assert::assertNotNull($user);
+        return $user->getId();
+    }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
new file mode 100644
index 00000000000..3f86ee05114
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
@@ -0,0 +1,65 @@
+@flowEntities
+Feature: EditNodePrivilege related features
+
+  Background:
+    Given The following additional policies are configured:
+      """
+      privilegeTargets:
+        'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege':
+          'Neos.Neos:EditBlog':
+            matcher: 'blog'
+      """
+    And using the following content dimensions:
+      | Identifier | Values                | Generalizations                     |
+      | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
+    And using the following node types:
+    """yaml
+    'Neos.Neos:Document': {}
+    """
+    And using identifier "default", I define a content repository
+    And I am in content repository "default"
+    And I am user identified by "initiating-user-identifier"
+    And the command CreateRootWorkspace is executed with payload:
+      | Key                | Value           |
+      | workspaceName      | "live"          |
+      | newContentStreamId | "cs-identifier" |
+    And I am in workspace "live" and dimension space point {}
+    And the command CreateRootNodeAggregateWithNode is executed with payload:
+      | Key             | Value                         |
+      | nodeAggregateId | "root"                        |
+      | nodeTypeName    | "Neos.ContentRepository:Root" |
+    And the following CreateNodeAggregateWithNode commands are executed:
+      | nodeAggregateId | nodeTypeName       | parentNodeAggregateId | nodeName | originDimensionSpacePoint |
+      | a               | Neos.Neos:Document | root                  | a        | {"language":"mul"}        |
+      | a1              | Neos.Neos:Document | a                     | a1       | {"language":"de"}         |
+      | a1a             | Neos.Neos:Document | a1                    | a1a      | {"language":"de"}         |
+      | a1a1            | Neos.Neos:Document | a1a                   | a1a1     | {"language":"de"}         |
+      | a1a1a           | Neos.Neos:Document | a1a1                  | a1a1a    | {"language":"de"}         |
+      | a1a1b           | Neos.Neos:Document | a1a1                  | a1a1b    | {"language":"de"}         |
+      | a1a2            | Neos.Neos:Document | a1a                   | a1a2     | {"language":"de"}         |
+      | a1b             | Neos.Neos:Document | a1                    | a1b      | {"language":"de"}         |
+      | a2              | Neos.Neos:Document | a                     | a2       | {"language":"de"}         |
+      | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
+      | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
+    And the following Neos users exist:
+      | Id      | Username | First name | Last name | Roles                                            |
+      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
+      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
+      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
+
+  Scenario: TODO
+    Given I am in workspace "live"
+    And I am in dimension space point {"language":"de"}
+    And the command TagSubtree is executed with payload:
+      | Key                          | Value                |
+      | nodeAggregateId              | "a"                  |
+      | nodeVariantSelectionStrategy | "allSpecializations" |
+      | tag                          | "blog"               |
+    And the role MANAGER is assigned to workspace "live" for user "jane.doe"
+    When content repository security is enabled
+    And the user "jane.doe" is authenticated
+    When the command DisableNodeAggregate is executed with payload and exceptions are caught:
+      | Key                          | Value         |
+      | nodeAggregateId              | "a1a"         |
+      | nodeVariantSelectionStrategy | "allVariants" |
+    Then the last command should have thrown an exception of type "AccessDenied"
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
similarity index 51%
rename from Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature
rename to Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
index 9b4e721c733..886f619599c 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
@@ -1,5 +1,5 @@
 @flowEntities
-Feature: TODO
+Feature: ReadNodePrivilege related features
 
   Background:
     Given The following additional policies are configured:
@@ -9,16 +9,17 @@ Feature: TODO
           'Neos.Neos:ReadBlog':
             matcher: 'blog'
       roles:
-
         'Neos.Neos:Administrator':
           privileges:
             - privilegeTarget: 'Neos.Neos:ReadBlog'
               permission: GRANT
       """
-    And using no content dimensions
+    And using the following content dimensions:
+      | Identifier | Values                | Generalizations                     |
+      | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
     And using the following node types:
     """yaml
-    'Neos.ContentRepository.Testing:Document': {}
+    'Neos.Neos:Document': {}
     """
     And using identifier "default", I define a content repository
     And I am in content repository "default"
@@ -33,26 +34,27 @@ Feature: TODO
       | nodeAggregateId | "root"                        |
       | nodeTypeName    | "Neos.ContentRepository:Root" |
     And the following CreateNodeAggregateWithNode commands are executed:
-      | nodeAggregateId | nodeTypeName                            | parentNodeAggregateId | nodeName |
-      | a               | Neos.ContentRepository.Testing:Document | root                  | a        |
-      | a1              | Neos.ContentRepository.Testing:Document | a                     | a1       |
-      | a1a             | Neos.ContentRepository.Testing:Document | a1                    | a1a      |
-      | a1a1            | Neos.ContentRepository.Testing:Document | a1a                   | a1a1     |
-      | a1a1a           | Neos.ContentRepository.Testing:Document | a1a1                  | a1a1a    |
-      | a1a1b           | Neos.ContentRepository.Testing:Document | a1a1                  | a1a1b    |
-      | a1a2            | Neos.ContentRepository.Testing:Document | a1a                   | a1a2     |
-      | a1b             | Neos.ContentRepository.Testing:Document | a1                    | a1b      |
-      | a2              | Neos.ContentRepository.Testing:Document | a                     | a2       |
-      | b               | Neos.ContentRepository.Testing:Document | root                  | b        |
-      | b1              | Neos.ContentRepository.Testing:Document | b                     | b1       |
+      | nodeAggregateId | nodeTypeName       | parentNodeAggregateId | nodeName | originDimensionSpacePoint |
+      | a               | Neos.Neos:Document | root                  | a        | {"language":"mul"}        |
+      | a1              | Neos.Neos:Document | a                     | a1       | {"language":"de"}         |
+      | a1a             | Neos.Neos:Document | a1                    | a1a      | {"language":"de"}         |
+      | a1a1            | Neos.Neos:Document | a1a                   | a1a1     | {"language":"de"}         |
+      | a1a1a           | Neos.Neos:Document | a1a1                  | a1a1a    | {"language":"de"}         |
+      | a1a1b           | Neos.Neos:Document | a1a1                  | a1a1b    | {"language":"de"}         |
+      | a1a2            | Neos.Neos:Document | a1a                   | a1a2     | {"language":"de"}         |
+      | a1b             | Neos.Neos:Document | a1                    | a1b      | {"language":"de"}         |
+      | a2              | Neos.Neos:Document | a                     | a2       | {"language":"de"}         |
+      | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
+      | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
     And the following Neos users exist:
       | Id      | Username | First name | Last name | Roles                                            |
       | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
       | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
       | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
 
-  Scenario: Access content graph for root workspace without role assignments
+  Scenario: TODO
     Given I am in workspace "live"
+    And I am in dimension space point {"language":"de"}
     And the command TagSubtree is executed with payload:
       | Key                          | Value                |
       | nodeAggregateId              | "a"                  |
@@ -60,16 +62,7 @@ Feature: TODO
       | tag                          | "blog"               |
     And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody"
     When content repository security is enabled
-    Then The user "john.doe" should not be able to read node "a1"
-    Then The user "jane.doe" should be able to read node "a1"
-
-  Scenario: TODO
-    When content repository security is enabled
-    And the user "jane.doe" accesses the content graph for workspace "live"
-    Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown
-
-  Scenario: TODO
-    Given the role MANAGER is assigned to workspace "live" for user "janedoe"
-    When content repository security is enabled
-    And the user "jane.doe" accesses the content graph for workspace "live"
-    Then no exception should be thrown
+    And the user "john.doe" is authenticated
+    Then The current user should not be able to read node "a1"
+    When the user "jane.doe" is authenticated
+    Then The current user should be able to read node "a1"
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
new file mode 100644
index 00000000000..eaf3d7ea4dd
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
@@ -0,0 +1,66 @@
+@flowEntities
+Feature: Workspace access related features
+
+  Background:
+    Given The following additional policies are configured:
+      """
+      privilegeTargets:
+        'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege':
+          'Neos.Neos:ReadBlog':
+            matcher: 'blog'
+      roles:
+        'Neos.Neos:Administrator':
+          privileges:
+            - privilegeTarget: 'Neos.Neos:ReadBlog'
+              permission: GRANT
+      """
+    And using the following content dimensions:
+      | Identifier | Values                | Generalizations                     |
+      | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
+    And using the following node types:
+    """yaml
+    'Neos.Neos:Document': {}
+    """
+    And using identifier "default", I define a content repository
+    And I am in content repository "default"
+    And I am user identified by "initiating-user-identifier"
+    And the command CreateRootWorkspace is executed with payload:
+      | Key                | Value           |
+      | workspaceName      | "live"          |
+      | newContentStreamId | "cs-identifier" |
+    And I am in workspace "live" and dimension space point {}
+    And the command CreateRootNodeAggregateWithNode is executed with payload:
+      | Key             | Value                         |
+      | nodeAggregateId | "root"                        |
+      | nodeTypeName    | "Neos.ContentRepository:Root" |
+    And the following CreateNodeAggregateWithNode commands are executed:
+      | nodeAggregateId | nodeTypeName       | parentNodeAggregateId | nodeName | originDimensionSpacePoint |
+      | a               | Neos.Neos:Document | root                  | a        | {"language":"mul"}        |
+      | a1              | Neos.Neos:Document | a                     | a1       | {"language":"de"}         |
+      | a1a             | Neos.Neos:Document | a1                    | a1a      | {"language":"de"}         |
+      | a1a1            | Neos.Neos:Document | a1a                   | a1a1     | {"language":"de"}         |
+      | a1a1a           | Neos.Neos:Document | a1a1                  | a1a1a    | {"language":"de"}         |
+      | a1a1b           | Neos.Neos:Document | a1a1                  | a1a1b    | {"language":"de"}         |
+      | a1a2            | Neos.Neos:Document | a1a                   | a1a2     | {"language":"de"}         |
+      | a1b             | Neos.Neos:Document | a1                    | a1b      | {"language":"de"}         |
+      | a2              | Neos.Neos:Document | a                     | a2       | {"language":"de"}         |
+      | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
+      | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
+    And the following Neos users exist:
+      | Id      | Username | First name | Last name | Roles                                            |
+      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
+      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
+      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
+
+  Scenario: TODO
+    When content repository security is enabled
+    And the user "jane.doe" is authenticated
+    And the current user accesses the content graph for workspace "live"
+    Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown
+
+  Scenario: TODO
+    Given the role MANAGER is assigned to workspace "live" for user "jane.doe"
+    When content repository security is enabled
+    And the user "jane.doe" is authenticated
+    And the current user accesses the content graph for workspace "live"
+    Then no exception should be thrown

From 2efbf3a5d72e2c60ec2d05e6580bc1cacf8b5a7e Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Fri, 8 Nov 2024 14:26:48 +0100
Subject: [PATCH 37/58] moar better tests

---
 ...ricCommandExecutionAndEventPublication.php | 15 ++-
 .../ContentRepositorySecurityTrait.php        | 16 ++--
 .../Features/Bootstrap/ExceptionsTrait.php    | 12 ++-
 .../Features/Bootstrap/UserServiceTrait.php   |  2 +-
 .../Bootstrap/WorkspaceServiceTrait.php       |  9 +-
 .../Security/EditNodePrivilege.feature        | 23 +++--
 .../Security/ReadNodePrivilege.feature        |  8 +-
 .../Security/WorkspaceAccess.feature          | 96 +++++++++++++------
 .../WorkspaceService.feature                  | 60 ++++++++----
 9 files changed, 162 insertions(+), 79 deletions(-)

diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
index dd899ee4e24..5ecc9d7d546 100644
--- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
+++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/GenericCommandExecutionAndEventPublication.php
@@ -14,6 +14,7 @@
 
 namespace Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap;
 
+use Behat\Gherkin\Node\PyStringNode;
 use Behat\Gherkin\Node\TableNode;
 use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePointSet;
 use Neos\ContentRepository\Core\EventStore\EventNormalizer;
@@ -147,14 +148,17 @@ protected function publishEvent(string $eventType, StreamName $streamName, array
     }
 
     /**
-     * @Then /^the last command should have thrown an exception of type "([^"]*)"(?: with code (\d*))?$/
+     * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode and message:
+     * @Then the last command should have thrown an exception of type :shortExceptionName with code :expectedCode
+     * @Then the last command should have thrown an exception of type :shortExceptionName with message:
+     * @Then the last command should have thrown an exception of type :shortExceptionName
      */
-    public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null): void
+    public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int $expectedCode = null, PyStringNode $expectedMessage = null): void
     {
         Assert::assertNotNull($this->lastCommandException, 'Command did not throw exception');
         $lastCommandExceptionShortName = (new \ReflectionClass($this->lastCommandException))->getShortName();
-        Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_class($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage()));
-        if (!is_null($expectedCode)) {
+        Assert::assertSame($shortExceptionName, $lastCommandExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCommandException), $this->lastCommandException->getCode(), $this->lastCommandException->getMessage()));
+        if ($expectedCode !== null) {
             Assert::assertSame($expectedCode, $this->lastCommandException->getCode(), sprintf(
                 'Expected exception code %s, got exception code %s instead; Message: %s',
                 $expectedCode,
@@ -162,6 +166,9 @@ public function theLastCommandShouldHaveThrown(string $shortExceptionName, ?int
                 $this->lastCommandException->getMessage()
             ));
         }
+        if ($expectedMessage !== null) {
+            Assert::assertSame($expectedMessage->getRaw(), $this->lastCommandException->getMessage());
+        }
     }
 
     /**
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
index 76c71de0e68..c8bc89a542e 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -169,9 +169,9 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici
     }
 
     /**
-     * @When the user :username is authenticated
+     * @When I am authenticated as :username
      */
-    public function theUserIsAuthenticated(string $username): void
+    public function iAmAuthenticatedAs(string $username): void
     {
         $user = $this->getObject(UserService::class)->getUser($username);
         $this->authenticateAccount($user->getAccounts()->first());
@@ -179,17 +179,17 @@ public function theUserIsAuthenticated(string $username): void
 
 
     /**
-     * @When the current user accesses the content graph for workspace :workspaceName
+     * @When I access the content graph for workspace :workspaceName
      */
-    public function theCurrentUserAccessesTheContentGraphForWorkspace(string $workspaceName): void
+    public function iAccessesTheContentGraphForWorkspace(string $workspaceName): void
     {
         $this->tryCatchingExceptions(fn () => $this->currentContentRepository->getContentGraph(WorkspaceName::fromString($workspaceName)));
     }
 
     /**
-     * @Then The current user should not be able to read node :nodeAggregateId
+     * @Then I should not be able to read node :nodeAggregateId
      */
-    public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId): void
+    public function iShouldNotBeAbleToReadNode(string $nodeAggregateId): void
     {
         $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
         if ($node !== null) {
@@ -198,9 +198,9 @@ public function theCurrentUserShouldNotBeAbleToReadNode(string $nodeAggregateId)
     }
 
     /**
-     * @Then The current user should be able to read node :nodeAggregateId
+     * @Then I should be able to read node :nodeAggregateId
      */
-    public function theCurrentUserShouldBeAbleToReadNode(string $nodeAggregateId): void
+    public function iShouldBeAbleToReadNode(string $nodeAggregateId): void
     {
         $node = $this->currentContentRepository->getContentSubgraph($this->currentWorkspaceName, $this->currentDimensionSpacePoint)->findNodeById(NodeAggregateId::fromString($nodeAggregateId));
         if ($node === null) {
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
index 228d4594bdc..5a87e107249 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
@@ -12,6 +12,7 @@
  * source code.
  */
 
+use Behat\Gherkin\Node\PyStringNode;
 use PHPUnit\Framework\Assert;
 
 /**
@@ -37,12 +38,17 @@ private function tryCatchingExceptions(\Closure $callback): mixed
     }
 
     /**
-     * @Then an exception :exceptionMessage should be thrown
+     * @Then an exception of type :expectedShortExceptionName should be thrown with message:
+     * @Then an exception of type :expectedShortExceptionName should be thrown
      */
-    public function anExceptionShouldBeThrown(string $exceptionMessage): void
+    public function anExceptionShouldBeThrown(string $expectedShortExceptionName, PyStringNode $expectedExceptionMessage = null): void
     {
         Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown');
-        Assert::assertSame($exceptionMessage, $this->lastCaughtException->getMessage());
+        $lastCaughtExceptionShortName = (new \ReflectionClass($this->lastCaughtException))->getShortName();
+        Assert::assertSame($expectedShortExceptionName, $lastCaughtExceptionShortName, sprintf('Actual exception: %s (%s): %s', get_debug_type($this->lastCaughtException), $this->lastCaughtException->getCode(), $this->lastCaughtException->getMessage()));
+        if ($expectedExceptionMessage !== null) {
+            Assert::assertSame($expectedExceptionMessage->getRaw(), $this->lastCaughtException->getMessage());
+        }
         $this->lastCaughtException = null;
     }
 
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
index 4c4118f8f57..da251d624fb 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php
@@ -65,7 +65,7 @@ public function theFollowingNeosUsersExist(TableNode $usersTable): void
                 username: $userData['Username'],
                 firstName: $userData['First name'] ?? null,
                 lastName: $userData['Last name'] ?? null,
-                roleIdentifiers: isset($userData['Roles']) ? explode(',', $userData['Roles']) : null,
+                roleIdentifiers: !empty($userData['Roles']) ? explode(',', $userData['Roles']) : null,
                 id: $userData['Id'] ?? null,
             );
         }
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 28fbdba1a00..5aeae6d712b 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -65,17 +65,18 @@ public function theRootWorkspaceIsCreated(string $workspaceName, string $title =
     }
 
     /**
-     * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :ownerUserId
+     * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :username
      */
-    public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $ownerUserId): void
+    public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $username): void
     {
+        $ownerUserId = $this->userIdForUsername($username);
         $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createPersonalWorkspace(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
             WorkspaceTitle::fromString($workspaceName),
             WorkspaceDescription::fromString(''),
             WorkspaceName::fromString($targetWorkspace),
-            UserId::fromString($ownerUserId),
+            $ownerUserId,
         ));
     }
 
@@ -258,7 +259,7 @@ public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username,
     private function userIdForUsername(string $username): UserId
     {
         $user = $this->getObject(UserService::class)->getUser($username);
-        Assert::assertNotNull($user);
+        Assert::assertNotNull($user, sprintf('The user "%s" does not exist', $username));
         return $user->getId();
     }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
index 3f86ee05114..d90d4448763 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
@@ -18,7 +18,6 @@ Feature: EditNodePrivilege related features
     """
     And using identifier "default", I define a content repository
     And I am in content repository "default"
-    And I am user identified by "initiating-user-identifier"
     And the command CreateRootWorkspace is executed with payload:
       | Key                | Value           |
       | workspaceName      | "live"          |
@@ -42,10 +41,10 @@ Feature: EditNodePrivilege related features
       | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
       | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
     And the following Neos users exist:
-      | Id      | Username | First name | Last name | Roles                                            |
-      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
-      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
-      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
+      | Username | First name | Last name | Roles                                            |
+      | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
+      | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
+      | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
 
   Scenario: TODO
     Given I am in workspace "live"
@@ -57,9 +56,19 @@ Feature: EditNodePrivilege related features
       | tag                          | "blog"               |
     And the role MANAGER is assigned to workspace "live" for user "jane.doe"
     When content repository security is enabled
-    And the user "jane.doe" is authenticated
+    And I am authenticated as "jane.doe"
     When the command DisableNodeAggregate is executed with payload and exceptions are caught:
       | Key                          | Value         |
       | nodeAggregateId              | "a1a"         |
       | nodeVariantSelectionStrategy | "allVariants" |
-    Then the last command should have thrown an exception of type "AccessDenied"
+    Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686
+#    Then the last command should have thrown an exception of type "AccessDenied" with message:
+#    """
+#    Command "Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate" was denied: No edit permissions for node "a1a" in workspace "live": Evaluated following 2 privilege target(s):
+#    "Neos.Neos:ReadBlog": ABSTAIN
+#    "Neos.Neos:ReadBlog": GRANT
+#    (1 granted, 0 denied, 1 abstained)
+#    Evaluated following 1 privilege target(s):
+#    "Neos.Neos:EditBlog": ABSTAIN
+#    (0 granted, 0 denied, 1 abstained)
+#    """
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
index 886f619599c..f30fc37739f 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
@@ -62,7 +62,7 @@ Feature: ReadNodePrivilege related features
       | tag                          | "blog"               |
     And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody"
     When content repository security is enabled
-    And the user "john.doe" is authenticated
-    Then The current user should not be able to read node "a1"
-    When the user "jane.doe" is authenticated
-    Then The current user should be able to read node "a1"
+    And I am authenticated as "john.doe"
+    Then I should not be able to read node "a1"
+    When I am authenticated as "jane.doe"
+    Then I should be able to read node "a1"
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
index eaf3d7ea4dd..473901a678f 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
@@ -29,38 +29,80 @@ Feature: Workspace access related features
       | workspaceName      | "live"          |
       | newContentStreamId | "cs-identifier" |
     And I am in workspace "live" and dimension space point {}
-    And the command CreateRootNodeAggregateWithNode is executed with payload:
-      | Key             | Value                         |
-      | nodeAggregateId | "root"                        |
-      | nodeTypeName    | "Neos.ContentRepository:Root" |
-    And the following CreateNodeAggregateWithNode commands are executed:
-      | nodeAggregateId | nodeTypeName       | parentNodeAggregateId | nodeName | originDimensionSpacePoint |
-      | a               | Neos.Neos:Document | root                  | a        | {"language":"mul"}        |
-      | a1              | Neos.Neos:Document | a                     | a1       | {"language":"de"}         |
-      | a1a             | Neos.Neos:Document | a1                    | a1a      | {"language":"de"}         |
-      | a1a1            | Neos.Neos:Document | a1a                   | a1a1     | {"language":"de"}         |
-      | a1a1a           | Neos.Neos:Document | a1a1                  | a1a1a    | {"language":"de"}         |
-      | a1a1b           | Neos.Neos:Document | a1a1                  | a1a1b    | {"language":"de"}         |
-      | a1a2            | Neos.Neos:Document | a1a                   | a1a2     | {"language":"de"}         |
-      | a1b             | Neos.Neos:Document | a1                    | a1b      | {"language":"de"}         |
-      | a2              | Neos.Neos:Document | a                     | a2       | {"language":"de"}         |
-      | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
-      | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
     And the following Neos users exist:
-      | Id      | Username | First name | Last name | Roles                                            |
-      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
-      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
-      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
+      | Username          | Roles                                            |
+      | admin             | Neos.Neos:Administrator                          |
+      | editor            | Neos.Neos:Editor                                 |
+      | restricted_editor | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
+      | no_editor         |                                                  |
 
   Scenario: TODO
     When content repository security is enabled
-    And the user "jane.doe" is authenticated
-    And the current user accesses the content graph for workspace "live"
-    Then an exception 'Read access denied for workspace "live": Account "jane.doe" is a Neos Administrator without explicit role for workspace "live"' should be thrown
+    And I am authenticated as "admin"
+    And I access the content graph for workspace "live"
+    Then an exception of type "AccessDenied" should be thrown with message:
+    """
+    Read access denied for workspace "live": Account "admin" is a Neos Administrator without explicit role for workspace "live"
+    """
 
   Scenario: TODO
-    Given the role MANAGER is assigned to workspace "live" for user "jane.doe"
+    Given the role MANAGER is assigned to workspace "live" for user "admin"
+    When content repository security is enabled
+    And I am authenticated as "admin"
+    And I access the content graph for workspace "live"
+    Then no exception should be thrown
+
+  Scenario Outline: Accessing content graph for explicitly assigned workspace role to the authenticated user
+    Given the role <workspace role> is assigned to workspace "live" for user "<user>"
+    When content repository security is enabled
+    And I am authenticated as "<user>"
+    And I access the content graph for workspace "live"
+    Then no exception should be thrown
+
+    Examples:
+      | user              | workspace role |
+      | admin             | VIEWER         |
+      | editor            | COLLABORATOR   |
+      | editor            | VIEWER         |
+      | restricted_editor | MANAGER        |
+      | restricted_editor | VIEWER         |
+
+  Scenario Outline: Accessing content graph for workspace role assigned to group of the authenticated user
+    Given the role <workspace role> is assigned to workspace "live" for group "<group>"
     When content repository security is enabled
-    And the user "jane.doe" is authenticated
-    And the current user accesses the content graph for workspace "live"
+    And I am authenticated as "<user>"
+    And I access the content graph for workspace "live"
     Then no exception should be thrown
+
+    Examples:
+      | user              | group                      | workspace role |
+      | admin             | Neos.Neos:Editor           | COLLABORATOR   |
+      | editor            | Neos.Neos:Editor           | COLLABORATOR   |
+      | restricted_editor | Neos.Neos:RestrictedEditor | VIEWER         |
+      | no_editor         | Neos.Flow:Everybody        | VIEWER         |
+
+  Scenario Outline: Accessing content graph for workspace role assigned to group the authenticated user is not part of
+    Given the role <workspace role> is assigned to workspace "live" for group "<group>"
+    When content repository security is enabled
+    And I am authenticated as "<user>"
+    And I access the content graph for workspace "live"
+    Then an exception of type "AccessDenied" should be thrown
+
+    Examples:
+      | user              | group                   | workspace role |
+      | admin             | Neos.Flow:Anonymous     | COLLABORATOR   |
+      | editor            | Neos.Neos:Administrator | MANAGER        |
+      | restricted_editor | Neos.Neos:Editor        | VIEWER         |
+
+  Scenario Outline: Accessing content graph for workspace that is owned by the authenticated user
+    Given the personal workspace "user-workspace" is created with the target workspace "live" for user "<user>"
+    When content repository security is enabled
+    And I am authenticated as "<user>"
+    And I access the content graph for workspace "user-workspace"
+    Then no exception should be thrown
+
+    Examples:
+      | user              |
+      | admin             |
+      | editor            |
+      | restricted_editor |
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature
index ea0a90995a2..22b3ff17e04 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature
@@ -29,12 +29,15 @@ Feature: Neos WorkspaceService related features
 
   Scenario: Create root workspace with a name that exceeds the workspace name max length
     When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created
-    Then an exception 'Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters' should be thrown
+    Then an exception of type "InvalidArgumentException" should be thrown with message:
+    """
+    Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters
+    """
 
   Scenario: Create root workspace with a name that is already used
     Given the root workspace "some-root-workspace" is created
     When the root workspace "some-root-workspace" is created
-    Then an exception "The workspace some-root-workspace already exists" should be thrown
+    Then an exception of type "WorkspaceAlreadyExists" should be thrown
 
   Scenario: Get metadata of non-existing root workspace
     When a root workspace "some-root-workspace" exists without metadata
@@ -73,10 +76,10 @@ Feature: Neos WorkspaceService related features
 
   Scenario: Create a single personal workspace
     When the root workspace "some-root-workspace" is created
-    And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id"
+    And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe"
     Then the workspace "some-user-workspace" should have the following metadata:
       | Title               | Description | Classification | Owner user id |
-      | some-user-workspace |             | PERSONAL       | some-user-id  |
+      | some-user-workspace |             | PERSONAL       | janedoe       |
 
   Scenario: Create a single shared workspace
     When the root workspace "some-root-workspace" is created
@@ -94,7 +97,10 @@ Feature: Neos WorkspaceService related features
 
   Scenario: Assign role to non-existing workspace
     When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor"
-    Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown
+    Then an exception of type "RuntimeException" should be thrown with message:
+    """
+    Failed to find workspace with name "some-workspace" for content repository "default"
+    """
 
   Scenario: Assign group role to root workspace
     Given the root workspace "some-root-workspace" is created
@@ -107,42 +113,54 @@ Feature: Neos WorkspaceService related features
     Given the root workspace "some-root-workspace" is created
     When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor"
     And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor"
-    Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown
+    Then an exception of type "RuntimeException" should be thrown with message:
+    """
+    Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first
+    """
 
   Scenario: Assign user role to root workspace
     Given the root workspace "some-root-workspace" is created
-    When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id"
+    When the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe"
     Then the workspace "some-root-workspace" should have the following role assignments:
-      | Subject type | Subject      | Role    |
-      | USER         | some-user-id | MANAGER |
+      | Subject type | Subject | Role    |
+      | USER         | janedoe | MANAGER |
 
   Scenario: Assign a role to the same user twice
     Given the root workspace "some-root-workspace" is created
-    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id"
-    And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id"
-    Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown
+    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe"
+    And the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe"
+    Then an exception of type "RuntimeException" should be thrown with message:
+    """
+    Failed to assign role for workspace "some-root-workspace" to subject "johndoe" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first
+    """
 
   Scenario: Unassign role from non-existing workspace
     When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace"
-    Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown
+    Then an exception of type "RuntimeException" should be thrown with message:
+    """
+    Failed to find workspace with name "some-workspace" for content repository "default"
+    """
 
   Scenario: Unassign role from workspace that has not been assigned before
     Given the root workspace "some-root-workspace" is created
     When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace"
-    Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown
+    Then an exception of type "RuntimeException" should be thrown with message:
+    """
+    Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group
+    """
 
   Scenario: Assign two roles, then unassign one
     Given the root workspace "some-root-workspace" is created
-    And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id"
+    And the role MANAGER is assigned to workspace "some-root-workspace" for user "jane.doe"
     And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor"
     Then the workspace "some-root-workspace" should have the following role assignments:
       | Subject type | Subject                  | Role         |
       | GROUP        | Neos.Neos:AbstractEditor | COLLABORATOR |
-      | USER         | some-user-id             | MANAGER      |
+      | USER         | janedoe                  | MANAGER      |
     When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace"
     Then the workspace "some-root-workspace" should have the following role assignments:
-      | Subject type | Subject      | Role    |
-      | USER         | some-user-id | MANAGER |
+      | Subject type | Subject | Role    |
+      | USER         | janedoe | MANAGER |
 
   Scenario: Workspace permissions for personal workspace for admin user
     Given the root workspace "live" is created
@@ -186,14 +204,14 @@ Feature: Neos WorkspaceService related features
 
   Scenario: Workspace permissions for collaborator by user
     When the root workspace "some-root-workspace" is created
-    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "johndoe"
+    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "john.doe"
     Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace"
     And the Neos user "john.doe" should have the permissions "read,write" for workspace "some-root-workspace"
     And the Neos user "editor" should have no permissions for workspace "some-root-workspace"
 
   Scenario: Workspace permissions for manager by user
     When the root workspace "some-root-workspace" is created
-    When the role MANAGER is assigned to workspace "some-root-workspace" for user "johndoe"
+    When the role MANAGER is assigned to workspace "some-root-workspace" for user "john.doe"
     Then the Neos user "jane.doe" should have the permissions "manage" for workspace "some-root-workspace"
     And the Neos user "john.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace"
     And the Neos user "editor" should have no permissions for workspace "some-root-workspace"
@@ -212,7 +230,7 @@ Feature: Neos WorkspaceService related features
 
   Scenario: Permissions for workspace without metadata
     Given a root workspace "some-root-workspace" exists without metadata
-    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "janedoe"
+    When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "jane.doe"
     Then the Neos user "jane.doe" should have the permissions "read,write,manage" for workspace "some-root-workspace"
     And the Neos user "john.doe" should have no permissions for workspace "some-root-workspace"
     And the Neos user "editor" should have no permissions for workspace "some-root-workspace"

From 8b8eea7700669e1edaece155d6ebbfc60a39022a Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Sat, 9 Nov 2024 19:16:46 +0100
Subject: [PATCH 38/58] Extract authenticated account from current Neos user

...instead of the security context
---
 Neos.Neos/Classes/Domain/Model/User.php              | 12 ++++++++++--
 .../Classes/Controller/WorkspaceController.php       | 12 ++++--------
 2 files changed, 14 insertions(+), 10 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php
index 24db0c6cfc3..33abe790644 100644
--- a/Neos.Neos/Classes/Domain/Model/User.php
+++ b/Neos.Neos/Classes/Domain/Model/User.php
@@ -92,13 +92,21 @@ public function setPreferences(UserPreferences $preferences)
      * @api
      */
     public function isActive()
+    {
+        return $this->getFirstActiveAccount() !== null;
+    }
+
+    /**
+     * @api
+     */
+    public function getFirstActiveAccount(): ?Account
     {
         foreach ($this->accounts as $account) {
             /** @var Account $account */
             if ($account->isActive()) {
-                return true;
+                return $account;
             }
         }
-        return false;
+        return null;
     }
 }
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index 2a8665181de..3b45f907c58 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -43,7 +43,6 @@
 use Neos\Flow\Mvc\Exception\StopActionException;
 use Neos\Flow\Package\PackageManager;
 use Neos\Flow\Property\PropertyMapper;
-use Neos\Flow\Security\Context;
 use Neos\Flow\Security\Exception\AccessDeniedException;
 use Neos\Media\Domain\Model\AssetInterface;
 use Neos\Media\Domain\Model\ImageInterface;
@@ -91,9 +90,6 @@ class WorkspaceController extends AbstractModuleController
     #[Flow\Inject]
     protected PropertyMapper $propertyMapper;
 
-    #[Flow\Inject]
-    protected Context $securityContext;
-
     #[Flow\Inject]
     protected UserService $userService;
 
@@ -114,7 +110,7 @@ class WorkspaceController extends AbstractModuleController
      */
     public function indexAction(): void
     {
-        $authenticatedAccount = $this->securityContext->getAccount();
+        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
         if ($authenticatedAccount === null) {
             throw new AccessDeniedException('No user authenticated', 1718308216);
         }
@@ -164,7 +160,7 @@ classification: $workspaceMetadata->classification->name,
 
     public function showAction(WorkspaceName $workspace): void
     {
-        $authenticatedAccount = $this->securityContext->getAccount();
+        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
         if ($authenticatedAccount === null) {
             throw new AccessDeniedException('No user authenticated', 1720371024);
         }
@@ -293,7 +289,7 @@ public function updateAction(
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
 
-        $authenticatedAccount = $this->securityContext->getAccount();
+        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
         if ($authenticatedAccount === null) {
             throw new AccessDeniedException('No user is authenticated', 1729620262);
         }
@@ -1012,7 +1008,7 @@ protected function prepareBaseWorkspaceOptions(
         ContentRepository $contentRepository,
         WorkspaceName $excludedWorkspace = null,
     ): array {
-        $authenticatedAccount = $this->securityContext->getAccount();
+        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
         $baseWorkspaceOptions = [];
         $workspaces = $contentRepository->findWorkspaces();
         foreach ($workspaces as $workspace) {

From 85698043954b9a18ca7f0f903a9fdfbcaec83a99 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Sun, 10 Nov 2024 14:21:08 +0100
Subject: [PATCH 39/58] Fix `test_parallel` CR settings

---
 .../Configuration/Settings.yaml                               | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml
index 212fb010b63..d38e376fd54 100644
--- a/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml
+++ b/Neos.ContentRepository.BehavioralTests/Configuration/Settings.yaml
@@ -27,8 +27,8 @@ Neos:
           factoryObjectName: Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinPyStringNodeBasedNodeTypeManagerFactory
         contentDimensionSource:
           factoryObjectName: Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\GherkinTableNodeBasedContentDimensionSourceFactory
-        userIdProvider:
-          factoryObjectName: Neos\ContentRepositoryRegistry\Factory\UserIdProvider\StaticUserIdProviderFactory
+        authProvider:
+          factoryObjectName: Neos\ContentRepositoryRegistry\Factory\AuthProvider\StaticAuthProviderFactory
         clock:
           factoryObjectName: Neos\ContentRepositoryRegistry\Factory\Clock\SystemClockFactory
         propertyConverters: {}

From 466c89e583dbbc9bfdf356e256f7f5cea69ec28f Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Mon, 11 Nov 2024 15:40:31 +0100
Subject: [PATCH 40/58] Simplify `ContentRepositoryAuthorizationService` API

according to review comments
---
 .../Classes/Controller/UsageController.php    |  10 +-
 .../Controller/Frontend/NodeController.php    |   7 +-
 Neos.Neos/Classes/Domain/Model/User.php       |  12 +-
 .../Domain/Service/WorkspaceService.php       |   4 +-
 .../ContentRepositoryAuthorizationService.php | 125 ++++--------------
 .../ContentRepositoryAuthProvider.php         |  40 ++----
 .../Bootstrap/WorkspaceServiceTrait.php       |  23 ++--
 .../Controller/WorkspaceController.php        |  30 ++---
 8 files changed, 77 insertions(+), 174 deletions(-)

diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php
index a62d36b6298..d3634d48125 100644
--- a/Neos.Media.Browser/Classes/Controller/UsageController.php
+++ b/Neos.Media.Browser/Classes/Controller/UsageController.php
@@ -22,13 +22,13 @@
 use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Media\Domain\Model\AssetInterface;
 use Neos\Media\Domain\Service\AssetService;
+use Neos\Neos\AssetUsage\Dto\AssetUsageReference;
 use Neos\Neos\Domain\Repository\SiteRepository;
 use Neos\Neos\Domain\Service\NodeTypeNameFactory;
 use Neos\Neos\Domain\Service\WorkspaceService;
 use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult;
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 use Neos\Neos\Service\UserService;
-use Neos\Neos\AssetUsage\Dto\AssetUsageReference;
 
 /**
  * Controller for asset usage handling
@@ -117,13 +117,7 @@ public function relatedNodesAction(AssetInterface $asset)
             );
             $nodeType = $nodeAggregate ? $contentRepository->getNodeTypeManager()->getNodeType($nodeAggregate->nodeTypeName) : null;
 
-            $authenticatedAccount = $this->securityContext->getAccount();
-            if ($authenticatedAccount !== null) {
-                $workspacePermissions =  $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($currentContentRepositoryId, $usage->getWorkspaceName(), $authenticatedAccount);
-            } else {
-                $workspacePermissions =  $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($currentContentRepositoryId, $usage->getWorkspaceName());
-            }
-
+            $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($currentContentRepositoryId, $usage->getWorkspaceName(), $this->securityContext->getRoles(), $this->userService->getBackendUser()?->getId());
             $workspace = $contentRepository->findWorkspaceByName($usage->getWorkspaceName());
 
             $inaccessibleRelation['nodeIdentifier'] = $usage->getNodeAggregateId()->value;
diff --git a/Neos.Neos/Classes/Controller/Frontend/NodeController.php b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
index 7191c4e496f..19300dea675 100644
--- a/Neos.Neos/Classes/Controller/Frontend/NodeController.php
+++ b/Neos.Neos/Classes/Controller/Frontend/NodeController.php
@@ -200,12 +200,7 @@ public function showAction(string $node): void
         }
 
         $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId);
-        $authenticatedAccount = $this->securityContext->getAccount();
-        if ($authenticatedAccount !== null) {
-            $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAccount($contentRepository->id, $authenticatedAccount);
-        } else {
-            $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraintsForAnonymousUser($contentRepository->id);
-        }
+        $visibilityConstraints = $this->contentRepositoryAuthorizationService->getVisibilityConstraints($contentRepository->id, $this->securityContext->getRoles());
         // By default, the visibility constraints only contain the SubtreeTags the authenticated user has _no_ access to
         // Neos backend users have access to the "disabled" SubtreeTag so that they can see/edit disabled nodes.
         // In this showAction (= "frontend") we have to explicitly remove those disabled nodes, even if the user was authenticated,
diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php
index 33abe790644..24db0c6cfc3 100644
--- a/Neos.Neos/Classes/Domain/Model/User.php
+++ b/Neos.Neos/Classes/Domain/Model/User.php
@@ -92,21 +92,13 @@ public function setPreferences(UserPreferences $preferences)
      * @api
      */
     public function isActive()
-    {
-        return $this->getFirstActiveAccount() !== null;
-    }
-
-    /**
-     * @api
-     */
-    public function getFirstActiveAccount(): ?Account
     {
         foreach ($this->accounts as $account) {
             /** @var Account $account */
             if ($account->isActive()) {
-                return $account;
+                return true;
             }
         }
-        return null;
+        return false;
     }
 }
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 3f7523c94e1..228605c322b 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -236,7 +236,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId,
     /**
      * Get all role assignments for the specified workspace
      *
-     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used!
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used!
      */
     public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments
     {
@@ -272,7 +272,7 @@ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentReposito
     /**
      * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName
      *
-     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAccount()} and {@see ContentRepositoryAuthorizationService::getWorkspacePermissionsForAnonymousUser()} should be used!
+     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used!
      */
     public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole
     {
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 2f6cc32cf1f..d0027c076a7 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -10,12 +10,12 @@
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\Flow\Annotations as Flow;
-use Neos\Flow\Security\Account;
 use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
+use Neos\Flow\Security\Context;
 use Neos\Flow\Security\Policy\PolicyService;
 use Neos\Flow\Security\Policy\Role;
 use Neos\Neos\Domain\Model\NodePermissions;
-use Neos\Neos\Domain\Model\User;
+use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
@@ -24,7 +24,6 @@
 use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege;
 use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
 use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject;
-use Neos\Party\Domain\Service\PartyService;
 
 /**
  * Central point which does ContentRepository authorization decisions within Neos.
@@ -34,13 +33,9 @@
 #[Flow\Scope('singleton')]
 final readonly class ContentRepositoryAuthorizationService
 {
-    private const FLOW_ROLE_EVERYBODY = 'Neos.Flow:Everybody';
-    private const FLOW_ROLE_ANONYMOUS = 'Neos.Flow:Anonymous';
-    private const FLOW_ROLE_AUTHENTICATED_USER = 'Neos.Flow:AuthenticatedUser';
-    private const FLOW_ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator';
+    private const ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator';
 
     public function __construct(
-        private PartyService $partyService,
         private WorkspaceService $workspaceService,
         private PolicyService $policyService,
         private PrivilegeManagerInterface $privilegeManager,
@@ -48,119 +43,61 @@ public function __construct(
     }
 
     /**
-     * Determines the {@see WorkspacePermissions} an anonymous user has for the specified workspace (aka "public access")
+     * Determines the {@see WorkspacePermissions} a user with the specified {@see Role}s has for the specified workspace
+     *
+     * @param array<Role> $roles The {@see Role} instances to check access for. Note: These have to be the expanded roles auf the authenticated tokens {@see Context::getRoles()}
+     * @param UserId|null $userId Optional ID of the authenticated Neos user. If set the workspace owner is evaluated since owners always have all permissions on their workspace
      */
-    public function getWorkspacePermissionsForAnonymousUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspacePermissions
-    {
-        $subjects = [WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_EVERYBODY), WorkspaceRoleSubject::createForGroup(self::FLOW_ROLE_ANONYMOUS)];
-        $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
-        if ($userWorkspaceRole === null) {
-            return WorkspacePermissions::none(sprintf('Anonymous user has no explicit role for workspace "%s"', $workspaceName->value));
-        }
-        return WorkspacePermissions::create(
-            read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
-            write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
-            manage: $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
-            reason: sprintf('Anonymous user has role "%s" for workspace "%s"', $userWorkspaceRole->value, $workspaceName->value),
-        );
-    }
-
-    /**
-     * Determines the {@see WorkspacePermissions} the given user has for the specified workspace
-     */
-    public function getWorkspacePermissionsForAccount(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, Account $account): WorkspacePermissions
+    public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions
     {
         $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName);
-        $neosUser = $this->neosUserFromAccount($account);
-        if ($workspaceMetadata->ownerUserId !== null && $neosUser !== null && $neosUser->getId()->equals($workspaceMetadata->ownerUserId)) {
-            return WorkspacePermissions::all(sprintf('User "%s" (id: %s is the owner of workspace "%s"', $neosUser->getLabel(), $neosUser->getId()->value, $workspaceName->value));
+        if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) {
+            return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value));
         }
-        $userRoles = $this->expandAccountRoles($account);
-        $userIsAdministrator = array_key_exists(self::FLOW_ROLE_NEOS_ADMINISTRATOR, $userRoles);
-        $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), array_keys($userRoles));
-
-        if ($neosUser !== null) {
-            $subjects[] = WorkspaceRoleSubject::createForUser($neosUser->getId());
+        $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), $roles);
+        $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers);
+        if ($userId !== null) {
+            $subjects[] = WorkspaceRoleSubject::createForUser($userId);
         }
+        $userIsAdministrator = array_key_exists(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers);
+
         $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
         if ($userWorkspaceRole === null) {
             if ($userIsAdministrator) {
-                return WorkspacePermissions::manage(sprintf('Account "%s" is a Neos Administrator without explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value));
+                return WorkspacePermissions::manage(sprintf('User is a Neos Administrator without explicit role for workspace "%s"', $workspaceName->value));
             }
-            return WorkspacePermissions::none(sprintf('Account "%s" is no Neos Administrator and has no explicit role for workspace "%s"', $account->getAccountIdentifier(), $workspaceName->value));
+            return WorkspacePermissions::none(sprintf('User is no Neos Administrator and has no explicit role for workspace "%s"', $workspaceName->value));
         }
         return WorkspacePermissions::create(
             read: $userWorkspaceRole->isAtLeast(WorkspaceRole::VIEWER),
             write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR),
             manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER),
-            reason: sprintf('Account "%s" is %s Neos Administrator and has role "%s" for workspace "%s"', $account->getAccountIdentifier(), $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value),
+            reason: sprintf('User is %s Neos Administrator and has role "%s" for workspace "%s"', $userIsAdministrator ? 'a' : 'no', $userWorkspaceRole->value, $workspaceName->value),
         );
     }
 
-    public function getNodePermissionsForAnonymousUser(Node $node): NodePermissions
-    {
-        $roles = $this->rolesOfAnonymousUser();
-        return $this->nodePermissionsForRoles($node, $roles);
-    }
-
-    public function getNodePermissionsForAccount(Node $node, Account $account): NodePermissions
-    {
-        $roles = $this->expandAccountRoles($account);
-        return $this->nodePermissionsForRoles($node, $roles);
-    }
-
     /**
-     * Determines the default {@see VisibilityConstraints} for an anonymous user (aka "public access")
+     * Determines the {@see NodePermissions} a user with the specified {@see Role}s has on the given {@see Node}
+     *
+     * @param array<Role> $roles
      */
-    public function getVisibilityConstraintsForAnonymousUser(ContentRepositoryId $contentRepositoryId): VisibilityConstraints
+    public function getNodePermissions(Node $node, array $roles): NodePermissions
     {
-        $roles = $this->rolesOfAnonymousUser();
-        return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles));
+        return $this->nodePermissionsForRoles($node, $roles);
     }
 
     /**
-     * Determines the default {@see VisibilityConstraints} for the specified account
+     * Determines the default {@see VisibilityConstraints} for the specified {@see Role}s
+     *
+     * @param array<Role> $roles
      */
-    public function getVisibilityConstraintsForAccount(ContentRepositoryId $contentRepositoryId, Account $account): VisibilityConstraints
+    public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
     {
-        $roles = $this->expandAccountRoles($account);
         return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles));
     }
 
     // ------------------------------
 
-    /**
-     * @return array<Role>
-     */
-    private function rolesOfAnonymousUser(): array
-    {
-        return [
-            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
-            self::FLOW_ROLE_ANONYMOUS => $this->policyService->getRole(self::FLOW_ROLE_ANONYMOUS),
-        ];
-    }
-
-    /**
-     * @return array<Role>
-     */
-    private function expandAccountRoles(Account $account): array
-    {
-        $roles = [
-            self::FLOW_ROLE_EVERYBODY => $this->policyService->getRole(self::FLOW_ROLE_EVERYBODY),
-            self::FLOW_ROLE_AUTHENTICATED_USER => $this->policyService->getRole(self::FLOW_ROLE_AUTHENTICATED_USER),
-        ];
-        foreach ($account->getRoles() as $currentRole) {
-            if (!array_key_exists($currentRole->getIdentifier(), $roles)) {
-                $roles[$currentRole->getIdentifier()] = $currentRole;
-            }
-            foreach ($currentRole->getAllParentRoles() as $currentParentRole) {
-                if (!array_key_exists($currentParentRole->getIdentifier(), $roles)) {
-                    $roles[$currentParentRole->getIdentifier()] = $currentParentRole;
-                }
-            }
-        }
-        return $roles;
-    }
 
     /**
      * @param array<Role> $roles
@@ -177,12 +114,6 @@ private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId
         return $restrictedSubtreeTags;
     }
 
-    private function neosUserFromAccount(Account $account): ?User
-    {
-        $user = $this->partyService->getAssignedPartyOfAccount($account);
-        return $user instanceof User ? $user : null;
-    }
-
     /**
      * @param array<Role> $roles
      */
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 95ff0a58ece..a84657c5aaa 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -78,11 +78,7 @@ public function getAuthenticatedUserId(): ?UserId
 
     public function getVisibilityConstraints(WorkspaceName $workspaceName): VisibilityConstraints
     {
-        $authenticatedAccount = $this->securityContext->getAccount();
-        if ($authenticatedAccount) {
-            return $this->authorizationService->getVisibilityConstraintsForAccount($this->contentRepositoryId, $authenticatedAccount);
-        }
-        return $this->authorizationService->getVisibilityConstraintsForAnonymousUser($this->contentRepositoryId);
+        return $this->authorizationService->getVisibilityConstraints($this->contentRepositoryId, $this->securityContext->getRoles());
     }
 
     public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privilege
@@ -90,12 +86,12 @@ public function canReadNodesFromWorkspace(WorkspaceName $workspaceName): Privile
         if ($this->securityContext->areAuthorizationChecksDisabled()) {
             return Privilege::granted('Authorization checks are disabled');
         }
-        $authenticatedAccount = $this->securityContext->getAccount();
-        if ($authenticatedAccount === null) {
-            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
-        } else {
-            $workspacePermissions = $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
-        }
+        $workspacePermissions = $this->authorizationService->getWorkspacePermissions(
+            $this->contentRepositoryId,
+            $workspaceName,
+            $this->securityContext->getRoles(),
+            $this->userService->getCurrentUser()?->getId(),
+        );
         return $workspacePermissions->read ? Privilege::granted($workspacePermissions->getReason()) : Privilege::denied($workspacePermissions->getReason());
     }
 
@@ -117,7 +113,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege
             if ($node === null) {
                 return Privilege::denied(sprintf('Failed to load node "%s" in workspace "%s"', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value));
             }
-            $nodePermissions = $this->getNodePermissionsForCurrentUser($node);
+            $nodePermissions = $this->authorizationService->getNodePermissions($node, $this->securityContext->getRoles());
             if (!$nodePermissions->edit) {
                 return Privilege::denied(sprintf('No edit permissions for node "%s" in workspace "%s": %s', $nodeThatRequiresEditPrivilege->aggregateId->value, $nodeThatRequiresEditPrivilege->workspaceName->value, $nodePermissions->getReason()));
             }
@@ -199,19 +195,11 @@ private function requireWorkspaceManagePermission(WorkspaceName $workspaceName):
 
     private function getWorkspacePermissionsForCurrentUser(WorkspaceName $workspaceName): WorkspacePermissions
     {
-        $authenticatedAccount = $this->securityContext->getAccount();
-        if ($authenticatedAccount === null) {
-            return $this->authorizationService->getWorkspacePermissionsForAnonymousUser($this->contentRepositoryId, $workspaceName);
-        }
-        return $this->authorizationService->getWorkspacePermissionsForAccount($this->contentRepositoryId, $workspaceName, $authenticatedAccount);
-    }
-
-    private function getNodePermissionsForCurrentUser(Node $node): NodePermissions
-    {
-        $authenticatedAccount = $this->securityContext->getAccount();
-        if ($authenticatedAccount === null) {
-            return $this->authorizationService->getNodePermissionsForAnonymousUser($node);
-        }
-        return $this->authorizationService->getNodePermissionsForAccount($node, $authenticatedAccount);
+        return $this->authorizationService->getWorkspacePermissions(
+            $this->contentRepositoryId,
+            $workspaceName,
+            $this->securityContext->getRoles(),
+            $this->userService->getCurrentUser()?->getId(),
+        );
     }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 416b6d4a8d3..74ba8b77b53 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -18,6 +18,7 @@
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Security\Context;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceDescription;
 use Neos\Neos\Domain\Model\WorkspaceRole;
@@ -212,14 +213,15 @@ public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName
      */
     public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username, string $expectedPermissions, string $workspaceName): void
     {
-        $user = $this->getObject(UserService::class)->getUser($username);
+        $userService = $this->getObject(UserService::class);
+        $user = $userService->getUser($username);
         Assert::assertNotNull($user);
-        $account = $user->getAccounts()->first();
-        Assert::assertNotNull($account);
-        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount(
+        $roles = $userService->getAllRoles($user);
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $account,
+            $roles,
+            $user->getId(),
         );
         Assert::assertSame($expectedPermissions, implode(',', array_keys(array_filter(get_object_vars($permissions)))));
     }
@@ -229,14 +231,15 @@ public function theNeosUserShouldHaveThePermissionsForWorkspace(string $username
      */
     public function theNeosUserShouldHaveNoPermissionsForWorkspace(string $username, string $workspaceName): void
     {
-        $user = $this->getObject(UserService::class)->getUser($username);
+        $userService = $this->getObject(UserService::class);
+        $user = $userService->getUser($username);
         Assert::assertNotNull($user);
-        $account = $user->getAccounts()->first();
-        Assert::assertNotNull($account);
-        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissionsForAccount(
+        $roles = $userService->getAllRoles($user);
+        $permissions = $this->getObject(ContentRepositoryAuthorizationService::class)->getWorkspacePermissions(
             $this->currentContentRepository->id,
             WorkspaceName::fromString($workspaceName),
-            $account,
+            $roles,
+            $user->getId(),
         );
         Assert::assertFalse($permissions->read);
         Assert::assertFalse($permissions->write);
diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
index 3b45f907c58..e8f02988acb 100644
--- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
+++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
@@ -43,6 +43,7 @@
 use Neos\Flow\Mvc\Exception\StopActionException;
 use Neos\Flow\Package\PackageManager;
 use Neos\Flow\Property\PropertyMapper;
+use Neos\Flow\Security\Context;
 use Neos\Flow\Security\Exception\AccessDeniedException;
 use Neos\Media\Domain\Model\AssetInterface;
 use Neos\Media\Domain\Model\ImageInterface;
@@ -90,6 +91,9 @@ class WorkspaceController extends AbstractModuleController
     #[Flow\Inject]
     protected PropertyMapper $propertyMapper;
 
+    #[Flow\Inject]
+    protected Context $securityContext;
+
     #[Flow\Inject]
     protected UserService $userService;
 
@@ -110,8 +114,8 @@ class WorkspaceController extends AbstractModuleController
      */
     public function indexAction(): void
     {
-        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
-        if ($authenticatedAccount === null) {
+        $currentUser = $this->userService->getCurrentUser();
+        if ($currentUser === null) {
             throw new AccessDeniedException('No user authenticated', 1718308216);
         }
 
@@ -140,7 +144,7 @@ public function indexAction(): void
                 continue;
             }
             $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName);
-            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $workspace->workspaceName, $authenticatedAccount);
+            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId());
             if (!$permissions->read) {
                 continue;
             }
@@ -160,8 +164,8 @@ classification: $workspaceMetadata->classification->name,
 
     public function showAction(WorkspaceName $workspace): void
     {
-        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
-        if ($authenticatedAccount === null) {
+        $currentUser = $this->userService->getCurrentUser();
+        if ($currentUser === null) {
             throw new AccessDeniedException('No user authenticated', 1720371024);
         }
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
@@ -179,7 +183,7 @@ public function showAction(WorkspaceName $workspace): void
             $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName);
             assert($baseWorkspace !== null);
             $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName);
-            $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepositoryId, $baseWorkspace->workspaceName, $authenticatedAccount);
+            $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId());
         }
         $this->view->assignMultiple([
             'selectedWorkspace' => $workspaceObj,
@@ -289,11 +293,11 @@ public function updateAction(
         $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId;
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
 
-        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
-        if ($authenticatedAccount === null) {
+        $currentUser = $this->userService->getCurrentUser();
+        if ($currentUser === null) {
             throw new AccessDeniedException('No user is authenticated', 1729620262);
         }
-        $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspaceName, $authenticatedAccount);
+        $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspaceName, $this->securityContext->getRoles(), $currentUser->getId());
         if (!$workspacePermissions->manage) {
             throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297);
         }
@@ -1008,7 +1012,7 @@ protected function prepareBaseWorkspaceOptions(
         ContentRepository $contentRepository,
         WorkspaceName $excludedWorkspace = null,
     ): array {
-        $authenticatedAccount = $this->userService->getCurrentUser()?->getFirstActiveAccount();
+        $currentUser = $this->userService->getCurrentUser();
         $baseWorkspaceOptions = [];
         $workspaces = $contentRepository->findWorkspaces();
         foreach ($workspaces as $workspace) {
@@ -1024,11 +1028,7 @@ protected function prepareBaseWorkspaceOptions(
             if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) {
                 continue;
             }
-            if ($authenticatedAccount !== null) {
-                $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAccount($contentRepository->id, $workspace->workspaceName, $authenticatedAccount);
-            } else {
-                $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissionsForAnonymousUser($contentRepository->id, $workspace->workspaceName);
-            }
+            $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId());
             if (!$permissions->manage) {
                 continue;
             }

From 637a9c653df1c4e98103240e461369512ce4e38e Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Mon, 11 Nov 2024 15:55:51 +0100
Subject: [PATCH 41/58] Fix role conversion in
 `ContentRepositoryAuthorizationService`

---
 .../Authorization/ContentRepositoryAuthorizationService.php  | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index d0027c076a7..0afdb315f02 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -54,13 +54,12 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId
         if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) {
             return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value));
         }
-        $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), $roles);
+        $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), array_values($roles));
         $subjects = array_map(WorkspaceRoleSubject::createForGroup(...), $roleIdentifiers);
         if ($userId !== null) {
             $subjects[] = WorkspaceRoleSubject::createForUser($userId);
         }
-        $userIsAdministrator = array_key_exists(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers);
-
+        $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true);
         $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
         if ($userWorkspaceRole === null) {
             if ($userIsAdministrator) {

From 6397ff4ec27caa004cc796122cea44e7bbb8c762 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Mon, 11 Nov 2024 17:11:04 +0100
Subject: [PATCH 42/58] Mark `NodePermissions` and `WorkspacePermissions`
 `@api`

---
 Neos.Neos/Classes/Domain/Model/NodePermissions.php      | 2 +-
 Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
index 5a2ff8733a8..28300b92af7 100644
--- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
@@ -13,7 +13,7 @@
  * - read: Permission to read the node and its properties and references
  * - edit: Permission to change the node
  *
- * @internal
+ * @api because it is returned by the {@see ContentRepositoryAuthorizationService}
  */
 #[Flow\Proxy(false)]
 final readonly class NodePermissions
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
index 421ed38c2cb..5761d61a74a 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
@@ -14,7 +14,7 @@
  * - write: Permission to write to the corresponding workspace, including publishing a derived workspace to it
  * - manage: Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles)
  *
- * @internal
+ * @api because it is returned by the {@see ContentRepositoryAuthorizationService}
  */
 #[Flow\Proxy(false)]
 final readonly class WorkspacePermissions

From 952932700344fbe63c1a516404582c1a054433ea Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 17:38:13 +0100
Subject: [PATCH 43/58] TASK: Split mighty `WorkspaceService` into
 `WorkspaceMetadataAndRoleRepository`

By moving the db implementation logic in a separate class, the dependency chain is cleaned up, as the `WorkspaceService` would previously hold the cr which would hold the `ContentRepositoryAuthorizationService` which has the `WorkspaceService`

Also we didnt want to add further zickzack and previously it would not have been able to acquire the `ContentRepositoryAuthorizationService` without hacks in the `WorkspaceService` to evaluate permissions for `setWorkspaceTitle` or `assignWorkspaceRole`
---
 .../WorkspaceMetadataAndRoleRepository.php    | 314 ++++++++++++++++++
 .../Domain/Service/WorkspaceService.php       | 265 +--------------
 .../ContentRepositoryAuthorizationService.php |  10 +-
 3 files changed, 336 insertions(+), 253 deletions(-)
 create mode 100644 Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php

diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php
new file mode 100644
index 00000000000..ed6c928f6ca
--- /dev/null
+++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php
@@ -0,0 +1,314 @@
+<?php
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+declare(strict_types=1);
+
+namespace Neos\Neos\Domain\Repository;
+
+use Doctrine\DBAL\ArrayParameterType;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Exception as DbalException;
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
+use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
+use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
+use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
+use Neos\Flow\Annotations as Flow;
+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\WorkspaceRole;
+use Neos\Neos\Domain\Model\WorkspaceRoleAssignment;
+use Neos\Neos\Domain\Model\WorkspaceRoleAssignments;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
+use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType;
+use Neos\Neos\Domain\Model\WorkspaceTitle;
+use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
+
+/**
+ * Implementation detail of {@see WorkspaceService} and {@see ContentRepositoryAuthorizationService}
+ *
+ * @internal Neos users should not need to deal with this low level repository. No security is imposed here. Please use the {@see WorkspaceService}!
+ */
+#[Flow\Scope('singleton')]
+final readonly class WorkspaceMetadataAndRoleRepository
+{
+    private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata';
+    private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role';
+
+    public function __construct(
+        private Connection $dbal
+    ) {
+    }
+
+    /**
+     * The public and documented API is {@see WorkspaceService::assignWorkspaceRole}
+     */
+    public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void
+    {
+        try {
+            $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [
+                'content_repository_id' => $contentRepositoryId->value,
+                'workspace_name' => $workspaceName->value,
+                'subject_type' => $assignment->subject->type->value,
+                'subject' => $assignment->subject->value,
+                'role' => $assignment->role->value,
+            ]);
+        } catch (UniqueConstraintViolationException $e) {
+            throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e);
+        } catch (DbalException $e) {
+            throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e);
+        }
+    }
+
+    /**
+     * 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 = <<<SQL
+            SELECT
+                *
+            FROM
+                {$table}
+            WHERE
+                content_repository_id = :contentRepositoryId
+                AND workspace_name = :workspaceName
+        SQL;
+        try {
+            $rows = $this->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 = <<<SQL
+            SELECT
+                role
+            FROM
+                {$tableRole}
+            WHERE
+                content_repository_id = :contentRepositoryId
+                AND workspace_name = :workspaceName
+                AND (
+                    (subject_type = :userSubjectType AND subject IN (:userSubjectValues))
+                    OR
+                    (subject_type = :groupSubjectType AND subject IN (:groupSubjectValues))
+                )
+            ORDER BY
+                /* We only want to return the most specific role so we order them and return the first row */
+                CASE
+                    {$roleCasesBySpecificity}
+                END
+                DESC
+            LIMIT 1
+        SQL;
+        $userSubjectValues = [];
+        $groupSubjectValues = [];
+        foreach ($subjects as $subject) {
+            if ($subject->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 = <<<SQL
+            SELECT
+                *
+            FROM
+                {$table}
+            WHERE
+                content_repository_id = :contentRepositoryId
+                AND workspace_name = :workspaceName
+        SQL;
+        try {
+            $metadataRow = $this->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 = <<<SQL
+            SELECT
+                workspace_name
+            FROM
+                {$tableMetadata}
+            WHERE
+                content_repository_id = :contentRepositoryId
+                AND classification = :personalWorkspaceClassification
+                AND owner_user_id = :userId
+        SQL;
+        $workspaceName = $this->dbal->fetchOne($query, [
+            'contentRepositoryId' => $contentRepositoryId->value,
+            'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value,
+            'userId' => $userId->value,
+        ]);
+        return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName);
+    }
+}
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 228605c322b..9aa2c14670c 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -14,10 +14,6 @@
 
 namespace Neos\Neos\Domain\Service;
 
-use Doctrine\DBAL\ArrayParameterType;
-use Doctrine\DBAL\Connection;
-use Doctrine\DBAL\Exception as DbalException;
-use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists;
@@ -36,9 +32,8 @@
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignment;
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignments;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
-use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
-use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType;
 use Neos\Neos\Domain\Model\WorkspaceTitle;
+use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository;
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
 
 /**
@@ -49,12 +44,9 @@
 #[Flow\Scope('singleton')]
 final readonly class WorkspaceService
 {
-    private const TABLE_NAME_WORKSPACE_METADATA = 'neos_neos_workspace_metadata';
-    private const TABLE_NAME_WORKSPACE_ROLE = 'neos_neos_workspace_role';
-
     public function __construct(
         private ContentRepositoryRegistry $contentRepositoryRegistry,
-        private Connection $dbal,
+        private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository
     ) {
     }
 
@@ -68,7 +60,7 @@ public function __construct(
     public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceMetadata
     {
         $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
-        $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName);
+        $metadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName);
         return $metadata ?? new WorkspaceMetadata(
             WorkspaceTitle::fromString($workspaceName->value),
             WorkspaceDescription::fromString(''),
@@ -84,9 +76,8 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
-        $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
-            'title' => $newWorkspaceTitle->value,
-        ]);
+        $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
+        $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null);
     }
 
     /**
@@ -96,9 +87,8 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work
      */
     public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void
     {
-        $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [
-            'description' => $newWorkspaceDescription->value,
-        ]);
+        $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
+        $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value);
     }
 
     /**
@@ -108,7 +98,7 @@ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId
      */
     public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace
     {
-        $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId);
+        $workspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId);
         if ($workspaceName === null) {
             throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801);
         }
@@ -129,7 +119,7 @@ public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, Wo
                 ContentStreamId::create()
             )
         );
-        $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null);
+        $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null);
     }
 
     /**
@@ -145,7 +135,7 @@ public function createLiveWorkspaceIfMissing(ContentRepositoryId $contentReposit
             return;
         }
         $this->createRootWorkspace($contentRepositoryId, $workspaceName, WorkspaceTitle::fromString('Public live workspace'), WorkspaceDescription::empty());
-        $this->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR));
+        $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR));
     }
 
     /**
@@ -170,7 +160,7 @@ public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId,
      */
     public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void
     {
-        $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId());
+        $existingWorkspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId());
         if ($existingWorkspaceName !== null) {
             $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName);
             return;
@@ -195,19 +185,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con
     public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void
     {
         $this->requireWorkspace($contentRepositoryId, $workspaceName);
-        try {
-            $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [
-                'content_repository_id' => $contentRepositoryId->value,
-                'workspace_name' => $workspaceName->value,
-                'subject_type' => $assignment->subject->type->value,
-                'subject' => $assignment->subject->value,
-                'role' => $assignment->role->value,
-            ]);
-        } catch (UniqueConstraintViolationException $e) {
-            throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e);
-        }
+        $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment);
     }
 
     /**
@@ -218,19 +196,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo
     public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void
     {
         $this->requireWorkspace($contentRepositoryId, $workspaceName);
-        try {
-            $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [
-                'content_repository_id' => $contentRepositoryId->value,
-                'workspace_name' => $workspaceName->value,
-                'subject_type' => $subject->type->value,
-                'subject' => $subject->value,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e);
-        }
-        if ($affectedRows === 0) {
-            throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071);
-        }
+        $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject);
     }
 
     /**
@@ -240,93 +206,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId,
      */
     public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments
     {
-        $table = self::TABLE_NAME_WORKSPACE_ROLE;
-        $query = <<<SQL
-            SELECT
-                *
-            FROM
-                {$table}
-            WHERE
-                content_repository_id = :contentRepositoryId
-                AND workspace_name = :workspaceName
-        SQL;
-        try {
-            $rows = $this->dbal->fetchAllAssociative($query, [
-                'contentRepositoryId' => $contentRepositoryId->value,
-                'workspaceName' => $workspaceName->value,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e);
-        }
-        return WorkspaceRoleAssignments::fromArray(
-            array_map(static fn (array $row) => WorkspaceRoleAssignment::create(
-                WorkspaceRoleSubject::create(
-                    WorkspaceRoleSubjectType::from($row['subject_type']),
-                    $row['subject'],
-                ),
-                WorkspaceRole::from($row['role']),
-            ), $rows)
-        );
-    }
-
-    /**
-     * Get the role with the most privileges for the specified {@see WorkspaceRoleSubjects} on workspace $workspaceName
-     *
-     * NOTE: This should never be used to evaluate permissions, instead {@see ContentRepositoryAuthorizationService::getWorkspacePermissions()} should be used!
-     */
-    public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjects $subjects): ?WorkspaceRole
-    {
-        $tableRole = self::TABLE_NAME_WORKSPACE_ROLE;
-        $roleCasesBySpecificity = implode("\n", array_map(static fn (WorkspaceRole $role) => "WHEN role='{$role->value}' THEN {$role->specificity()}\n", WorkspaceRole::cases()));
-        $query = <<<SQL
-            SELECT
-                role
-            FROM
-                {$tableRole}
-            WHERE
-                content_repository_id = :contentRepositoryId
-                AND workspace_name = :workspaceName
-                AND (
-                    (subject_type = :userSubjectType AND subject IN (:userSubjectValues))
-                    OR
-                    (subject_type = :groupSubjectType AND subject IN (:groupSubjectValues))
-                )
-            ORDER BY
-                /* We only want to return the most specific role so we order them and return the first row */
-                CASE
-                    {$roleCasesBySpecificity}
-                END
-                DESC
-            LIMIT 1
-        SQL;
-        $userSubjectValues = [];
-        $groupSubjectValues = [];
-        foreach ($subjects as $subject) {
-            if ($subject->type ===  WorkspaceRoleSubjectType::GROUP) {
-                $groupSubjectValues[] = $subject->value;
-            } else {
-                $userSubjectValues[] = $subject->value;
-            }
-        }
-        try {
-            $role = $this->dbal->fetchOne($query, [
-                'contentRepositoryId' => $contentRepositoryId->value,
-                'workspaceName' => $workspaceName->value,
-                'userSubjectType' => WorkspaceRoleSubjectType::USER->value,
-                'userSubjectValues' => $userSubjectValues,
-                'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value,
-                'groupSubjectValues' => $groupSubjectValues,
-            ], [
-                'userSubjectValues' => ArrayParameterType::STRING,
-                'groupSubjectValues' => ArrayParameterType::STRING,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to load role for workspace "%s" (content repository "%s"): %e', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1729325871, $e);
-        }
-        if ($role === false) {
-            return null;
-        }
-        return WorkspaceRole::from($role);
+        return $this->metadataAndRoleRepository->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName);
     }
 
     /**
@@ -362,13 +242,7 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId,
      */
     public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void
     {
-        try {
-            $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [
-                'content_repository_id' => $contentRepositoryId->value,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to prune workspace metadata Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512100, $e);
-        }
+        $this->metadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId);
     }
 
     /**
@@ -376,79 +250,11 @@ public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId)
      */
     public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void
     {
-        try {
-            $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [
-                'content_repository_id' => $contentRepositoryId->value,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to prune workspace roles for Content Repository "%s": %s', $contentRepositoryId->value, $e->getMessage()), 1729512142, $e);
-        }
+        $this->metadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId);
     }
 
     // ------------------
 
-    private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata
-    {
-        $table = self::TABLE_NAME_WORKSPACE_METADATA;
-        $query = <<<SQL
-            SELECT
-                *
-            FROM
-                {$table}
-            WHERE
-                content_repository_id = :contentRepositoryId
-                AND workspace_name = :workspaceName
-        SQL;
-        try {
-            $metadataRow = $this->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<string, mixed> $data
-     */
-    private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void
-    {
-        $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
-        try {
-            $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [
-                'content_repository_id' => $contentRepositoryId->value,
-                'workspace_name' => $workspaceName->value,
-            ]);
-            if ($affectedRows === 0) {
-                $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [
-                    'content_repository_id' => $contentRepositoryId->value,
-                    'workspace_name' => $workspaceName->value,
-                    'description' => '',
-                    'title' => $workspaceName->value,
-                    'classification' => $workspace->isRootWorkspace() ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value,
-                    ...$data,
-                ]);
-            }
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e);
-        }
-    }
-
     private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void
     {
         $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId);
@@ -459,44 +265,7 @@ private function createWorkspace(ContentRepositoryId $contentRepositoryId, Works
                 ContentStreamId::create()
             )
         );
-        $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId);
-    }
-
-    private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void
-    {
-        try {
-            $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [
-                'content_repository_id' => $contentRepositoryId->value,
-                'workspace_name' => $workspaceName->value,
-                'title' => $title->value,
-                'description' => $description->value,
-                'classification' => $classification->value,
-                'owner_user_id' => $ownerUserId?->value,
-            ]);
-        } catch (DbalException $e) {
-            throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e);
-        }
-    }
-
-    private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName
-    {
-        $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA;
-        $query = <<<SQL
-            SELECT
-                workspace_name
-            FROM
-                {$tableMetadata}
-            WHERE
-                content_repository_id = :contentRepositoryId
-                AND classification = :personalWorkspaceClassification
-                AND owner_user_id = :userId
-        SQL;
-        $workspaceName = $this->dbal->fetchOne($query, [
-            'contentRepositoryId' => $contentRepositoryId->value,
-            'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value,
-            'userId' => $userId->value,
-        ]);
-        return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName);
+        $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId);
     }
 
     private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace
diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index 0afdb315f02..ec3c91a93f3 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -20,7 +20,7 @@
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubjects;
-use Neos\Neos\Domain\Service\WorkspaceService;
+use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository;
 use Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege;
 use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
 use Neos\Neos\Security\Authorization\Privilege\SubtreeTagPrivilegeSubject;
@@ -36,7 +36,7 @@
     private const ROLE_NEOS_ADMINISTRATOR = 'Neos.Neos:Administrator';
 
     public function __construct(
-        private WorkspaceService $workspaceService,
+        private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository,
         private PolicyService $policyService,
         private PrivilegeManagerInterface $privilegeManager,
     ) {
@@ -50,8 +50,8 @@ public function __construct(
      */
     public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $roles, UserId|null $userId): WorkspacePermissions
     {
-        $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName);
-        if ($userId !== null && $workspaceMetadata->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) {
+        $workspaceMetadata = $this->metadataAndRoleRepository->loadWorkspaceMetadata($contentRepositoryId, $workspaceName);
+        if ($userId !== null && $workspaceMetadata?->ownerUserId !== null && $userId->equals($workspaceMetadata->ownerUserId)) {
             return WorkspacePermissions::all(sprintf('User with id "%s" is the owner of workspace "%s"', $userId->value, $workspaceName->value));
         }
         $roleIdentifiers = array_map(static fn (Role $role) => $role->getIdentifier(), array_values($roles));
@@ -60,7 +60,7 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId
             $subjects[] = WorkspaceRoleSubject::createForUser($userId);
         }
         $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true);
-        $userWorkspaceRole = $this->workspaceService->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
+        $userWorkspaceRole = $this->metadataAndRoleRepository->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects));
         if ($userWorkspaceRole === null) {
             if ($userIsAdministrator) {
                 return WorkspacePermissions::manage(sprintf('User is a Neos Administrator without explicit role for workspace "%s"', $workspaceName->value));

From 4f07965b295b93d105214bac67187ec893b34a24 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 17:52:29 +0100
Subject: [PATCH 44/58] FEATURE: Check permissions for operations inside
 `WorkspaceService`

---
 .../Domain/Service/WorkspaceService.php       | 27 ++++++++-
 .../Bootstrap/WorkspaceServiceTrait.php       | 57 +++++++++++--------
 2 files changed, 58 insertions(+), 26 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 9aa2c14670c..969b88f5c79 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -23,6 +23,8 @@
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Flow\Security\Exception\AccessDeniedException;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceClassification;
@@ -46,7 +48,10 @@
 {
     public function __construct(
         private ContentRepositoryRegistry $contentRepositoryRegistry,
-        private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository
+        private WorkspaceMetadataAndRoleRepository $metadataAndRoleRepository,
+        private UserService $userService,
+        private ContentRepositoryAuthorizationService $authorizationService,
+        private SecurityContext $securityContext,
     ) {
     }
 
@@ -76,6 +81,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
+        $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName);
         $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
         $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: $newWorkspaceTitle->value, description: null);
     }
@@ -87,6 +93,7 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work
      */
     public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void
     {
+        $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName);
         $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName);
         $this->metadataAndRoleRepository->updateWorkspaceMetadata($contentRepositoryId, $workspace, title: null, description: $newWorkspaceDescription->value);
     }
@@ -184,6 +191,7 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con
      */
     public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void
     {
+        $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName);
         $this->requireWorkspace($contentRepositoryId, $workspaceName);
         $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment);
     }
@@ -195,6 +203,7 @@ public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, Wo
      */
     public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubject $subject): void
     {
+        $this->requireManagementWorkspacePermission($contentRepositoryId, $workspaceName);
         $this->requireWorkspace($contentRepositoryId, $workspaceName);
         $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject);
     }
@@ -278,4 +287,20 @@ private function requireWorkspace(ContentRepositoryId $contentRepositoryId, Work
         }
         return $workspace;
     }
+
+    private function requireManagementWorkspacePermission(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void
+    {
+        if ($this->securityContext->areAuthorizationChecksDisabled()) {
+            return;
+        }
+        $workspacePermissions = $this->authorizationService->getWorkspacePermissions(
+            $contentRepositoryId,
+            $workspaceName,
+            $this->securityContext->getRoles(),
+            $this->userService->getCurrentUser()?->getId()
+        );
+        if (!$workspacePermissions->manage) {
+            throw new AccessDeniedException(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731343473);
+        }
+    }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index 74ba8b77b53..5377bfc6648 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -18,13 +18,12 @@
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
-use Neos\Flow\Security\Context;
+use Neos\Flow\Security\Context as SecurityContext;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceDescription;
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignment;
 use Neos\Neos\Domain\Model\WorkspaceRoleSubject;
-use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType;
 use Neos\Neos\Domain\Model\WorkspaceTitle;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Domain\Service\WorkspaceService;
@@ -132,11 +131,13 @@ public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspa
      */
     public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void
     {
-        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle(
-            $this->currentContentRepository->id,
-            WorkspaceName::fromString($workspaceName),
-            WorkspaceTitle::fromString($newTitle),
-        ));
+        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
+            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle(
+                $this->currentContentRepository->id,
+                WorkspaceName::fromString($workspaceName),
+                WorkspaceTitle::fromString($newTitle),
+            ))
+        );
     }
 
     /**
@@ -144,11 +145,13 @@ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTit
      */
     public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void
     {
-        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription(
-            $this->currentContentRepository->id,
-            WorkspaceName::fromString($workspaceName),
-            WorkspaceDescription::fromString($newDescription),
-        ));
+        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
+            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription(
+                $this->currentContentRepository->id,
+                WorkspaceName::fromString($workspaceName),
+                WorkspaceDescription::fromString($newDescription),
+            ))
+        );
     }
 
     /**
@@ -171,14 +174,16 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table
      */
     public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $userId = null): void
     {
-        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
-            $this->currentContentRepository->id,
-            WorkspaceName::fromString($workspaceName),
-            WorkspaceRoleAssignment::create(
-                $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
-                WorkspaceRole::from($role)
-            )
-        ));
+        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
+            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
+                $this->currentContentRepository->id,
+                WorkspaceName::fromString($workspaceName),
+                WorkspaceRoleAssignment::create(
+                    $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
+                    WorkspaceRole::from($role)
+                )
+            ))
+        );
     }
 
     /**
@@ -187,11 +192,13 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string
      */
     public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $userId = null): void
     {
-        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
-            $this->currentContentRepository->id,
-            WorkspaceName::fromString($workspaceName),
-            $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
-        ));
+        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
+            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
+                $this->currentContentRepository->id,
+                WorkspaceName::fromString($workspaceName),
+                $groupName !== null ? WorkspaceRoleSubject::createForGroup($groupName) : WorkspaceRoleSubject::createForUser(UserId::fromString($userId)),
+            ))
+        );
     }
 
     /**

From 99eb4764ac8d4cf7d9152ffea11c616bea948874 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 18:58:08 +0100
Subject: [PATCH 45/58] TASK: Make `pruneWorkspaceMetadata` and
 `pruneRoleAssignments` internal by not exposing on the `WorkspaceService`

They were introduce in #5306 for the pruning, but this is a purely internal task and there is no need for this to be api for the Neos User.
Also, the reason is that it cannot be protected with security easily.
---
 .../Classes/Command/CrCommandController.php      |  6 ++++--
 .../Classes/Domain/Service/WorkspaceService.php  | 16 ----------------
 2 files changed, 4 insertions(+), 18 deletions(-)

diff --git a/Neos.Neos/Classes/Command/CrCommandController.php b/Neos.Neos/Classes/Command/CrCommandController.php
index ac92121499a..83c8ce2d539 100644
--- a/Neos.Neos/Classes/Command/CrCommandController.php
+++ b/Neos.Neos/Classes/Command/CrCommandController.php
@@ -27,6 +27,7 @@
 use Neos\Neos\Domain\Model\WorkspaceRole;
 use Neos\Neos\Domain\Model\WorkspaceRoleAssignment;
 use Neos\Neos\Domain\Model\WorkspaceTitle;
+use Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository;
 use Neos\Neos\Domain\Service\WorkspaceService;
 use Neos\Utility\Files;
 
@@ -41,6 +42,7 @@ public function __construct(
         private readonly ProjectionReplayServiceFactory $projectionReplayServiceFactory,
         private readonly AssetUsageService $assetUsageService,
         private readonly WorkspaceService $workspaceService,
+        private readonly WorkspaceMetadataAndRoleRepository $workspaceMetadataAndRoleRepository,
         private readonly ProjectionReplayServiceFactory $projectionServiceFactory,
     ) {
         parent::__construct();
@@ -162,8 +164,8 @@ public function pruneCommand(string $contentRepository = 'default', bool $force
         );
 
         // remove the workspace metadata and role assignments for this cr
-        $this->workspaceService->pruneRoleAssignments($contentRepositoryId);
-        $this->workspaceService->pruneWorkspaceMetadata($contentRepositoryId);
+        $this->workspaceMetadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId);
+        $this->workspaceMetadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId);
 
         // reset the events table
         $contentStreamPruner->pruneAllWorkspacesAndContentStreamsFromEventStream();
diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 969b88f5c79..07cd7f8f064 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -246,22 +246,6 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId,
         throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479);
     }
 
-    /**
-     * Removes all workspace metadata records for the specified content repository id
-     */
-    public function pruneWorkspaceMetadata(ContentRepositoryId $contentRepositoryId): void
-    {
-        $this->metadataAndRoleRepository->pruneWorkspaceMetadata($contentRepositoryId);
-    }
-
-    /**
-     * Removes all workspace role assignments for the specified content repository id
-     */
-    public function pruneRoleAssignments(ContentRepositoryId $contentRepositoryId): void
-    {
-        $this->metadataAndRoleRepository->pruneRoleAssignments($contentRepositoryId);
-    }
-
     // ------------------
 
     private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void

From 139f3eb20200aa9385506f89352e19d59ce813d6 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 19:34:41 +0100
Subject: [PATCH 46/58] TASK: Adjust docs for privileg and permission v/o

---
 .../Classes/Feature/Security/Dto/Privilege.php                | 3 +++
 Neos.Neos/Classes/Domain/Model/NodePermissions.php            | 4 +++-
 Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php       | 4 +++-
 3 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
index 6712ab9279f..2cda976620d 100644
--- a/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
+++ b/Neos.ContentRepository.Core/Classes/Feature/Security/Dto/Privilege.php
@@ -36,6 +36,9 @@ public static function denied(string $reason): self
         return new self(false, $reason);
     }
 
+    /**
+     * Human-readable explanation for why this privilege was evaluated
+     */
     public function getReason(): string
     {
         return $this->reason;
diff --git a/Neos.Neos/Classes/Domain/Model/NodePermissions.php b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
index 28300b92af7..62201162bba 100644
--- a/Neos.Neos/Classes/Domain/Model/NodePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/NodePermissions.php
@@ -21,7 +21,6 @@
     /**
      * @param bool $read Permission to read data from the corresponding node
      * @param bool $edit Permission to edit the corresponding node
-     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
      */
     private function __construct(
         public bool $read,
@@ -53,6 +52,9 @@ public static function none(string $reason): self
         return new self(false, false, $reason);
     }
 
+    /**
+     * Human-readable explanation for why this permission was evaluated
+     */
     public function getReason(): string
     {
         return $this->reason;
diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
index 5761d61a74a..67f1f970110 100644
--- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
+++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php
@@ -23,7 +23,6 @@
      * @param bool $read Permission to read data from the corresponding workspace (e.g. get hold of and traverse the content graph)
      * @param bool $write Permission to write to the corresponding workspace, including publishing a derived workspace to it
      * @param bool $manage Permission to change the metadata and roles of the corresponding workspace (e.g. change description/title or add/remove workspace roles)
-     * @param string $reason Human-readable explanation for why this permission was evaluated {@see getReason()}
      */
     private function __construct(
         public bool $read,
@@ -63,6 +62,9 @@ public static function none(string $reason): self
         return new self(false, false, false, $reason);
     }
 
+    /**
+     * Human-readable explanation for why this permission was evaluated
+     */
     public function getReason(): string
     {
         return $this->reason;

From bf6e4e29cda80b4fd9d8c9f44589abf8f719690e Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 19:35:18 +0100
Subject: [PATCH 47/58] TASK: inline methods in
 ContentRepositoryAuthorizationService instead of having them private

---
 .../ContentRepositoryAuthorizationService.php | 37 +++++--------------
 1 file changed, 9 insertions(+), 28 deletions(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
index ec3c91a93f3..6de599cbd7c 100644
--- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
+++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php
@@ -82,7 +82,14 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId
      */
     public function getNodePermissions(Node $node, array $roles): NodePermissions
     {
-        return $this->nodePermissionsForRoles($node, $roles);
+        $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId);
+        $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason);
+        $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason);
+        return NodePermissions::create(
+            read: $readGranted,
+            edit: $writeGranted,
+            reason: $readReason . "\n" . $writeReason,
+        );
     }
 
     /**
@@ -91,17 +98,6 @@ public function getNodePermissions(Node $node, array $roles): NodePermissions
      * @param array<Role> $roles
      */
     public function getVisibilityConstraints(ContentRepositoryId $contentRepositoryId, array $roles): VisibilityConstraints
-    {
-        return VisibilityConstraints::fromTagConstraints($this->tagConstraintsForRoles($contentRepositoryId, $roles));
-    }
-
-    // ------------------------------
-
-
-    /**
-     * @param array<Role> $roles
-     */
-    private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId, array $roles): SubtreeTags
     {
         $restrictedSubtreeTags = SubtreeTags::createEmpty();
         /** @var ReadNodePrivilege $privilege */
@@ -110,21 +106,6 @@ private function tagConstraintsForRoles(ContentRepositoryId $contentRepositoryId
                 $restrictedSubtreeTags = $restrictedSubtreeTags->merge($privilege->getSubtreeTags());
             }
         }
-        return $restrictedSubtreeTags;
-    }
-
-    /**
-     * @param array<Role> $roles
-     */
-    private function nodePermissionsForRoles(Node $node, array $roles): NodePermissions
-    {
-        $subtreeTagPrivilegeSubject = new SubtreeTagPrivilegeSubject($node->tags->all(), $node->contentRepositoryId);
-        $readGranted = $this->privilegeManager->isGrantedForRoles($roles, ReadNodePrivilege::class, $subtreeTagPrivilegeSubject, $readReason);
-        $writeGranted = $this->privilegeManager->isGrantedForRoles($roles, EditNodePrivilege::class, $subtreeTagPrivilegeSubject, $writeReason);
-        return NodePermissions::create(
-            read: $readGranted,
-            edit: $writeGranted,
-            reason: $readReason . "\n" . $writeReason,
-        );
+        return VisibilityConstraints::fromTagConstraints($restrictedSubtreeTags);
     }
 }

From 80d4c31503a37a02529d28d797c05f4fac1892c8 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 19:35:33 +0100
Subject: [PATCH 48/58] TASK: Remove obsolete warnings in docs

---
 Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 07cd7f8f064..105a3126bff 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -76,8 +76,6 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W
 
     /**
      * Update/set title metadata for the specified workspace
-     *
-     * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed
      */
     public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void
     {
@@ -88,8 +86,6 @@ public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, Work
 
     /**
      * Update/set description metadata for the specified workspace
-     *
-     * NOTE: The workspace privileges are not evaluated for this interaction, this should be done in the calling side if needed
      */
     public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void
     {

From 843ceda60d2bae856437dcd0aed338692ad572ff Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 20:24:45 +0100
Subject: [PATCH 49/58] TASK: Fix cli output of `workspace:assignrole` to not
 throw error

---
 Neos.Neos/Classes/Command/WorkspaceCommandController.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
index c4ff1597e03..876e5b8d199 100644
--- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php
+++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php
@@ -299,7 +299,7 @@ public function assignRoleCommand(string $workspace, string $subject, string $ro
                 $workspaceRole
             )
         );
-        $this->outputLine('<success>Assigned role "%s" to subject "%s" for workspace "%s"</success>', [$workspaceRole->value, $roleSubject, $workspaceName->value]);
+        $this->outputLine('<success>Assigned role "%s" to subject "%s" for workspace "%s"</success>', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]);
     }
 
     /**

From abd32d33ce8e3caf407b91799b60584d05582055 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Mon, 11 Nov 2024 20:25:16 +0100
Subject: [PATCH 50/58] TASK: Declare `AbstractSubtreeTagBasedPrivilege`
 internal as it doesnt make sense to be extended

---
 .../Privilege/AbstractSubtreeTagBasedPrivilege.php              | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
index e42051d4bc9..227cb75d6aa 100644
--- a/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
+++ b/Neos.Neos/Classes/Security/Authorization/Privilege/AbstractSubtreeTagBasedPrivilege.php
@@ -22,7 +22,7 @@
 
 /**
  * Common base class for privileges that evaluate {@see SubtreeTagPrivilegeSubject}s
- * @see ReadNodePrivilege, EditNodePrivilege
+ * @internal the public API is {@see ReadNodePrivilege, EditNodePrivilege}
  */
 abstract class AbstractSubtreeTagBasedPrivilege extends AbstractPrivilege
 {

From 93c1b8bd5b70193391423fa64344f091278100db Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 13 Nov 2024 13:07:40 +0100
Subject: [PATCH 51/58] Add more tests

---
 .../Security/EditNodePrivilege.feature        |  84 ++++++----
 .../Security/ReadNodePrivilege.feature        |  53 +++++--
 .../Security/WorkspaceAccess.feature          | 108 -------------
 .../Security/WorkspacePermissions.feature     | 147 ++++++++++++++++++
 4 files changed, 241 insertions(+), 151 deletions(-)
 delete mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
 create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature

diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
index d90d4448763..d279d035287 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature
@@ -6,15 +6,26 @@ Feature: EditNodePrivilege related features
       """
       privilegeTargets:
         'Neos\Neos\Security\Authorization\Privilege\EditNodePrivilege':
-          'Neos.Neos:EditBlog':
-            matcher: 'blog'
+          'Neos.Neos:EditSubtreeA':
+            matcher: 'subtree_a'
+      roles:
+        'Neos.Neos:RoleWithPrivilegeToEditSubtree':
+          privileges:
+            -
+              privilegeTarget: 'Neos.Neos:EditSubtreeA'
+              permission: GRANT
       """
     And using the following content dimensions:
       | Identifier | Values                | Generalizations                     |
       | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
     And using the following node types:
     """yaml
-    'Neos.Neos:Document': {}
+    'Neos.Neos:Document':
+      properties:
+        foo:
+          type: string
+      references:
+        ref: []
     """
     And using identifier "default", I define a content repository
     And I am in content repository "default"
@@ -41,34 +52,55 @@ Feature: EditNodePrivilege related features
       | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
       | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
     And the following Neos users exist:
-      | Username | First name | Last name | Roles                                            |
-      | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
-      | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
-      | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
-
-  Scenario: TODO
-    Given I am in workspace "live"
+      | Username              | First name | Last name  | Roles                                                     |
+      | admin                 | Armin      | Admin      | Neos.Neos:Administrator                                   |
+      | restricted_editor     | Rich       | Restricted | Neos.Neos:RestrictedEditor                                |
+      | editor                | Edward     | Editor     | Neos.Neos:Editor                                          |
+      | editor_with_privilege | Pete       | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToEditSubtree |
+    And I am in workspace "live"
     And I am in dimension space point {"language":"de"}
     And the command TagSubtree is executed with payload:
       | Key                          | Value                |
       | nodeAggregateId              | "a"                  |
       | nodeVariantSelectionStrategy | "allSpecializations" |
-      | tag                          | "blog"               |
-    And the role MANAGER is assigned to workspace "live" for user "jane.doe"
-    When content repository security is enabled
-    And I am authenticated as "jane.doe"
-    When the command DisableNodeAggregate is executed with payload and exceptions are caught:
+      | tag                          | "subtree_a"          |
+    And the command DisableNodeAggregate is executed with payload:
       | Key                          | Value         |
-      | nodeAggregateId              | "a1a"         |
+      | nodeAggregateId              | "a1a1a"       |
       | nodeVariantSelectionStrategy | "allVariants" |
+    And the role COLLABORATOR is assigned to workspace "live" for group "Neos.Neos:Editor"
+    When a personal workspace for user "editor" is created
+    And content repository security is enabled
+
+  Scenario Outline: Handling all relevant EditNodePrivilege related commands with different users
+    Given I am authenticated as "editor"
+    When the command <command> is executed with payload '<command 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 <command> is executed with payload '<command 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 <command> is executed with payload '<command payload>' and exceptions are caught
     Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686
-#    Then the last command should have thrown an exception of type "AccessDenied" with message:
-#    """
-#    Command "Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate" was denied: No edit permissions for node "a1a" in workspace "live": Evaluated following 2 privilege target(s):
-#    "Neos.Neos:ReadBlog": ABSTAIN
-#    "Neos.Neos:ReadBlog": GRANT
-#    (1 granted, 0 denied, 1 abstained)
-#    Evaluated following 1 privilege target(s):
-#    "Neos.Neos:EditBlog": ABSTAIN
-#    (0 granted, 0 denied, 1 abstained)
-#    """
+
+    When I am authenticated as "editor_with_privilege"
+    And the command <command> is executed with payload '<command payload>'
+
+    When I am in workspace "edward-editor"
+    And the command <command> is executed with payload '<command payload>' and exceptions are caught
+    Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686
+
+    Examples:
+      | command                     | command payload                                                                                        |
+      | CreateNodeAggregateWithNode | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"}           |
+      | CreateNodeVariant           | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"en"}}             |
+      | DisableNodeAggregate        | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"}                                  |
+      | EnableNodeAggregate         | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"}                               |
+      | RemoveNodeAggregate         | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"}                                  |
+      | TagSubtree                  | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"}                 |
+      | UntagSubtree                | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"}                 |
+      | MoveNodeAggregate           | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"}                                                |
+      | SetNodeProperties           | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}}                                                |
+      | SetNodeReferences           | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} |
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
index f30fc37739f..2545f9c7bac 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature
@@ -6,12 +6,13 @@ Feature: ReadNodePrivilege related features
       """
       privilegeTargets:
         'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege':
-          'Neos.Neos:ReadBlog':
-            matcher: 'blog'
+          'Neos.Neos:ReadSubtreeA':
+            matcher: 'subtree_a'
       roles:
-        'Neos.Neos:Administrator':
+        'Neos.Neos:RoleWithPrivilegeToReadSubtree':
           privileges:
-            - privilegeTarget: 'Neos.Neos:ReadBlog'
+            -
+              privilegeTarget: 'Neos.Neos:ReadSubtreeA'
               permission: GRANT
       """
     And using the following content dimensions:
@@ -19,11 +20,15 @@ Feature: ReadNodePrivilege related features
       | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
     And using the following node types:
     """yaml
-    'Neos.Neos:Document': {}
+    'Neos.Neos:Document':
+      properties:
+        foo:
+          type: string
+      references:
+        ref: []
     """
     And using identifier "default", I define a content repository
     And I am in content repository "default"
-    And I am user identified by "initiating-user-identifier"
     And the command CreateRootWorkspace is executed with payload:
       | Key                | Value           |
       | workspaceName      | "live"          |
@@ -47,22 +52,36 @@ Feature: ReadNodePrivilege related features
       | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
       | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
     And the following Neos users exist:
-      | Id      | Username | First name | Last name | Roles                                            |
-      | janedoe | jane.doe | Jane       | Doe       | Neos.Neos:Administrator                          |
-      | johndoe | john.doe | John       | Doe       | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
-      | editor  | editor   | Edward     | Editor    | Neos.Neos:Editor                                 |
-
-  Scenario: TODO
-    Given I am in workspace "live"
+      | Username              | First name | Last name  | Roles                                                     |
+      | admin                 | Armin      | Admin      | Neos.Neos:Administrator                                   |
+      | restricted_editor     | Rich       | Restricted | Neos.Neos:RestrictedEditor                                |
+      | editor                | Edward     | Editor     | Neos.Neos:Editor                                          |
+      | editor_with_privilege | Pete       | Privileged | Neos.Neos:Editor,Neos.Neos:RoleWithPrivilegeToReadSubtree |
+    And I am in workspace "live"
     And I am in dimension space point {"language":"de"}
     And the command TagSubtree is executed with payload:
       | Key                          | Value                |
       | nodeAggregateId              | "a"                  |
       | nodeVariantSelectionStrategy | "allSpecializations" |
-      | tag                          | "blog"               |
+      | tag                          | "subtree_a"          |
     And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody"
-    When content repository security is enabled
-    And I am authenticated as "john.doe"
+    When a personal workspace for user "editor" is created
+    And content repository security is enabled
+
+  Scenario Outline: Read tagged node as user without corresponding ReadNodePrivilege
+    And I am authenticated as "<user>"
     Then I should not be able to read node "a1"
-    When I am authenticated as "jane.doe"
+
+    Examples:
+      | user              |
+      | admin             |
+      | restricted_editor |
+      | editor            |
+
+  Scenario Outline: Read tagged node as user with corresponding ReadNodePrivilege
+    And I am authenticated as "<user>"
     Then I should be able to read node "a1"
+
+    Examples:
+      | user                  |
+      | editor_with_privilege |
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
deleted file mode 100644
index 473901a678f..00000000000
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspaceAccess.feature
+++ /dev/null
@@ -1,108 +0,0 @@
-@flowEntities
-Feature: Workspace access related features
-
-  Background:
-    Given The following additional policies are configured:
-      """
-      privilegeTargets:
-        'Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege':
-          'Neos.Neos:ReadBlog':
-            matcher: 'blog'
-      roles:
-        'Neos.Neos:Administrator':
-          privileges:
-            - privilegeTarget: 'Neos.Neos:ReadBlog'
-              permission: GRANT
-      """
-    And using the following content dimensions:
-      | Identifier | Values                | Generalizations                     |
-      | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
-    And using the following node types:
-    """yaml
-    'Neos.Neos:Document': {}
-    """
-    And using identifier "default", I define a content repository
-    And I am in content repository "default"
-    And I am user identified by "initiating-user-identifier"
-    And the command CreateRootWorkspace is executed with payload:
-      | Key                | Value           |
-      | workspaceName      | "live"          |
-      | newContentStreamId | "cs-identifier" |
-    And I am in workspace "live" and dimension space point {}
-    And the following Neos users exist:
-      | Username          | Roles                                            |
-      | admin             | Neos.Neos:Administrator                          |
-      | editor            | Neos.Neos:Editor                                 |
-      | restricted_editor | Neos.Neos:RestrictedEditor,Neos.Neos:UserManager |
-      | no_editor         |                                                  |
-
-  Scenario: TODO
-    When content repository security is enabled
-    And I am authenticated as "admin"
-    And I access the content graph for workspace "live"
-    Then an exception of type "AccessDenied" should be thrown with message:
-    """
-    Read access denied for workspace "live": Account "admin" is a Neos Administrator without explicit role for workspace "live"
-    """
-
-  Scenario: TODO
-    Given the role MANAGER is assigned to workspace "live" for user "admin"
-    When content repository security is enabled
-    And I am authenticated as "admin"
-    And I access the content graph for workspace "live"
-    Then no exception should be thrown
-
-  Scenario Outline: Accessing content graph for explicitly assigned workspace role to the authenticated user
-    Given the role <workspace role> is assigned to workspace "live" for user "<user>"
-    When content repository security is enabled
-    And I am authenticated as "<user>"
-    And I access the content graph for workspace "live"
-    Then no exception should be thrown
-
-    Examples:
-      | user              | workspace role |
-      | admin             | VIEWER         |
-      | editor            | COLLABORATOR   |
-      | editor            | VIEWER         |
-      | restricted_editor | MANAGER        |
-      | restricted_editor | VIEWER         |
-
-  Scenario Outline: Accessing content graph for workspace role assigned to group of the authenticated user
-    Given the role <workspace role> is assigned to workspace "live" for group "<group>"
-    When content repository security is enabled
-    And I am authenticated as "<user>"
-    And I access the content graph for workspace "live"
-    Then no exception should be thrown
-
-    Examples:
-      | user              | group                      | workspace role |
-      | admin             | Neos.Neos:Editor           | COLLABORATOR   |
-      | editor            | Neos.Neos:Editor           | COLLABORATOR   |
-      | restricted_editor | Neos.Neos:RestrictedEditor | VIEWER         |
-      | no_editor         | Neos.Flow:Everybody        | VIEWER         |
-
-  Scenario Outline: Accessing content graph for workspace role assigned to group the authenticated user is not part of
-    Given the role <workspace role> is assigned to workspace "live" for group "<group>"
-    When content repository security is enabled
-    And I am authenticated as "<user>"
-    And I access the content graph for workspace "live"
-    Then an exception of type "AccessDenied" should be thrown
-
-    Examples:
-      | user              | group                   | workspace role |
-      | admin             | Neos.Flow:Anonymous     | COLLABORATOR   |
-      | editor            | Neos.Neos:Administrator | MANAGER        |
-      | restricted_editor | Neos.Neos:Editor        | VIEWER         |
-
-  Scenario Outline: Accessing content graph for workspace that is owned by the authenticated user
-    Given the personal workspace "user-workspace" is created with the target workspace "live" for user "<user>"
-    When content repository security is enabled
-    And I am authenticated as "<user>"
-    And I access the content graph for workspace "user-workspace"
-    Then no exception should be thrown
-
-    Examples:
-      | user              |
-      | admin             |
-      | editor            |
-      | restricted_editor |
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature
new file mode 100644
index 00000000000..b7ba9bfb5cc
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature
@@ -0,0 +1,147 @@
+@flowEntities
+Feature: Workspace permission related features
+
+  Background:
+    When using the following content dimensions:
+      | Identifier | Values                | Generalizations                     |
+      | language   | mul, de, en, gsw, ltz | ltz->de->mul, gsw->de->mul, en->mul |
+    And using the following node types:
+    """yaml
+    'Neos.Neos:Document':
+      properties:
+        foo:
+          type: string
+      references:
+        ref: []
+    'Neos.Neos:Document2': {}
+    'Neos.Neos:CustomRoot':
+      superTypes:
+        'Neos.ContentRepository:Root': true
+    """
+    And using identifier "default", I define a content repository
+    And I am in content repository "default"
+    And the command CreateRootWorkspace is executed with payload:
+      | Key                | Value           |
+      | workspaceName      | "live"          |
+      | newContentStreamId | "cs-identifier" |
+    And I am in workspace "live" and dimension space point {}
+    And the command CreateRootNodeAggregateWithNode is executed with payload:
+      | Key             | Value                         |
+      | nodeAggregateId | "root"                        |
+      | nodeTypeName    | "Neos.ContentRepository:Root" |
+    And the following CreateNodeAggregateWithNode commands are executed:
+      | nodeAggregateId | nodeTypeName       | parentNodeAggregateId | nodeName | originDimensionSpacePoint |
+      | a               | Neos.Neos:Document | root                  | a        | {"language":"mul"}        |
+      | a1              | Neos.Neos:Document | a                     | a1       | {"language":"de"}         |
+      | a1a             | Neos.Neos:Document | a1                    | a1a      | {"language":"de"}         |
+      | a1a1            | Neos.Neos:Document | a1a                   | a1a1     | {"language":"de"}         |
+      | a1a1a           | Neos.Neos:Document | a1a1                  | a1a1a    | {"language":"de"}         |
+      | a1a1b           | Neos.Neos:Document | a1a1                  | a1a1b    | {"language":"de"}         |
+      | a1a2            | Neos.Neos:Document | a1a                   | a1a2     | {"language":"de"}         |
+      | a1b             | Neos.Neos:Document | a1                    | a1b      | {"language":"de"}         |
+      | a2              | Neos.Neos:Document | a                     | a2       | {"language":"de"}         |
+      | b               | Neos.Neos:Document | root                  | b        | {"language":"de"}         |
+      | b1              | Neos.Neos:Document | b                     | b1       | {"language":"de"}         |
+    And the following Neos users exist:
+      | Username          | Roles                      |
+      | admin             | Neos.Neos:Administrator    |
+      | editor            | Neos.Neos:Editor           |
+      | restricted_editor | Neos.Neos:RestrictedEditor |
+      | owner             | Neos.Neos:Editor           |
+      | manager           | Neos.Neos:Editor           |
+      | collaborator      | Neos.Neos:Editor           |
+      | uninvolved        | Neos.Neos:Editor           |
+    And I am in workspace "live"
+    And I am in dimension space point {"language":"de"}
+    And the command TagSubtree is executed with payload:
+      | Key                          | Value                |
+      | nodeAggregateId              | "a"                  |
+      | nodeVariantSelectionStrategy | "allSpecializations" |
+      | tag                          | "subtree_a"          |
+    And the command DisableNodeAggregate is executed with payload:
+      | Key                          | Value         |
+      | nodeAggregateId              | "a1a1a"       |
+      | nodeVariantSelectionStrategy | "allVariants" |
+    And the personal workspace "workspace" is created with the target workspace "live" for user "owner"
+    And I am in workspace "workspace"
+    And the role MANAGER is assigned to workspace "workspace" for user "manager"
+    And the role COLLABORATOR is assigned to workspace "workspace" for user "collaborator"
+    # The following step was added in order to make the `AddDimensionShineThrough` command viable
+    And I change the content dimensions in content repository "default" to:
+      | Identifier | Values      | Generalizations |
+      | language   | mul, de, ch | ch->de->mul     |
+    And content repository security is enabled
+
+  Scenario Outline: Creating a root workspace
+    Given I am authenticated as <user>
+    When the command CreateRootWorkspace is executed with payload '{"workspaceName":"new-ws","newContentStreamId":"new-cs"}' and exceptions are caught
+    Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686
+
+    Examples:
+      | user              |
+      | admin             |
+      | editor            |
+      | restricted_editor |
+
+  Scenario Outline: Deleting a workspace without MANAGE permissions
+    Given I am authenticated as <user>
+    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 <user>
+    When the command DeleteWorkspace is executed with payload '{"workspaceName":"workspace"}'
+
+    Examples:
+      | user    |
+      | admin   |
+      | manager |
+      | owner   |
+
+  Scenario Outline: Handling commands that require WRITE permissions on the workspace
+    When I am authenticated as "editor"
+    And the command <command> is executed with payload '<command 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 <command> is executed with payload '<command 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 <command> is executed with payload '<command 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 <command> is executed with payload '<command payload>'
+
+    Examples:
+      | command                             | command payload                                                                                        |
+      | CreateNodeAggregateWithNode         | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"}           |
+      | CreateNodeVariant                   | {"nodeAggregateId":"a1","sourceOrigin":{"language":"de"},"targetOrigin":{"language":"mul"}}             |
+      | DisableNodeAggregate                | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"}                                  |
+      | EnableNodeAggregate                 | {"nodeAggregateId":"a1a1a","nodeVariantSelectionStrategy":"allVariants"}                               |
+      | RemoveNodeAggregate                 | {"nodeAggregateId":"a1","nodeVariantSelectionStrategy":"allVariants"}                                  |
+      | TagSubtree                          | {"nodeAggregateId":"a1","tag":"some_tag","nodeVariantSelectionStrategy":"allVariants"}                 |
+      | UntagSubtree                        | {"nodeAggregateId":"a","tag":"subtree_a","nodeVariantSelectionStrategy":"allVariants"}                 |
+      | MoveNodeAggregate                   | {"nodeAggregateId":"a1","newParentNodeAggregateId":"b"}                                                |
+      | SetNodeProperties                   | {"nodeAggregateId":"a1","propertyValues":{"foo":"bar"}}                                                |
+      | SetNodeReferences                   | {"sourceNodeAggregateId":"a1","references":[{"referenceName": "ref", "references": [{"target":"b"}]}]} |
+
+      | AddDimensionShineThrough            | {"nodeAggregateId":"a1","source":{"language":"de"},"target":{"language":"ch"}}                         |
+      | ChangeNodeAggregateName             | {"nodeAggregateId":"a1","newNodeName":"changed"}                                                       |
+      | ChangeNodeAggregateType             | {"nodeAggregateId":"a1","newNodeTypeName":"Neos.Neos:Document2","strategy":"happypath"}                |
+      | CreateRootNodeAggregateWithNode     | {"nodeAggregateId":"c","nodeTypeName":"Neos.Neos:CustomRoot"}                                          |
+      | MoveDimensionSpacePoint             | {"source":{"language":"de"},"target":{"language":"ch"}}                                                |
+      | UpdateRootNodeAggregateDimensions   | {"nodeAggregateId":"root"}                                                                             |
+      | DiscardWorkspace                    | {}                                                                                                     |
+      | DiscardIndividualNodesFromWorkspace | {"nodesToDiscard":[{"nodeAggregateId":"a1"}]}                                                          |
+      | PublishWorkspace                    | {}                                                                                                     |
+      | PublishIndividualNodesFromWorkspace | {"nodesToPublish":[{"nodeAggregateId":"a1"}]}                                                          |
+      | RebaseWorkspace                     | {}                                                                                                     |
+      | CreateWorkspace                     | {"workspaceName":"new-workspace","baseWorkspaceName":"workspace","newContentStreamId":"any"}           |
+

From 810b0a3d430b878db4044ca649a7643cfd0362e3 Mon Sep 17 00:00:00 2001
From: Bastian Waidelich <b.waidelich@wwwision.de>
Date: Wed, 13 Nov 2024 13:10:29 +0100
Subject: [PATCH 52/58] Prefix ContentRepositorySecurityTrait fields to avoid
 naming clashes

---
 .../ContentRepositorySecurityTrait.php        | 40 +++++++++----------
 1 file changed, 20 insertions(+), 20 deletions(-)

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
index c8bc89a542e..188186397ae 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -54,14 +54,14 @@ trait ContentRepositorySecurityTrait
     use CRBehavioralTestsSubjectProvider;
     use ExceptionsTrait;
 
-    private bool $flowSecurityEnabled = false;
-    private bool $contentRepositorySecurityEnabled = false;
+    private bool $crSecurity_flowSecurityEnabled = false;
+    private bool $crSecurity_contentRepositorySecurityEnabled = false;
 
-    private ?TestingProvider $testingProvider = null;
+    private ?TestingProvider $crSecurity_testingProvider = null;
 
-    private ?ActionRequest $mockActionRequest = null;
+    private ?ActionRequest $crSecurity_mockActionRequest = null;
 
-    private static ?string $testingPolicyPathAndFilename = null;
+    private static ?string $crSecurity_testingPolicyPathAndFilename = null;
 
     /**
      * @template T of object
@@ -74,40 +74,40 @@ abstract private function getObject(string $className): object;
     public function resetContentRepositorySecurity(): void
     {
         TestingAuthProvider::resetAuthProvider();
-        $this->contentRepositorySecurityEnabled = false;
+        $this->crSecurity_contentRepositorySecurityEnabled = false;
     }
 
     #[BeforeFeature]
     #[AfterFeature]
     public static function resetPolicies(): void
     {
-        if (self::$testingPolicyPathAndFilename !== null && file_exists(self::$testingPolicyPathAndFilename)) {
-            unlink(self::$testingPolicyPathAndFilename);
+        if (self::$crSecurity_testingPolicyPathAndFilename !== null && file_exists(self::$crSecurity_testingPolicyPathAndFilename)) {
+            unlink(self::$crSecurity_testingPolicyPathAndFilename);
         }
     }
 
     private function enableFlowSecurity(): void
     {
-        if ($this->flowSecurityEnabled === true) {
+        if ($this->crSecurity_flowSecurityEnabled === true) {
             return;
         }
         $this->getObject(PrivilegeManagerInterface::class)->reset();
 
         $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class);
 
-        $this->testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider'];
+        $this->crSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider'];
 
         $securityContext = $this->getObject(SecurityContext::class);
         $securityContext->clearContext();
         $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/');
-        $this->mockActionRequest = ActionRequest::fromHttpRequest($httpRequest);
-        $securityContext->setRequest($this->mockActionRequest);
-        $this->flowSecurityEnabled = true;
+        $this->crSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest);
+        $securityContext->setRequest($this->crSecurity_mockActionRequest);
+        $this->crSecurity_flowSecurityEnabled = true;
     }
 
     private function enableContentRepositorySecurity(): void
     {
-        if ($this->contentRepositorySecurityEnabled === true) {
+        if ($this->crSecurity_contentRepositorySecurityEnabled === true) {
             return;
         }
         $contentRepositoryAuthProviderFactory = $this->getObject(ContentRepositoryAuthProviderFactory::class);
@@ -126,18 +126,18 @@ public function __construct(
         $contentRepositoryAuthProvider = $contentRepositoryAuthProviderFactory->build($this->currentContentRepository->id, $contentGraphProjection->getState());
 
         TestingAuthProvider::replaceAuthProvider($contentRepositoryAuthProvider);
-        $this->contentRepositorySecurityEnabled = true;
+        $this->crSecurity_contentRepositorySecurityEnabled = true;
     }
 
     private function authenticateAccount(Account $account): void
     {
         $this->enableFlowSecurity();
-        $this->testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
-        $this->testingProvider->setAccount($account);
+        $this->crSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
+        $this->crSecurity_testingProvider->setAccount($account);
 
         $securityContext = $this->getObject(SecurityContext::class);
         $securityContext->clearContext();
-        $securityContext->setRequest($this->mockActionRequest);
+        $securityContext->setRequest($this->crSecurity_mockActionRequest);
         $this->getObject(AuthenticationProviderManager::class)->authenticate();
     }
 
@@ -161,8 +161,8 @@ public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $polici
         $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true);
         $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw()));
 
-        self::$testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml';
-        file_put_contents(self::$testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration));
+        self::$crSecurity_testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml';
+        file_put_contents(self::$crSecurity_testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration));
 
         ObjectAccess::setProperty($policyService, 'initialized', false, true);
         $this->getObject(ConfigurationManager::class)->flushConfigurationCache();

From 33c0877d1e33af4d25e949361cf269c3f8518444 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 07:58:38 +0100
Subject: [PATCH 53/58] TASK: Fix new Neos security tests and fake
 `PolicyService` correctly

- we must not write the settings to the file system via $crSecurity_testingPolicyPathAndFilename as following processes and tests in the same context would be affected. Also flushing and reloading the configuration catch (`flushConfigurationCache`) is expensive and the tests must not mess with that low level flow behaviour

- instead we only rely on faking the `PolicyService`'s runtime caches. This is done by injecting a custom configuration into `$policyConfiguration` and then initialising the `PolicyService`
---
 .../ContentRepositorySecurityTrait.php        | 46 ++++++++++++-------
 1 file changed, 29 insertions(+), 17 deletions(-)

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
index 188186397ae..7260cd10844 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -61,8 +61,6 @@ trait ContentRepositorySecurityTrait
 
     private ?ActionRequest $crSecurity_mockActionRequest = null;
 
-    private static ?string $crSecurity_testingPolicyPathAndFilename = null;
-
     /**
      * @template T of object
      * @param class-string<T> $className
@@ -75,15 +73,14 @@ public function resetContentRepositorySecurity(): void
     {
         TestingAuthProvider::resetAuthProvider();
         $this->crSecurity_contentRepositorySecurityEnabled = false;
-    }
+        $this->crSecurity_flowSecurityEnabled = false;
+
+        $policyService = $this->getObject(PolicyService::class);
+        // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager)
+        $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset()
+        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
+        $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class));
 
-    #[BeforeFeature]
-    #[AfterFeature]
-    public static function resetPolicies(): void
-    {
-        if (self::$crSecurity_testingPolicyPathAndFilename !== null && file_exists(self::$crSecurity_testingPolicyPathAndFilename)) {
-            unlink(self::$crSecurity_testingPolicyPathAndFilename);
-        }
     }
 
     private function enableFlowSecurity(): void
@@ -157,15 +154,30 @@ public function contentRepositorySecurityIsEnabled(): void
     public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void
     {
         $policyService = $this->getObject(PolicyService::class);
-        $policyService->getRoles(); // force initialization
-        $policyConfiguration = ObjectAccess::getProperty($policyService, 'policyConfiguration', true);
-        $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule($policyConfiguration, Yaml::parse($policies->getRaw()));
 
-        self::$crSecurity_testingPolicyPathAndFilename = $this->getObject(Environment::class)->getPathToTemporaryDirectory() . 'Policy.yaml';
-        file_put_contents(self::$crSecurity_testingPolicyPathAndFilename, Yaml::dump($mergedPolicyConfiguration));
+        $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule(
+            $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY),
+            Yaml::parse($policies->getRaw())
+        );
+
+        // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used)
+        // we can change the roles and privileges at runtime :D
+        $policyService->reset(); // TODO also reset privilegeTargets in ->reset()
+        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
+        $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager
+        {
+            public function __construct(
+                private array $mergedPolicyConfiguration
+            ) {
+            }
 
-        ObjectAccess::setProperty($policyService, 'initialized', false, true);
-        $this->getObject(ConfigurationManager::class)->flushConfigurationCache();
+            public function getConfiguration(string $configurationType, string $configurationPath = null)
+            {
+                Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType);
+                Assert::assertSame(null, $configurationPath);
+                return $this->mergedPolicyConfiguration;
+            }
+        });
     }
 
     /**

From 3e73e68de492e2cdef7b495ac01a2cf53f254a36 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 08:06:41 +0100
Subject: [PATCH 54/58] TASK: Split `ContentRepositorySecurityTrait` and
 `FlowSecurityTrait`

---
 .../ContentRepositorySecurityTrait.php        |  95 +------------
 .../Features/Bootstrap/FlowSecurityTrait.php  | 132 ++++++++++++++++++
 2 files changed, 136 insertions(+), 91 deletions(-)
 create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
index 7260cd10844..86fc2bb454e 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ContentRepositorySecurityTrait.php
@@ -12,9 +12,6 @@
  * source code.
  */
 
-use Behat\Gherkin\Node\PyStringNode;
-use Behat\Hook\AfterFeature;
-use Behat\Hook\BeforeFeature;
 use Behat\Hook\BeforeScenario;
 use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider;
 use Neos\ContentRepository\Core\Factory\ContentRepositoryServiceFactoryDependencies;
@@ -24,25 +21,11 @@
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\ContentRepository\TestSuite\Behavior\Features\Bootstrap\Helpers\TestingAuthProvider;
-use Neos\Flow\Configuration\ConfigurationManager;
 use Neos\Flow\Mvc\ActionRequest;
-use Neos\Flow\Security\Account;
-use Neos\Flow\Security\Authentication\AuthenticationProviderManager;
 use Neos\Flow\Security\Authentication\Provider\TestingProvider;
-use Neos\Flow\Security\Authentication\TokenAndProviderFactoryInterface;
-use Neos\Flow\Security\Authentication\TokenInterface;
-use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
-use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Flow\Security\Policy\PolicyService;
-use Neos\Flow\Utility\Environment;
 use Neos\Neos\Domain\Service\UserService;
-use Neos\Neos\Security\Authorization\Privilege\ReadNodePrivilege;
 use Neos\Neos\Security\ContentRepositoryAuthProvider\ContentRepositoryAuthProviderFactory;
-use Neos\Utility\Arrays;
-use Neos\Utility\ObjectAccess;
 use PHPUnit\Framework\Assert;
-use Psr\Http\Message\ServerRequestFactoryInterface;
-use Symfony\Component\Yaml\Yaml;
 
 /**
  * Step implementations and helper for Content Repository Security related tests inside Neos.Neos
@@ -53,6 +36,7 @@ trait ContentRepositorySecurityTrait
 {
     use CRBehavioralTestsSubjectProvider;
     use ExceptionsTrait;
+    use FlowSecurityTrait;
 
     private bool $crSecurity_flowSecurityEnabled = false;
     private bool $crSecurity_contentRepositorySecurityEnabled = false;
@@ -68,38 +52,13 @@ trait ContentRepositorySecurityTrait
      */
     abstract private function getObject(string $className): object;
 
-    #[BeforeScenario]
+    /**
+     * @BeforeScenario
+     */
     public function resetContentRepositorySecurity(): void
     {
         TestingAuthProvider::resetAuthProvider();
         $this->crSecurity_contentRepositorySecurityEnabled = false;
-        $this->crSecurity_flowSecurityEnabled = false;
-
-        $policyService = $this->getObject(PolicyService::class);
-        // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager)
-        $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset()
-        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
-        $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class));
-
-    }
-
-    private function enableFlowSecurity(): void
-    {
-        if ($this->crSecurity_flowSecurityEnabled === true) {
-            return;
-        }
-        $this->getObject(PrivilegeManagerInterface::class)->reset();
-
-        $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class);
-
-        $this->crSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider'];
-
-        $securityContext = $this->getObject(SecurityContext::class);
-        $securityContext->clearContext();
-        $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/');
-        $this->crSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest);
-        $securityContext->setRequest($this->crSecurity_mockActionRequest);
-        $this->crSecurity_flowSecurityEnabled = true;
     }
 
     private function enableContentRepositorySecurity(): void
@@ -126,18 +85,6 @@ public function __construct(
         $this->crSecurity_contentRepositorySecurityEnabled = true;
     }
 
-    private function authenticateAccount(Account $account): void
-    {
-        $this->enableFlowSecurity();
-        $this->crSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
-        $this->crSecurity_testingProvider->setAccount($account);
-
-        $securityContext = $this->getObject(SecurityContext::class);
-        $securityContext->clearContext();
-        $securityContext->setRequest($this->crSecurity_mockActionRequest);
-        $this->getObject(AuthenticationProviderManager::class)->authenticate();
-    }
-
     /**
      * @Given content repository security is enabled
      */
@@ -147,39 +94,6 @@ public function contentRepositorySecurityIsEnabled(): void
         $this->enableContentRepositorySecurity();
     }
 
-
-    /**
-     * @Given The following additional policies are configured:
-     */
-    public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void
-    {
-        $policyService = $this->getObject(PolicyService::class);
-
-        $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule(
-            $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY),
-            Yaml::parse($policies->getRaw())
-        );
-
-        // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used)
-        // we can change the roles and privileges at runtime :D
-        $policyService->reset(); // TODO also reset privilegeTargets in ->reset()
-        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
-        $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager
-        {
-            public function __construct(
-                private array $mergedPolicyConfiguration
-            ) {
-            }
-
-            public function getConfiguration(string $configurationType, string $configurationPath = null)
-            {
-                Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType);
-                Assert::assertSame(null, $configurationPath);
-                return $this->mergedPolicyConfiguration;
-            }
-        });
-    }
-
     /**
      * @When I am authenticated as :username
      */
@@ -189,7 +103,6 @@ public function iAmAuthenticatedAs(string $username): void
         $this->authenticateAccount($user->getAccounts()->first());
     }
 
-
     /**
      * @When I access the content graph for workspace :workspaceName
      */
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
new file mode 100644
index 00000000000..9e2123e3c70
--- /dev/null
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Neos.Neos package.
+ *
+ * (c) Contributors of the Neos Project - www.neos.io
+ *
+ * This package is Open Source Software. For the full copyright and license
+ * information, please view the LICENSE file which was distributed with this
+ * source code.
+ */
+
+use Behat\Gherkin\Node\PyStringNode;
+use Behat\Hook\BeforeScenario;
+use Neos\Flow\Configuration\ConfigurationManager;
+use Neos\Flow\Mvc\ActionRequest;
+use Neos\Flow\Security\Account;
+use Neos\Flow\Security\Authentication\AuthenticationProviderManager;
+use Neos\Flow\Security\Authentication\Provider\TestingProvider;
+use Neos\Flow\Security\Authentication\TokenAndProviderFactoryInterface;
+use Neos\Flow\Security\Authentication\TokenInterface;
+use Neos\Flow\Security\Authorization\PrivilegeManagerInterface;
+use Neos\Flow\Security\Context as SecurityContext;
+use Neos\Flow\Security\Policy\PolicyService;
+use Neos\Utility\Arrays;
+use Neos\Utility\ObjectAccess;
+use PHPUnit\Framework\Assert;
+use Psr\Http\Message\ServerRequestFactoryInterface;
+use Symfony\Component\Yaml\Yaml;
+
+/**
+ * Step implementations and helper for Flow Security related tests inside Neos.Neos
+ *
+ * TODO this should wander to the Flow core at some point.
+ *
+ * @internal only for behat tests within the Neos.Neos package
+ */
+trait FlowSecurityTrait
+{
+    private bool $flowSecurity_securityEnabled = false;
+
+    private ?TestingProvider $flowSecurity_testingProvider = null;
+
+    private ?ActionRequest $flowSecurity_mockActionRequest = null;
+
+    /**
+     * @template T of object
+     * @param class-string<T> $className
+     * @return T
+     */
+    abstract protected function getObject(string $className): object;
+
+    /**
+     * @BeforeScenario
+     */
+    final public function resetFlowSecurity(): void
+    {
+        $this->flowSecurity_securityEnabled = false;
+
+        $policyService = $this->getObject(PolicyService::class);
+        // reset the $policyConfiguration to the default (fetched from the original ConfigurationManager)
+        $this->getObject(PolicyService::class)->reset(); // TODO also reset privilegeTargets in ->reset()
+        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
+        $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class));
+
+        $this->getObject(SecurityContext::class)->clearContext();
+        $this->getObject(PrivilegeManagerInterface::class)->reset();
+    }
+
+    final protected function enableFlowSecurity(): void
+    {
+        if ($this->flowSecurity_securityEnabled === true) {
+            return;
+        }
+
+        $tokenAndProviderFactory = $this->getObject(TokenAndProviderFactoryInterface::class);
+
+        $this->flowSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider'];
+
+        $securityContext = $this->getObject(SecurityContext::class);
+        $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/');
+        $this->flowSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest);
+        $securityContext->setRequest($this->flowSecurity_mockActionRequest);
+        $this->flowSecurity_securityEnabled = true;
+    }
+
+    final protected function authenticateAccount(Account $account): void
+    {
+        $this->enableFlowSecurity();
+        $this->flowSecurity_testingProvider->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
+        $this->flowSecurity_testingProvider->setAccount($account);
+
+        $securityContext = $this->getObject(SecurityContext::class);
+        $securityContext->clearContext();
+        $securityContext->setRequest($this->flowSecurity_mockActionRequest);
+        $this->getObject(AuthenticationProviderManager::class)->authenticate();
+    }
+
+    /**
+     * @Given The following additional policies are configured:
+     */
+    final public function theFollowingAdditionalPoliciesAreConfigured(PyStringNode $policies): void
+    {
+        $policyService = $this->getObject(PolicyService::class);
+
+        $mergedPolicyConfiguration = Arrays::arrayMergeRecursiveOverrule(
+            $this->getObject(ConfigurationManager::class)->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY),
+            Yaml::parse($policies->getRaw())
+        );
+
+        // if we de-initialise the PolicyService and set a new $policyConfiguration (by injecting a stub ConfigurationManager which will be used)
+        // we can change the roles and privileges at runtime :D
+        $policyService->reset(); // TODO also reset privilegeTargets in ->reset()
+        ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
+        $policyService->injectConfigurationManager(new class ($mergedPolicyConfiguration) extends ConfigurationManager
+        {
+            public function __construct(
+                private array $mergedPolicyConfiguration
+            ) {
+            }
+
+            public function getConfiguration(string $configurationType, string $configurationPath = null)
+            {
+                Assert::assertSame(ConfigurationManager::CONFIGURATION_TYPE_POLICY, $configurationType);
+                Assert::assertSame(null, $configurationPath);
+                return $this->mergedPolicyConfiguration;
+            }
+        });
+    }
+}

From db43d1f26afbb8d229adcf6c77c22a4791dfba98 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 08:08:04 +0100
Subject: [PATCH 55/58] TASK: Cleanup imports

---
 .../ContentRepositoryAuthProvider.php                        | 5 -----
 .../ContentRepositoryAuthProviderFactory.php                 | 1 -
 2 files changed, 6 deletions(-)

diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
index 0ece429d71f..6bd844d8a5a 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php
@@ -8,15 +8,12 @@
 use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\AddDimensionShineThrough;
 use Neos\ContentRepository\Core\Feature\DimensionSpaceAdjustment\Command\MoveDimensionSpacePoint;
 use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNode;
-use Neos\ContentRepository\Core\Feature\NodeCreation\Command\CreateNodeAggregateWithNodeAndSerializedProperties;
 use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\DisableNodeAggregate;
 use Neos\ContentRepository\Core\Feature\NodeDisabling\Command\EnableNodeAggregate;
 use Neos\ContentRepository\Core\Feature\NodeDuplication\Command\CopyNodesRecursively;
 use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties;
-use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetSerializedNodeProperties;
 use Neos\ContentRepository\Core\Feature\NodeMove\Command\MoveNodeAggregate;
 use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences;
-use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetSerializedNodeReferences;
 use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate;
 use Neos\ContentRepository\Core\Feature\NodeRenaming\Command\ChangeNodeAggregateName;
 use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType;
@@ -38,13 +35,11 @@
 use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
-use Neos\ContentRepository\Core\Projection\ContentGraph\Node;
 use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress;
 use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName;
 use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Neos\Domain\Model\NodePermissions;
 use Neos\Neos\Domain\Model\WorkspacePermissions;
 use Neos\Neos\Domain\Service\UserService;
 use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService;
diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
index cf3d2fb9ac5..cc2ebb54dab 100644
--- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
+++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProviderFactory.php
@@ -4,7 +4,6 @@
 
 namespace Neos\Neos\Security\ContentRepositoryAuthProvider;
 
-use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphProjectionInterface;
 use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface;
 use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId;
 use Neos\ContentRepositoryRegistry\Factory\AuthProvider\AuthProviderFactoryInterface;

From f1415b8301266d0fa6f0af7424eadea2fe5b3669 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 08:19:51 +0100
Subject: [PATCH 56/58] TASK: Cleanup imports

Now we throw the cr AccessDenied exception instead of flows access denied in all places of the workspace service to make behaviour consistent.

$contentRepository->handle might for example throw the `AccessDenied` when creating a workspace
---
 Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 105a3126bff..95be477088c 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -14,6 +14,7 @@
 
 namespace Neos\Neos\Domain\Service;
 
+use Neos\ContentRepository\Core\Feature\Security\Exception\AccessDenied;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace;
 use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists;
@@ -24,7 +25,6 @@
 use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
 use Neos\Flow\Annotations as Flow;
 use Neos\Flow\Security\Context as SecurityContext;
-use Neos\Flow\Security\Exception\AccessDeniedException;
 use Neos\Neos\Domain\Model\User;
 use Neos\Neos\Domain\Model\UserId;
 use Neos\Neos\Domain\Model\WorkspaceClassification;
@@ -280,7 +280,7 @@ private function requireManagementWorkspacePermission(ContentRepositoryId $conte
             $this->userService->getCurrentUser()?->getId()
         );
         if (!$workspacePermissions->manage) {
-            throw new AccessDeniedException(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731343473);
+            throw new AccessDenied(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731654519);
         }
     }
 }

From efca1247f240b9d78756dca0cd7827ab08e7a9b0 Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 08:20:34 +0100
Subject: [PATCH 57/58] TASK: Remove `withoutAuthorizationChecks` in tests

And introduce the behaviour of the `FunctionalTestCase`:
https://github.com/neos/flow-development-collection/commit/b9c89e3e08649cbb5366cb769b2f79b0f13bd68e
---
 .../Features/Bootstrap/FlowSecurityTrait.php  |  5 +-
 .../Bootstrap/WorkspaceServiceTrait.php       | 54 ++++++++-----------
 2 files changed, 27 insertions(+), 32 deletions(-)

diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
index 9e2123e3c70..5f4915dfcf1 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
@@ -65,7 +65,10 @@ final public function resetFlowSecurity(): void
         ObjectAccess::setProperty($policyService, 'privilegeTargets', [], true);
         $policyService->injectConfigurationManager($this->getObject(ConfigurationManager::class));
 
-        $this->getObject(SecurityContext::class)->clearContext();
+        $securityContext = $this->getObject(SecurityContext::class);
+        $securityContext->clearContext();
+        // todo add setter! Also used in FunctionalTestCase https://github.com/neos/flow-development-collection/commit/b9c89e3e08649cbb5366cb769b2f79b0f13bd68e
+        ObjectAccess::setProperty($securityContext, 'authorizationChecksDisabled', true, true);
         $this->getObject(PrivilegeManagerInterface::class)->reset();
     }
 
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
index e1a3ece74af..af046a39443 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php
@@ -132,13 +132,11 @@ public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspa
      */
     public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void
     {
-        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
-            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle(
-                $this->currentContentRepository->id,
-                WorkspaceName::fromString($workspaceName),
-                WorkspaceTitle::fromString($newTitle),
-            ))
-        );
+        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle(
+            $this->currentContentRepository->id,
+            WorkspaceName::fromString($workspaceName),
+            WorkspaceTitle::fromString($newTitle),
+        ));
     }
 
     /**
@@ -146,13 +144,11 @@ public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTit
      */
     public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void
     {
-        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
-            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription(
-                $this->currentContentRepository->id,
-                WorkspaceName::fromString($workspaceName),
-                WorkspaceDescription::fromString($newDescription),
-            ))
-        );
+        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription(
+            $this->currentContentRepository->id,
+            WorkspaceName::fromString($workspaceName),
+            WorkspaceDescription::fromString($newDescription),
+        ));
     }
 
     /**
@@ -180,16 +176,14 @@ public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string
         } else {
             $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username));
         }
-        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
-            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
-                $this->currentContentRepository->id,
-                WorkspaceName::fromString($workspaceName),
-                WorkspaceRoleAssignment::create(
-                    $subject,
-                    WorkspaceRole::from($role)
-                )
-            ))
-        );
+        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole(
+            $this->currentContentRepository->id,
+            WorkspaceName::fromString($workspaceName),
+            WorkspaceRoleAssignment::create(
+                $subject,
+                WorkspaceRole::from($role)
+            )
+        ));
     }
 
     /**
@@ -203,13 +197,11 @@ public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $
         } else {
             $subject = WorkspaceRoleSubject::createForUser($this->userIdForUsername($username));
         }
-        $this->getObject(SecurityContext::class)->withoutAuthorizationChecks(fn () =>
-            $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
-                $this->currentContentRepository->id,
-                WorkspaceName::fromString($workspaceName),
-                $subject,
-            ))
-        );
+        $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole(
+            $this->currentContentRepository->id,
+            WorkspaceName::fromString($workspaceName),
+            $subject,
+        ));
     }
 
     /**

From 8768298c1ed45802094fc59be20e1e82c9c4a46f Mon Sep 17 00:00:00 2001
From: mhsdesign <85400359+mhsdesign@users.noreply.github.com>
Date: Fri, 15 Nov 2024 08:54:05 +0100
Subject: [PATCH 58/58] TASK: Add security tests for workspace service

assert that base workspace creation is only allowed for writing

and that managing (setting title and roles) is only allowed to managers
---
 .../Domain/Service/WorkspaceService.php       |  2 +-
 .../Features/Bootstrap/ExceptionsTrait.php    |  6 +-
 .../Security/WorkspacePermissions.feature     | 69 +++++++++++++++++++
 3 files changed, 75 insertions(+), 2 deletions(-)

diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
index 95be477088c..b4021fae155 100644
--- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
+++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php
@@ -280,7 +280,7 @@ private function requireManagementWorkspacePermission(ContentRepositoryId $conte
             $this->userService->getCurrentUser()?->getId()
         );
         if (!$workspacePermissions->manage) {
-            throw new AccessDenied(sprintf('The current user does not have manage permissions for workspace "%s" in content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1731654519);
+            throw new AccessDenied(sprintf('Managing workspace "%s" in "%s" was denied: %s', $workspaceName->value, $contentRepositoryId->value, $workspacePermissions->getReason()), 1731654519);
         }
     }
 }
diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
index 5a87e107249..b2257a5c4d2 100644
--- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
+++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php
@@ -38,10 +38,11 @@ private function tryCatchingExceptions(\Closure $callback): mixed
     }
 
     /**
+     * @Then an exception of type :expectedShortExceptionName should be thrown with code :code
      * @Then an exception of type :expectedShortExceptionName should be thrown with message:
      * @Then an exception of type :expectedShortExceptionName should be thrown
      */
-    public function anExceptionShouldBeThrown(string $expectedShortExceptionName, PyStringNode $expectedExceptionMessage = null): void
+    public function anExceptionShouldBeThrown(string $expectedShortExceptionName, ?int $code = null, PyStringNode $expectedExceptionMessage = null): void
     {
         Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown');
         $lastCaughtExceptionShortName = (new \ReflectionClass($this->lastCaughtException))->getShortName();
@@ -49,6 +50,9 @@ public function anExceptionShouldBeThrown(string $expectedShortExceptionName, Py
         if ($expectedExceptionMessage !== null) {
             Assert::assertSame($expectedExceptionMessage->getRaw(), $this->lastCaughtException->getMessage());
         }
+        if ($code !== null) {
+            Assert::assertSame($code, $this->lastCaughtException->getCode());
+        }
         $this->lastCaughtException = null;
     }
 
diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature
index b7ba9bfb5cc..7b02cca647b 100644
--- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature
+++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature
@@ -82,6 +82,35 @@ Feature: Workspace permission related features
       | admin             |
       | editor            |
       | restricted_editor |
+      | owner             |
+      | collaborator      |
+      | uninvolved        |
+
+  Scenario Outline: Creating a base workspace without WRITE permissions
+    Given I am authenticated as <user>
+    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 <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 <user>
+    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 <user>
+
+    Examples:
+      | user         |
+      | collaborator |
+      | owner        |
 
   Scenario Outline: Deleting a workspace without MANAGE permissions
     Given I am authenticated as <user>
@@ -103,7 +132,43 @@ Feature: Workspace permission related features
       | manager |
       | owner   |
 
+  Scenario Outline: Managing metadata and roles of a workspace without MANAGE permissions
+    Given I am authenticated as <user>
+    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 <user>
+    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 <command> is executed with payload '<command 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 <command> is executed with payload '<command payload>' and exceptions are caught
     Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686
@@ -119,6 +184,10 @@ Feature: Workspace permission related features
     When I am authenticated as "owner"
     And the command <command> is executed with payload '<command payload>'
 
+    # todo test also collaborator, but cannot commands twice here:
+    # When I am authenticated as "collaborator"
+    # And the command <command> is executed with payload '<command payload>' and exceptions are caught
+
     Examples:
       | command                             | command payload                                                                                        |
       | CreateNodeAggregateWithNode         | {"nodeAggregateId":"a1b1","parentNodeAggregateId":"a1b","nodeTypeName":"Neos.Neos:Document"}           |