Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BUGFIX: WorkspaceService adjust to real world use cases #5334

Merged
merged 25 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d5300e2
TASK: Introduce utility methods for #5132
mhsdesign Oct 31, 2024
1404d37
TASK: Introduce `WorkspaceService::deleteWorkspace`
mhsdesign Dec 5, 2024
909223e
TASK: Test `ChangeBaseWorkspace` behaviour with security
mhsdesign Dec 6, 2024
9755c9d
TASK: Ensure the live workspace is created by the WorkspaceService fo…
mhsdesign Dec 6, 2024
0257b28
TASK: Ensure the live workspace is created by the WorkspaceService fo…
mhsdesign Dec 6, 2024
d514e61
TASK: Allow workspace creation with only base workspace read privilege
mhsdesign Dec 6, 2024
484c1ab
BUGFIX: Only allow to publish if the current user can write to the base
mhsdesign Dec 6, 2024
74cfaaa
TASK: Simplify test to only have one `uninvolved_editor`
mhsdesign Dec 6, 2024
a7d887f
BUGFIX: Ensure that the live workspace is created WITH public viewing…
mhsdesign Dec 6, 2024
28b98c5
TASK: Add test for not authenticated case
mhsdesign Dec 7, 2024
8ee8521
TASK: Fix workspace permission test to no be in authorizationChecksDi…
mhsdesign Dec 7, 2024
6351ce6
TASK: Use `deleteWorkspace` from service in cli command and test
mhsdesign Dec 9, 2024
06348d5
TASK: Throw specific `WorkspaceDoesNotExist` in workspace service to …
mhsdesign Dec 9, 2024
6d0a149
BUGFIX: Allow to specify `WorkspaceRoleAssignments` when creating a n…
mhsdesign Dec 9, 2024
3c678dd
TASK: Document how Neos.Neos:LivePublisher and Neos.Neos:AbstractEdit…
mhsdesign Dec 9, 2024
1e5dae6
BUGFIX: Fix root workspace creation to specify assignments
mhsdesign Dec 9, 2024
a36e564
TASK: Test current state of ordering if multiple workspaces exist for…
mhsdesign Dec 9, 2024
94c4065
TASK: Add test to ensure personal workspace names are always unique
mhsdesign Dec 9, 2024
099274a
TASK: Document and cleanup `createRootWorkspace` and `createSharedWor…
mhsdesign Dec 9, 2024
31473fb
BUGFIX: Ensure for now that the api does not allow two personal works…
mhsdesign Dec 9, 2024
5cc7e0f
TASK: Apply suggestions from review
mhsdesign Dec 10, 2024
3f65a74
BUGFIX: Use correct error code to assert
dlubitz Dec 10, 2024
29a9b6c
Merge remote-tracking branch 'origin/9.0' into bugfix/workspace-servi…
mhsdesign Dec 12, 2024
ddccba1
TASK: Ensure that the workspace owner is unique via sql index
mhsdesign Dec 12, 2024
08d01e1
TASK: Make workspace role and metadata creation atomic
mhsdesign Dec 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn):
];
$roleAssignments[] = [
'subject_type' => WorkspaceRoleSubjectType::GROUP->value,
'subject' => 'Neos.Neos:Everybody',
'subject' => 'Neos.Flow:Everybody',
mhsdesign marked this conversation as resolved.
Show resolved Hide resolved
'role' => WorkspaceRole::VIEWER->value,
];
} elseif ($isInternalWorkspace) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@
use Neos\ContentRepository\Export\ProcessingContext;
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\WorkspaceTitle;
use Neos\Neos\Domain\Service\WorkspaceService;

/**
Expand All @@ -44,7 +40,6 @@ 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->createLiveWorkspaceIfMissing($this->contentRepository->id);
}
}
6 changes: 6 additions & 0 deletions Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
10 changes: 10 additions & 0 deletions Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,14 @@ 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,58 @@ public function getMostPrivilegedWorkspaceRoleForSubjects(ContentRepositoryId $c
return WorkspaceRole::from($role);
}

public function deleteWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void
{
$table = self::TABLE_NAME_WORKSPACE_METADATA;
$query = <<<SQL
DELETE FROM
{$table}
WHERE
content_repository_id = :contentRepositoryId
AND workspace_name = :workspaceName
SQL;

try {
$this->dbal->executeStatement($query, [
'contentRepositoryId' => $contentRepositoryId->value,
'workspaceName' => $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
{
$table = self::TABLE_NAME_WORKSPACE_ROLE;
$query = <<<SQL
DELETE FROM
{$table}
WHERE
content_repository_id = :contentRepositoryId
AND workspace_name = :workspaceName
SQL;

try {
$this->dbal->executeStatement($query, [
'contentRepositoryId' => $contentRepositoryId->value,
'workspaceName' => $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
*/
Expand Down
20 changes: 20 additions & 0 deletions Neos.Neos/Classes/Domain/Service/WorkspaceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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\Workspace\ContentStreamId;
use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace;
Expand Down Expand Up @@ -139,6 +140,7 @@ public function createLiveWorkspaceIfMissing(ContentRepositoryId $contentReposit
}
$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->assignWorkspaceRole($contentRepositoryId, $workspaceName, WorkspaceRoleAssignment::createForGroup('Neos.Flow:Everybody', WorkspaceRole::VIEWER));
}

/**
Expand Down Expand Up @@ -204,6 +206,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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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'),
};
Expand Down
13 changes: 13 additions & 0 deletions Neos.Neos/Tests/Behavior/Features/Bootstrap/FlowSecurityTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
);
}
Expand All @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ public function theRootWorkspaceIsCreated(string $workspaceName, string $title =
));
}

/**
* @Given the live workspace exists
*/
public function theLiveWorkspaceExists(): void
{
$this->getObject(WorkspaceService::class)->createLiveWorkspaceIfMissing(
$this->currentContentRepository->id
);
}

/**
* @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :username
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading