diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceDoesNotExist.php b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceDoesNotExist.php index 3def569da87..26004ba393b 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceDoesNotExist.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Exception/WorkspaceDoesNotExist.php @@ -14,18 +14,28 @@ namespace Neos\ContentRepository\Core\SharedModel\Exception; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; /** - * @api because exception is thrown during invariant checks on command execution + * @api because exception is thrown during invariant checks on command execution or when attempting to query a non-existing workspace */ final class WorkspaceDoesNotExist extends \DomainException { public static function butWasSupposedTo(WorkspaceName $name): self { return new self(sprintf( - 'The source workspace %s does not exist', + 'The workspace "%s" does not exist', $name->value ), 1513924741); } + + public static function butWasSupposedToInContentRepository(WorkspaceName $name, ContentRepositoryId $contentRepositoryId): self + { + return new self(sprintf( + 'The workspace "%s" does not exist in content repository "%s"', + $name->value, + $contentRepositoryId->value + ), 1733737361); + } } diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 80d849bb17c..3348f1b73b3 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -716,7 +716,7 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): ]; $roleAssignments[] = [ 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, - 'subject' => 'Neos.Neos:Everybody', + 'subject' => 'Neos.Flow:Everybody', 'role' => WorkspaceRole::VIEWER->value, ]; } elseif ($isInternalWorkspace) { diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 876e5b8d199..ac290f02685 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Command; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; @@ -31,6 +30,7 @@ use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; @@ -148,7 +148,8 @@ public function createRootCommand(string $name, string $contentRepository = 'def $contentRepositoryId, $workspaceName, WorkspaceTitle::fromString($title ?? $name), - WorkspaceDescription::fromString($description ?? '') + WorkspaceDescription::fromString($description ?? ''), + WorkspaceRoleAssignments::createEmpty() ); $this->outputLine('Created root workspace "%s" in content repository "%s"', [$workspaceName->value, $contentRepositoryId->value]); } @@ -206,6 +207,7 @@ public function createSharedCommand(string $workspace, string $baseWorkspace = ' WorkspaceTitle::fromString($title ?? $workspaceName->value), WorkspaceDescription::fromString($description ?? ''), WorkspaceName::fromString($baseWorkspace), + WorkspaceRoleAssignments::createEmpty() ); $this->outputLine('Created shared workspace "%s"', [$workspaceName->value]); } @@ -400,11 +402,7 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $this->workspacePublishingService->discardAllWorkspaceChanges($contentRepositoryId, $workspaceName); } - $contentRepositoryInstance->handle( - DeleteWorkspace::create( - $workspaceName - ) - ); + $this->workspaceService->deleteWorkspace($contentRepositoryId, $workspaceName); $this->outputLine('Deleted workspace "%s"', [$workspaceName->value]); } diff --git a/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php index 788dad93734..3e0b582d438 100644 --- a/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php +++ b/Neos.Neos/Classes/Domain/Import/LiveWorkspaceCreationProcessor.php @@ -20,8 +20,7 @@ use Neos\ContentRepository\Export\ProcessorInterface; use Neos\ContentRepository\Export\Severity; use Neos\Neos\Domain\Model\WorkspaceDescription; -use Neos\Neos\Domain\Model\WorkspaceRole; -use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\WorkspaceService; @@ -44,7 +43,12 @@ public function run(ProcessingContext $context): void $context->dispatch(Severity::NOTICE, 'Workspace already exists, skipping'); return; } - $this->workspaceService->createRootWorkspace($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceTitle::fromString('Live workspace'), WorkspaceDescription::fromString('')); - $this->workspaceService->assignWorkspaceRole($this->contentRepository->id, WorkspaceName::forLive(), WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + $this->workspaceService->createRootWorkspace( + $this->contentRepository->id, + WorkspaceName::forLive(), + WorkspaceTitle::fromString('Public live workspace'), + WorkspaceDescription::empty(), + WorkspaceRoleAssignments::createForLiveWorkspace() + ); } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php index f8206c8d2ce..418e7134e22 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -42,4 +42,10 @@ public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role ); } + + public function equals(WorkspaceRoleAssignment $other): bool + { + return $this->subject->equals($other->subject) + && $this->role === $other->role; + } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php index a63eb23b899..87b33bc0d3b 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php @@ -5,6 +5,7 @@ namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; +use Neos\Neos\Domain\Service\WorkspaceService; /** * A set of {@see WorkspaceRoleAssignment} instances @@ -25,6 +26,16 @@ private function __construct(WorkspaceRoleAssignment ...$assignments) $this->assignments = $assignments; } + public static function createEmpty(): self + { + return new self(); + } + + public static function create(WorkspaceRoleAssignment ...$assignments): self + { + return new self(...$assignments); + } + /** * @param array $assignments */ @@ -33,6 +44,44 @@ public static function fromArray(array $assignments): self return new self(...$assignments); } + /** + * Default role assignment to be specified at creation via {@see WorkspaceService::createRootWorkspace()} + * + * Users with the role "Neos.Neos:LivePublisher" are collaborators and everybody can read. + */ + public static function createForLiveWorkspace(): self + { + return new self( + WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:LivePublisher', + WorkspaceRole::COLLABORATOR + ), + WorkspaceRoleAssignment::createForGroup( + 'Neos.Flow:Everybody', + WorkspaceRole::VIEWER + ) + ); + } + + /** + * Default role assignment to be specified at creation via {@see WorkspaceService::createSharedWorkspace()} + * + * Users with the role "Neos.Neos:AbstractEditor" are collaborators and the specified user is manager + */ + public static function createForSharedWorkspace(UserId $userId): self + { + return new self( + WorkspaceRoleAssignment::createForUser( + $userId, + WorkspaceRole::MANAGER, + ), + WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:AbstractEditor', + WorkspaceRole::COLLABORATOR, + ) + ); + } + public function isEmpty(): bool { return $this->assignments === []; @@ -47,4 +96,19 @@ public function count(): int { return count($this->assignments); } + + public function contains(WorkspaceRoleAssignment $assignment): bool + { + foreach ($this->assignments as $existingAssignment) { + if ($existingAssignment->equals($assignment)) { + return true; + } + } + return false; + } + + public function withAssignment(WorkspaceRoleAssignment $assignment): self + { + return new self(...[...$this->assignments, $assignment]); + } } diff --git a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php index cf320af71f5..c5ce393682a 100644 --- a/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php +++ b/Neos.Neos/Classes/Domain/Repository/WorkspaceMetadataAndRoleRepository.php @@ -178,6 +178,40 @@ public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $c return WorkspaceRole::from($role); } + public function deleteWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf( + 'Failed to delete metadata for workspace "%s" (Content Repository "%s"): %s', + $workspaceName->value, + $contentRepositoryId->value, + $e->getMessage() + ), 1726821159, $e); + } + } + + public function deleteWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + try { + $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf( + 'Failed to delete role assignments for workspace "%s" (Content Repository "%s"): %s', + $workspaceName->value, + $contentRepositoryId->value, + $e->getMessage() + ), 1726821159, $e); + } + } + /** * Removes all workspace metadata records for the specified content repository id */ @@ -306,7 +340,7 @@ public function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W } } - public function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName + public function findWorkspaceNameByUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName { $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; $query = <<dbal->transactional($fn); + } } diff --git a/Neos.Neos/Classes/Domain/Service/SiteServiceInternals.php b/Neos.Neos/Classes/Domain/Service/SiteServiceInternals.php index 5842e6366db..09d64fefd46 100644 --- a/Neos.Neos/Classes/Domain/Service/SiteServiceInternals.php +++ b/Neos.Neos/Classes/Domain/Service/SiteServiceInternals.php @@ -33,6 +33,9 @@ use Neos\Neos\Domain\Exception\SiteNodeTypeIsInvalid; use Neos\Neos\Domain\Model\Site; use Neos\Neos\Domain\Model\SiteNodeName; +use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; +use Neos\Neos\Domain\Model\WorkspaceTitle; /** * @internal FIXME refactor and incorporate into SiteService @@ -89,7 +92,16 @@ public function removeSiteNode(SiteNodeName $siteNodeName): void public function createSiteNodeIfNotExists(Site $site, string $nodeTypeName): void { - $this->workspaceService->createLiveWorkspaceIfMissing($this->contentRepository->id); + $liveWorkspace = $this->contentRepository->findWorkspaceByName(WorkspaceName::forLive()); + if ($liveWorkspace === null) { + $this->workspaceService->createRootWorkspace( + $this->contentRepository->id, + WorkspaceName::forLive(), + WorkspaceTitle::fromString('Public live workspace'), + WorkspaceDescription::empty(), + WorkspaceRoleAssignments::createForLiveWorkspace() + ); + } $sitesNodeIdentifier = $this->getOrCreateRootNodeAggregate(); $siteNodeType = $this->nodeTypeManager->getNodeType($nodeTypeName); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index b4021fae155..00ac32de62c 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -18,7 +18,9 @@ use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; +use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -95,13 +97,11 @@ public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId } /** - * Retrieve the currently active personal workspace for the specified $userId - * - * NOTE: Currently there can only ever be a single personal workspace per user. But this API already prepares support for multiple personal workspaces per user + * Retrieve the personal workspace for the specified user, if no workspace exist an exception is thrown. */ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace { - $workspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); + $workspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $userId); if ($workspaceName === null) { throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); } @@ -111,9 +111,19 @@ public function getPersonalWorkspaceForUser(ContentRepositoryId $contentReposito /** * Create a new root (aka base) workspace with the specified metadata * + * To ensure that editors can publish to the live workspace and to allow everybody to view it an assignment like {@see WorkspaceRoleAssignments::createForLiveWorkspace} needs to be specified: + * + * $this->workspaceService->createRootWorkspace( + * $contentRepositoryId, + * WorkspaceName::forLive(), + * WorkspaceTitle::fromString('Public live workspace'), + * WorkspaceDescription::empty(), + * WorkspaceRoleAssignments::createForLiveWorkspace() + * ); + * * @throws WorkspaceAlreadyExists */ - public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description): void + public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceRoleAssignments $assignments): void { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $contentRepository->handle( @@ -122,48 +132,74 @@ public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, Wo ContentStreamId::create() ) ); - $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); - } - /** - * Create the "live" root workspace with the default role assignment (users with the role "Neos.Neos:LivePublisher" are collaborators) - */ - public function createLiveWorkspaceIfMissing(ContentRepositoryId $contentRepositoryId): void - { - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $workspaceName = WorkspaceName::forLive(); - $liveWorkspace = $contentRepository->findWorkspaceByName($workspaceName); - if ($liveWorkspace !== null) { - // live workspace already exists - return; - } - $this->createRootWorkspace($contentRepositoryId, $workspaceName, WorkspaceTitle::fromString('Public live workspace'), WorkspaceDescription::empty()); - $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Neos:LivePublisher', WorkspaceRole::COLLABORATOR)); + $this->metadataAndRoleRepository->transactional(function () use ($contentRepositoryId, $workspaceName, $title, $description, $assignments) { + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, ownerUserId: null); + foreach ($assignments as $assignment) { + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment); + } + }); } /** - * Create a new, personal, workspace for the specified user + * Create a new, personal, workspace for the specified user (fails if the user already owns a workspace) */ public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId $ownerId): void { - $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::PERSONAL); + $existingUserWorkspace = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $ownerId); + if ($existingUserWorkspace !== null) { + throw new \RuntimeException(sprintf('Failed to create personal workspace "%s" for user with id "%s", because the workspace "%s" is already assigned to the user', $workspaceName->value, $ownerId->value, $existingUserWorkspace->value), 1733754904); + } + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + ContentStreamId::create() + ) + ); + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::PERSONAL, $ownerId); } /** * Create a new, potentially shared, workspace + * + * To ensure that the user can manage the shared workspace and to enable collaborates an assignment like {@see WorkspaceRoleAssignments::createForSharedWorkspace} needs to be specified: + * + * $this->workspaceService->createWorkspace( + * ..., + * assignments: WorkspaceRoleAssignments::createForSharedWorkspace( + * $currentUser->getId() + * ) + * ); + * + * NOTE: By default - if no role assignments are specified - only administrators can manage workspaces without role assignments. */ - public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName): void + public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, WorkspaceRoleAssignments $assignments): void { - $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, null, WorkspaceClassification::SHARED); + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + ContentStreamId::create() + ) + ); + + $this->metadataAndRoleRepository->transactional(function () use ($contentRepositoryId, $workspaceName, $title, $description, $assignments) { + $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::SHARED, ownerUserId: null); + foreach ($assignments as $assignment) { + $this->metadataAndRoleRepository->assignWorkspaceRole($contentRepositoryId, $workspaceName, $assignment); + } + }); } /** * Create a new, personal, workspace for the specified user if none exists yet - * @internal experimental api, until actually used by the Neos.Ui */ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void { - $existingWorkspaceName = $this->metadataAndRoleRepository->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + $existingWorkspaceName = $this->metadataAndRoleRepository->findWorkspaceNameByUser($contentRepositoryId, $user->getId()); if ($existingWorkspaceName !== null) { $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); return; @@ -204,6 +240,24 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, $this->metadataAndRoleRepository->unassignWorkspaceRole($contentRepositoryId, $workspaceName, $subject); } + /** + * Deletes a content repository workspace and also all role assignments and metadata + */ + public function deleteWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireWorkspace($contentRepositoryId, $workspaceName); + + $contentRepository->handle( + DeleteWorkspace::create( + $workspaceName + ) + ); + + $this->metadataAndRoleRepository->deleteWorkspaceMetadata($contentRepositoryId, $workspaceName); + $this->metadataAndRoleRepository->deleteWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); + } + /** * Get all role assignments for the specified workspace * @@ -211,6 +265,7 @@ public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, */ public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments { + $this->requireWorkspace($contentRepositoryId, $workspaceName); return $this->metadataAndRoleRepository->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); } @@ -244,26 +299,16 @@ public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, // ------------------ - private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void - { - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspaceName, - ContentStreamId::create() - ) - ); - $this->metadataAndRoleRepository->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); - } - + /** + * @throws WorkspaceDoesNotExist if the workspace does not exist + */ private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace { $workspace = $this->contentRepositoryRegistry ->get($contentRepositoryId) ->findWorkspaceByName($workspaceName); if ($workspace === null) { - throw new \RuntimeException(sprintf('Failed to find workspace with name "%s" for content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1718379722); + throw WorkspaceDoesNotExist::butWasSupposedToInContentRepository($workspaceName, $contentRepositoryId); } return $workspace; } diff --git a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php index 6de599cbd7c..b187e63f7a6 100644 --- a/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php +++ b/Neos.Neos/Classes/Security/Authorization/ContentRepositoryAuthorizationService.php @@ -59,6 +59,10 @@ public function getWorkspacePermissions(ContentRepositoryId $contentRepositoryId if ($userId !== null) { $subjects[] = WorkspaceRoleSubject::createForUser($userId); } + /** + * We hardcode the check against administrators to always grant manage permissions. This is done to allow administrators to fix permissions of all workspaces. + * We don't allow all rights like read and write. Admins should be able to grant themselves permissions to write to other personal workspaces, but they should not have this permission automagically. + */ $userIsAdministrator = in_array(self::ROLE_NEOS_ADMINISTRATOR, $roleIdentifiers, true); $userWorkspaceRole = $this->metadataAndRoleRepository->getMostPrivilegedWorkspaceRoleForSubjects($contentRepositoryId, $workspaceName, WorkspaceRoleSubjects::fromArray($subjects)); if ($userWorkspaceRole === null) { diff --git a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php index 74bfd788aa4..73c4bb6bf3f 100644 --- a/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php +++ b/Neos.Neos/Classes/Security/ContentRepositoryAuthProvider/ContentRepositoryAuthProvider.php @@ -26,6 +26,7 @@ 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\WorkspaceCreation\Exception\BaseWorkspaceDoesNotExist; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; @@ -36,6 +37,8 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\ContentGraphReadModelInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceHasNoBaseWorkspaceName; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Security\Context as SecurityContext; @@ -116,6 +119,13 @@ public function canExecuteCommand(CommandInterface $command): Privilege if ($command instanceof CreateRootWorkspace) { return Privilege::denied('Creation of root workspaces is currently only allowed with disabled authorization checks'); } + if ($command instanceof CreateWorkspace) { + $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->baseWorkspaceName); + if (!$baseWorkspacePermissions->read) { + return Privilege::denied(sprintf('Missing "read" permissions for base workspace "%s": %s', $command->baseWorkspaceName->value, $baseWorkspacePermissions->getReason())); + } + return Privilege::granted(sprintf('User has "read" permissions for base workspace "%s"', $command->baseWorkspaceName->value)); + } if ($command instanceof ChangeBaseWorkspace) { $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName); if (!$workspacePermissions->manage) { @@ -127,6 +137,28 @@ public function canExecuteCommand(CommandInterface $command): Privilege } return Privilege::granted(sprintf('User has "manage" permissions for workspace "%s" and "read" permissions for base workspace "%s"', $command->workspaceName->value, $command->baseWorkspaceName->value)); } + if ($command instanceof PublishWorkspace || $command instanceof PublishIndividualNodesFromWorkspace) { + $workspacePermissions = $this->getWorkspacePermissionsForCurrentUser($command->workspaceName); + if (!$workspacePermissions->write) { + return Privilege::denied(sprintf('Missing "write" permissions for workspace "%s": %s', $command->workspaceName->value, $workspacePermissions->getReason())); + } + $workspace = $this->contentGraphReadModel->findWorkspaceByName($command->workspaceName); + if ($workspace === null) { + throw WorkspaceDoesNotExist::butWasSupposedTo($command->workspaceName); + } + if ($workspace->baseWorkspaceName === null) { + throw WorkspaceHasNoBaseWorkspaceName::butWasSupposedTo($workspace->workspaceName); + } + $baseWorkspace = $this->contentGraphReadModel->findWorkspaceByName($workspace->baseWorkspaceName); + if ($baseWorkspace === null) { + throw BaseWorkspaceDoesNotExist::butWasSupposedTo($workspace->workspaceName); + } + $baseWorkspacePermissions = $this->getWorkspacePermissionsForCurrentUser($baseWorkspace->workspaceName); + if (!$baseWorkspacePermissions->write) { + return Privilege::denied(sprintf('Missing "write" permissions for base workspace "%s": %s', $baseWorkspace->workspaceName->value, $baseWorkspacePermissions->getReason())); + } + return Privilege::granted(sprintf('User has "manage" permissions for workspace "%s" and "write" permissions for base workspace "%s"', $command->workspaceName->value, $baseWorkspace->workspaceName->value)); + } return match ($command::class) { AddDimensionShineThrough::class, ChangeNodeAggregateName::class, @@ -136,10 +168,7 @@ public function canExecuteCommand(CommandInterface $command): Privilege UpdateRootNodeAggregateDimensions::class, DiscardWorkspace::class, DiscardIndividualNodesFromWorkspace::class, - PublishWorkspace::class, - PublishIndividualNodesFromWorkspace::class, RebaseWorkspace::class => $this->requireWorkspaceWritePermission($command->workspaceName), - CreateWorkspace::class => $this->requireWorkspaceWritePermission($command->baseWorkspaceName), DeleteWorkspace::class => $this->requireWorkspaceManagePermission($command->workspaceName), default => Privilege::granted('Command not restricted'), }; diff --git a/Neos.Neos/Configuration/Policy.yaml b/Neos.Neos/Configuration/Policy.yaml index c441d522255..c5f3beb3ca3 100644 --- a/Neos.Neos/Configuration/Policy.yaml +++ b/Neos.Neos/Configuration/Policy.yaml @@ -56,10 +56,6 @@ privilegeTargets: label: General access to content editing matcher: 'method(Neos\Neos\Service\Controller\NodeController->(show|getPrimaryChildNode|getChildNodesForTree|filterChildNodesForTree|getChildNodes|getChildNodesFromParent|create|createAndRender|createNodeForTheTree|move|moveBefore|moveAfter|moveInto|moveAndRender|copy|copyBefore|copyAfter|copyInto|copyAndRender|update|updateAndRender|delete|searchPage|error)Action()) || method(Neos\Neos\Controller\Backend\ContentController->(uploadAsset|assetsWithMetadata|imageWithMetadata|createImageVariant|error)Action()) || method(Neos\Neos\Controller\Service\AssetProxiesController->(index|show|import|error)Action()) || method(Neos\Neos\Controller\Service\AssetsController->(index|show|error)Action()) || method(Neos\Neos\Controller\Service\NodesController->(index|show|create|error)Action())' - 'Neos.Neos:Backend.PublishToLiveWorkspace': - label: Allowed to publish to the live workspace - matcher: 'method(Neos\ContentRepository\Domain\Model\Workspace->(publish|publishNode|publishNodes)(targetWorkspace.name === "live"))' - 'Neos.Neos:Backend.PublishOwnWorkspaceContent': label: Allowed to publish own personal workspace matcher: 'method(Neos\Neos\Service\Controller\WorkspaceController->(publishNode|publishNodes|error)Action()) || method(Neos\Neos\Service\Controller\WorkspaceController->publishAllAction(workspaceName = current.userInformation.personalWorkspaceName)) || method(Neos\Neos\Service\Controller\WorkspaceController->getWorkspaceWideUnpublishedNodesAction(workspace.name = current.userInformation.personalWorkspaceName))' @@ -170,14 +166,12 @@ roles: permission: GRANT 'Neos.Neos:LivePublisher': + # This group is assigned conventionally as collaborator for the live workspace. See WorkspaceService::assignWorkspaceRole label: Live publisher description: The role allows to publish to the live workspace - privileges: - - - privilegeTarget: 'Neos.Neos:Backend.PublishToLiveWorkspace' - permission: GRANT 'Neos.Neos:AbstractEditor': + # This group is assigned conventionally for new shared workspaces as collaborator. See WorkspaceService::assignWorkspaceRole abstract: true parentRoles: ['Neos.ContentRepository:Administrator'] privileges: diff --git a/Neos.Neos/Migrations/Mysql/Version20240425223900.php b/Neos.Neos/Migrations/Mysql/Version20240425223900.php index 69bd0305bcd..aca3a3a172a 100644 --- a/Neos.Neos/Migrations/Mysql/Version20240425223900.php +++ b/Neos.Neos/Migrations/Mysql/Version20240425223900.php @@ -29,7 +29,7 @@ public function up(Schema $schema): void $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); $tableWorkspaceMetadata->addColumn('owner_user_id', 'string', ['length' => 255, 'notnull' => false]); $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); - $tableWorkspaceMetadata->addIndex(['owner_user_id']); + $tableWorkspaceMetadata->addIndex(['owner_user_id'], 'IDX_D6197E562B18554A'); $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); diff --git a/Neos.Neos/Migrations/Mysql/Version20241212000000.php b/Neos.Neos/Migrations/Mysql/Version20241212000000.php new file mode 100644 index 00000000000..260398ed844 --- /dev/null +++ b/Neos.Neos/Migrations/Mysql/Version20241212000000.php @@ -0,0 +1,40 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDBPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDBPlatform'." + ); + + $tableWorkspaceMetadata = $schema->getTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addUniqueIndex(['content_repository_id', 'owner_user_id'], 'owner'); + $tableWorkspaceMetadata->dropIndex('IDX_D6197E562B18554A'); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDBPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDBPlatform'." + ); + + $tableWorkspaceMetadata = $schema->getTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addIndex(['owner_user_id'], 'IDX_D6197E562B18554A'); + $tableWorkspaceMetadata->dropIndex('owner'); + } +} diff --git a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php index b370bd7b9d1..93cb4813b24 100644 --- a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php +++ b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php @@ -26,7 +26,7 @@ public function up(Schema $schema): void $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); $tableWorkspaceMetadata->addColumn('owner_user_id', 'string', ['length' => 255, 'notnull' => false]); $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); - $tableWorkspaceMetadata->addIndex(['owner_user_id']); + $tableWorkspaceMetadata->addIndex(['owner_user_id'], 'IDX_D6197E562B18554A'); $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); diff --git a/Neos.Neos/Migrations/Postgresql/Version20241212000001.php b/Neos.Neos/Migrations/Postgresql/Version20241212000001.php new file mode 100644 index 00000000000..85e574c2969 --- /dev/null +++ b/Neos.Neos/Migrations/Postgresql/Version20241212000001.php @@ -0,0 +1,40 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\PostgreSQLPlatform'." + ); + + $tableWorkspaceMetadata = $schema->getTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addUniqueIndex(['content_repository_id', 'owner_user_id'], 'owner'); + $tableWorkspaceMetadata->dropIndex('IDX_D6197E562B18554A'); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\PostgreSQLPlatform'." + ); + + $tableWorkspaceMetadata = $schema->getTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addIndex(['owner_user_id'], 'IDX_D6197E562B18554A'); + $tableWorkspaceMetadata->dropIndex('owner'); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php index 5f4915dfcf1..23c6b9e347d 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php @@ -83,6 +83,7 @@ final protected function enableFlowSecurity(): void $this->flowSecurity_testingProvider = $tokenAndProviderFactory->getProviders()['TestingProvider']; $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); // enable authorizationChecks $httpRequest = $this->getObject(ServerRequestFactoryInterface::class)->createServerRequest('GET', 'http://localhost/'); $this->flowSecurity_mockActionRequest = ActionRequest::fromHttpRequest($httpRequest); $securityContext->setRequest($this->flowSecurity_mockActionRequest); @@ -132,4 +133,16 @@ public function getConfiguration(string $configurationType, string $configuratio } }); } + + /** + * @When I am not authenticated + */ + final public function iAmNotAuthenticated(): void + { + $this->flowSecurity_testingProvider->reset(); + $securityContext = $this->getObject(SecurityContext::class); + $securityContext->clearContext(); + $securityContext->setRequest($this->flowSecurity_mockActionRequest); + $this->getObject(AuthenticationProviderManager::class)->authenticate(); + } } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php index da251d624fb..7585fdf492f 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/UserServiceTrait.php @@ -16,10 +16,8 @@ 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; @@ -61,11 +59,14 @@ public function theNeosUserExists(string $username, string $id = null, string $f public function theFollowingNeosUsersExist(TableNode $usersTable): void { foreach ($usersTable->getHash() as $userData) { + if (empty($userData['Roles'])) { + throw new \InvalidArgumentException('Please specify explicit roles for the Neos user, to avoid using any fallbacks.'); + } $this->createUser( username: $userData['Username'], firstName: $userData['First name'] ?? null, lastName: $userData['Last name'] ?? null, - roleIdentifiers: !empty($userData['Roles']) ? explode(',', $userData['Roles']) : null, + roleIdentifiers: explode(',', $userData['Roles']), id: $userData['Id'] ?? null, ); } @@ -81,6 +82,7 @@ private function createUser(string $username, string $firstName = null, string $ $accountFactory = $this->getObject(AccountFactory::class); + // todo either hack the global hash service or fix flow to avoid having to inline this code // NOTE: We replace the original {@see HashService} by a "mock" for performance reasons (the default hashing strategy usually takes a while to create passwords) /** @var HashService $originalHashService */ diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php index af046a39443..938ce3f31a8 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -15,15 +15,16 @@ use Behat\Gherkin\Node\TableNode; use Neos\ContentRepository\BehavioralTests\TestSuite\Behavior\CRBehavioralTestsSubjectProvider; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; +use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\Flow\Security\Context as SecurityContext; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; 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; @@ -59,9 +60,35 @@ public function theRootWorkspaceIsCreated(string $workspaceName, string $title = WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($title ?? $workspaceName), WorkspaceDescription::fromString($description ?? ''), + WorkspaceRoleAssignments::createEmpty() )); } + /** + * @When the workspace :workspaceName is deleted + */ + public function theWorkspaceIsDeleted(string $workspaceName): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->deleteWorkspace( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + )); + } + + /** + * @Given the live workspace exists + */ + public function theLiveWorkspaceExists(): void + { + $this->getObject(WorkspaceService::class)->createRootWorkspace( + $this->currentContentRepository->id, + WorkspaceName::forLive(), + WorkspaceTitle::fromString('Public live workspace'), + WorkspaceDescription::empty(), + WorkspaceRoleAssignments::createForLiveWorkspace() + ); + } + /** * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :username */ @@ -90,17 +117,58 @@ public function aPersonalWorkspaceForUserIsCreated(string $username): void )); } + /** + * @Then the personal workspace for user :username is :workspaceName + */ + public function thePersonalWorkspaceForUserIs(string $username, string $workspaceName): void + { + $ownerUserId = $this->userIdForUsername($username); + $actualWorkspace = $this->getObject(WorkspaceService::class)->getPersonalWorkspaceForUser($this->currentContentRepository->id, $ownerUserId); + Assert::assertNotNull($actualWorkspace); + Assert::assertSame($workspaceName, $actualWorkspace->workspaceName->value); + } + + /** + * @Then the user :username does not have a personal workspace + */ + public function theUserDoesNotHaveAPersonalWorkspace(string $username): void + { + $ownerUserId = $this->userIdForUsername($username); + try { + $this->getObject(WorkspaceService::class)->getPersonalWorkspaceForUser($this->currentContentRepository->id, $ownerUserId); + } catch (\Throwable $e) { + // todo throw WorkspaceDoesNotExist instead?? + Assert::assertInstanceOf(\RuntimeException::class, $e, $e->getMessage()); + Assert::assertSame(1718293801, $e->getCode()); + return; + } + Assert::fail('Did not throw'); + } + /** * @When the shared workspace :workspaceName is created with the target workspace :targetWorkspace + * @When the shared workspace :workspaceName is created with the target workspace :targetWorkspace and role assignments: */ - public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void + public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, ?TableNode $rawRoleAssignments = null): void { + $workspaceRoleAssignments = WorkspaceRoleAssignments::createEmpty(); + foreach ($rawRoleAssignments?->getHash() ?? [] as $row) { + $workspaceRoleAssignments = $workspaceRoleAssignments->withAssignment(WorkspaceRoleAssignment::create( + WorkspaceRoleSubject::create( + WorkspaceRoleSubjectType::from($row['Type']), + $row['Value'] + ), + WorkspaceRole::from($row['Role']) + )); + } + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createSharedWorkspace( $this->currentContentRepository->id, WorkspaceName::fromString($workspaceName), WorkspaceTitle::fromString($workspaceName), WorkspaceDescription::fromString(''), WorkspaceName::fromString($targetWorkspace), + $workspaceRoleAssignments )); } @@ -115,18 +183,6 @@ public function aRootWorkspaceExistsWithoutMetadata(string $workspaceName): void )); } - /** - * @When a workspace :workspaceName with base workspace :baseWorkspaceName exists without metadata - */ - public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspaceName, string $baseWorkspaceName): void - { - $this->currentContentRepository->handle(CreateWorkspace::create( - WorkspaceName::fromString($workspaceName), - WorkspaceName::fromString($baseWorkspaceName), - ContentStreamId::create(), - )); - } - /** * @When the title of workspace :workspaceName is set to :newTitle */ @@ -165,6 +221,42 @@ public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, Table ]); } + /** + * @Then the metadata for workspace :workspaceName does not exist + */ + public function theWorkspaceMetadataFails($workspaceName): void + { + $metaData = $this->getObject(\Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository::class)->loadWorkspaceMetadata($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + Assert::assertNull($metaData); + + // asking the API FAILS! + try { + $this->getObject(WorkspaceService::class)->getWorkspaceMetadata($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + } catch (\Throwable $e) { + Assert::assertInstanceOf(WorkspaceDoesNotExist::class, $e, $e->getMessage()); + return; + } + Assert::fail('Did not throw'); + } + + /** + * @Then the roles for workspace :workspaceName does not exist + */ + public function theWorkspaceRolesFails($workspaceName): void + { + $roles = $this->getObject(\Neos\Neos\Domain\Repository\WorkspaceMetadataAndRoleRepository::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + Assert::assertTrue($roles->isEmpty()); + + // asking the API FAILS! + try { + $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + } catch (\Throwable $e) { + Assert::assertInstanceOf(WorkspaceDoesNotExist::class, $e, $e->getMessage()); + return; + } + Assert::fail('Did not throw'); + } + /** * @When the role :role is assigned to workspace :workspaceName for group :groupName * @When the role :role is assigned to workspace :workspaceName for user :username diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature index d279d035287..2355a2f72f4 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/EditNodePrivilege.feature @@ -29,10 +29,7 @@ Feature: EditNodePrivilege related features """ 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 the live workspace exists And I am in workspace "live" and dimension space point {} And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature index 2545f9c7bac..02cfd9b7e80 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/ReadNodePrivilege.feature @@ -29,10 +29,7 @@ Feature: ReadNodePrivilege related features """ 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 the live workspace exists And I am in workspace "live" and dimension space point {} And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | @@ -64,7 +61,6 @@ Feature: ReadNodePrivilege related features | nodeAggregateId | "a" | | nodeVariantSelectionStrategy | "allSpecializations" | | tag | "subtree_a" | - And the role VIEWER is assigned to workspace "live" for group "Neos.Flow:Everybody" When a personal workspace for user "editor" is created And content repository security is enabled diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature index c039224874b..f9532c61317 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/Security/WorkspacePermissions.feature @@ -20,11 +20,8 @@ Feature: Workspace permission related features """ 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 live workspace exists + And I am in workspace "live" And the command CreateRootNodeAggregateWithNode is executed with payload: | Key | Value | | nodeAggregateId | "root" | @@ -45,32 +42,24 @@ Feature: Workspace permission related features And the following Neos users exist: | Username | Roles | | admin | Neos.Neos:Administrator | - | editor | Neos.Neos:Editor | - | restricted_editor | Neos.Neos:RestrictedEditor | + # all editors are Neos.Neos:LivePublisher | 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" | + | restricted_editor | Neos.Neos:RestrictedEditor | + | uninvolved_editor | Neos.Neos:Editor | + # neos user with out any editing roles + | simple_user | Neos.Neos:UserManager | + + When content repository security is enabled + And the shared workspace "shared-workspace" is created with the target workspace "live" and role assignments: + | Role | Type | Value | + | COLLABORATOR | GROUP | Neos.Neos:AbstractEditor | + + Given I am authenticated as owner 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 @@ -80,13 +69,13 @@ Feature: Workspace permission related features Examples: | user | | admin | - | editor | | restricted_editor | | owner | | collaborator | - | uninvolved | + | uninvolved_editor | + | simple_user | - Scenario Outline: Creating a base workspace without WRITE permissions + Scenario Outline: Creating a nested workspace without READ permissions Given I am authenticated as And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" Then an exception of type "AccessDenied" should be thrown with code 1729086686 @@ -97,11 +86,11 @@ Feature: Workspace permission related features Examples: | user | | admin | - | editor | | restricted_editor | - | uninvolved | + | uninvolved_editor | + | simple_user | - Scenario Outline: Creating a base workspace with WRITE permissions + Scenario Outline: Creating a nested workspace with READ permissions Given I am authenticated as And the shared workspace "some-shared-workspace" is created with the target workspace "workspace" @@ -110,6 +99,52 @@ Feature: Workspace permission related features Examples: | user | | collaborator | + # the "owner" user already owns a workspace + + Scenario: Creating a workspace without Neos User but READ permissions on live + Given I am not authenticated + And the shared workspace "some-shared-workspace" is created with the target workspace "live" + + Scenario Outline: Creating a workspace with READ permissions (on live) + Given I am authenticated as + And the shared workspace "some-shared-workspace" is created with the target workspace "live" + + And the personal workspace "some-other-personal-workspace" is created with the target workspace "live" for user + + Examples: + | user | + | admin | + | collaborator | + | uninvolved_editor | + | restricted_editor | + | simple_user | + # the "owner" user already owns a workspace + + Scenario Outline: Changing a base workspace without MANAGE permissions or READ permissions on the base workspace + Given I am authenticated as + When the command ChangeBaseWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "workspace" | + | baseWorkspaceName | "shared-workspace" | + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | restricted_editor | + | collaborator | + | uninvolved_editor | + + Scenario Outline: Changing a base workspace with MANAGE permissions or READ permissions on the base workspace + Given I am authenticated as + When the command ChangeBaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "workspace" | + | baseWorkspaceName | "shared-workspace" | + + Examples: + | user | + | admin | + | manager | | owner | Scenario Outline: Deleting a workspace without MANAGE permissions @@ -118,9 +153,10 @@ Feature: Workspace permission related features Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 Examples: - | user | - | collaborator | - | uninvolved | + | user | + | collaborator | + | uninvolved_editor | + | simple_user | Scenario Outline: Deleting a workspace with MANAGE permissions Given I am authenticated as @@ -147,9 +183,9 @@ Feature: Workspace permission related features Then an exception of type "AccessDenied" should be thrown with code 1731654519 Examples: - | user | - | collaborator | - | uninvolved | + | user | + | collaborator | + | uninvolved_editor | Scenario Outline: Managing metadata and roles of a workspace with MANAGE permissions Given I am authenticated as @@ -165,11 +201,33 @@ Feature: Workspace permission related features | owner | Scenario Outline: Handling commands that require WRITE permissions on the workspace - When I am authenticated as "uninvolved" + # Prepare the content repository so all commands are applicable + When I am authenticated as "owner" + And I am in workspace "live" and 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" | + # 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 the command RebaseWorkspace is executed with payload: + | Key | Value | + | workspaceName | "workspace" | + + And I am in workspace "workspace" + + Given I am not authenticated And the command is executed with payload '' and exceptions are caught Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 - When I am authenticated as "editor" + When I am authenticated as "uninvolved_editor" And the command is executed with payload '' and exceptions are caught Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 @@ -209,8 +267,58 @@ Feature: Workspace permission related features | UpdateRootNodeAggregateDimensions | {"nodeAggregateId":"root"} | | DiscardWorkspace | {} | | DiscardIndividualNodesFromWorkspace | {"nodesToDiscard":["a1"]} | - | PublishWorkspace | {} | - | PublishIndividualNodesFromWorkspace | {"nodesToPublish":["a1"]} | | RebaseWorkspace | {} | + # note, creating a core workspace will not grant permissions to it to the current user: Missing "read" permissions for base workspace "new-workspace" | CreateWorkspace | {"workspaceName":"new-workspace","baseWorkspaceName":"workspace","newContentStreamId":"any"} | + Scenario Outline: Publishing a workspace without WRITE permissions to live + # make changes as owner + Given I am authenticated as owner + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | workspaceName | originDimensionSpacePoint | + | shernode-homes | Neos.Neos:Document | a | workspace | {"language":"de"} | + | other-node | Neos.Neos:Document | a | workspace | {"language":"de"} | + + # someone else attempts to publish + Given I am authenticated as + + And the command PublishIndividualNodesFromWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "workspace" | + | nodesToPublish | ["shernode-homes"] | + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + And the command PublishWorkspace is executed with payload and exceptions are caught: + | Key | Value | + | workspaceName | "workspace" | + Then the last command should have thrown an exception of type "AccessDenied" with code 1729086686 + + Examples: + | user | + | restricted_editor | + | simple_user | + | uninvolved_editor | + | admin | + + Scenario Outline: Publishing a workspace with WRITE permissions to live + Given I am authenticated as + + And the following CreateNodeAggregateWithNode commands are executed: + | nodeAggregateId | nodeTypeName | parentNodeAggregateId | workspaceName | originDimensionSpacePoint | + | shernode-homes | Neos.Neos:Document | a | workspace | {"language":"de"} | + | other-node | Neos.Neos:Document | a | workspace | {"language":"de"} | + + And the command PublishIndividualNodesFromWorkspace is executed with payload: + | Key | Value | + | workspaceName | "workspace" | + | nodesToPublish | ["shernode-homes"] | + + And the command PublishWorkspace is executed with payload: + | Key | Value | + | workspaceName | "workspace" | + + Examples: + | user | + | owner | + | collaborator | diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature index 1bdac437c30..c91c1a3f3b6 100644 --- a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -87,14 +87,39 @@ Feature: Neos WorkspaceService related features | Title | Description | Classification | Owner user id | | some-root-workspace | Some new workspace description | ROOT | | - Scenario: Create a single personal workspace When the root workspace "some-root-workspace" is created + Then the user "jane.doe" does not have a personal workspace And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + Then the personal workspace for user "jane.doe" is "some-user-workspace" Then the workspace "some-user-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | | some-user-workspace | | PERSONAL | janedoe | + Scenario: Personal workspace names are unique https://github.com/neos/neos-development-collection/issues/2850 + And the following Neos users exist: + | Id | Username | First name | Last name | Roles | + | 123 | test-user | Test | User | Neos.Neos:Administrator | + | 456 | test.user | Test | User | Neos.Neos:Administrator | + + When the root workspace "live" is created + + When a personal workspace for user "test-user" is created + Then the personal workspace for user "test-user" is "test-user" + + When a personal workspace for user "test.user" is created + Then the personal workspace for user "test.user" is "test-user-1" + + Scenario: A user cannot have multiple personal workspaces + When the root workspace "some-root-workspace" is created + And the personal workspace "a-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + And the personal workspace "b-user-workspace" is created with the target workspace "some-root-workspace" for user "jane.doe" + Then an exception of type "RuntimeException" should be thrown with message: + """ + Failed to create personal workspace "b-user-workspace" for user with id "janedoe", because the workspace "a-user-workspace" is already assigned to the user + """ + Then the personal workspace for user "jane.doe" is "a-user-workspace" + Scenario: Create a single shared workspace When the root workspace "some-root-workspace" is created And the shared workspace "some-shared-workspace" is created with the target workspace "some-root-workspace" @@ -102,18 +127,35 @@ Feature: Neos WorkspaceService related features | Title | Description | Classification | Owner user id | | some-shared-workspace | | SHARED | | - Scenario: Get metadata of non-existing sub workspace + Scenario: Get metadata of a sub workspace which is directly created via the content repository Given the root workspace "some-root-workspace" is created - When a workspace "some-workspace" with base workspace "some-root-workspace" exists without metadata + # dont use the workspace service here: + When the command CreateWorkspace is executed with payload: + | Key | Value | + | workspaceName | "some-workspace" | + | baseWorkspaceName | "some-root-workspace" | + | newContentStreamId | "any-cs" | + Then the workspace "some-workspace" should have the following metadata: | Title | Description | Classification | Owner user id | | some-workspace | | UNKNOWN | | + Scenario: Get metadata or roles if the workspace does not exist + Then the metadata for workspace "non-existing-workspace" does not exist + Then the roles for workspace "non-existing-workspace" does not exist + + When the root workspace "some-root-workspace" with title "Some root workspace" and description "Some description" is created + And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Given the workspace "some-root-workspace" is deleted + + Then the metadata for workspace "some-root-workspace" does not exist + Then the roles for workspace "some-root-workspace" does not exist + 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 of type "RuntimeException" should be thrown with message: + Then an exception of type "WorkspaceDoesNotExist" should be thrown with message: """ - Failed to find workspace with name "some-workspace" for content repository "default" + The workspace "some-workspace" does not exist in content repository "default" """ Scenario: Assign group role to root workspace @@ -150,9 +192,9 @@ Feature: Neos WorkspaceService related features Scenario: Unassign role from non-existing workspace When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" - Then an exception of type "RuntimeException" should be thrown with message: + Then an exception of type "WorkspaceDoesNotExist" should be thrown with message: """ - Failed to find workspace with name "some-workspace" for content repository "default" + The workspace "some-workspace" does not exist in content repository "default" """ Scenario: Unassign role from workspace that has not been assigned before @@ -179,6 +221,7 @@ Feature: Neos WorkspaceService related features Scenario: Workspace permissions for personal workspace for admin user Given the root workspace "live" is created When a personal workspace for user "jane.doe" is created + Then the personal workspace for user "jane.doe" is "jane-doe" Then the workspace "jane-doe" should have the following metadata: | Title | Description | Classification | Owner user id | | Jane Doe | | PERSONAL | janedoe | diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 8718f28aedb..0bdd6dc234f 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -54,6 +54,7 @@ use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; @@ -221,6 +222,9 @@ public function createAction( $title, $description, $baseWorkspace, + WorkspaceRoleAssignments::createForSharedWorkspace( + $currentUser->getId() + ) ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( @@ -230,22 +234,6 @@ public function createAction( ); $this->redirect('new'); } - $this->workspaceService->assignWorkspaceRole( - $contentRepositoryId, - $workspaceName, - WorkspaceRoleAssignment::createForUser( - $currentUser->getId(), - WorkspaceRole::MANAGER, - ) - ); - $this->workspaceService->assignWorkspaceRole( - $contentRepositoryId, - $workspaceName, - WorkspaceRoleAssignment::createForGroup( - 'Neos.Neos:AbstractEditor', - WorkspaceRole::COLLABORATOR, - ) - ); $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); $this->redirect('index'); } @@ -1017,7 +1005,7 @@ protected function prepareBaseWorkspaceOptions( continue; } $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId()); - if (!$permissions->manage) { + if (!$permissions->read) { continue; } $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspaceMetadata->title->value;