diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index b3e8e07652c47..1872528333937 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -18,6 +18,17 @@ 'NCU\\Config\\Lexicon\\Preset' => $baseDir . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Governance\\Events\\LabelAssignedEvent' => $baseDir . '/lib/unstable/Governance/Events/LabelAssignedEvent.php', + 'NCU\\Governance\\Events\\LabelRemovedEvent' => $baseDir . '/lib/unstable/Governance/Events/LabelRemovedEvent.php', + 'NCU\\Governance\\Exception\\GovernanceException' => $baseDir . '/lib/unstable/Governance/Exception/GovernanceException.php', + 'NCU\\Governance\\Exception\\GovernanceUnavailableException' => $baseDir . '/lib/unstable/Governance/Exception/GovernanceUnavailableException.php', + 'NCU\\Governance\\Exception\\LabelNotFoundException' => $baseDir . '/lib/unstable/Governance/Exception/LabelNotFoundException.php', + 'NCU\\Governance\\Exception\\LabelPermissionException' => $baseDir . '/lib/unstable/Governance/Exception/LabelPermissionException.php', + 'NCU\\Governance\\Exception\\ResourceNotFoundException' => $baseDir . '/lib/unstable/Governance/Exception/ResourceNotFoundException.php', + 'NCU\\Governance\\ILabelManager' => $baseDir . '/lib/unstable/Governance/ILabelManager.php', + 'NCU\\Governance\\LabelModification' => $baseDir . '/lib/unstable/Governance/LabelModification.php', + 'NCU\\Governance\\LabelType' => $baseDir . '/lib/unstable/Governance/LabelType.php', + 'NCU\\Governance\\ResourceLabel' => $baseDir . '/lib/unstable/Governance/ResourceLabel.php', 'NCU\\Resource\\IResourceType' => $baseDir . '/lib/unstable/Resource/IResourceType.php', 'NCU\\Resource\\Permission\\APermission' => $baseDir . '/lib/unstable/Resource/Permission/APermission.php', 'NCU\\Resource\\Permission\\AccessEntry' => $baseDir . '/lib/unstable/Resource/Permission/AccessEntry.php', @@ -2069,6 +2080,7 @@ 'OC\\FullTextSearch\\Model\\SearchRequestSimpleQuery' => $baseDir . '/lib/private/FullTextSearch/Model/SearchRequestSimpleQuery.php', 'OC\\FullTextSearch\\Model\\SearchTemplate' => $baseDir . '/lib/private/FullTextSearch/Model/SearchTemplate.php', 'OC\\GlobalScale\\Config' => $baseDir . '/lib/private/GlobalScale/Config.php', + 'OC\\Governance\\LabelManager' => $baseDir . '/lib/private/Governance/LabelManager.php', 'OC\\Group\\Backend' => $baseDir . '/lib/private/Group/Backend.php', 'OC\\Group\\Database' => $baseDir . '/lib/private/Group/Database.php', 'OC\\Group\\DisplayNameCache' => $baseDir . '/lib/private/Group/DisplayNameCache.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 113654baeef47..8d1635270d1fd 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -59,6 +59,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Config\\Lexicon\\Preset' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Governance\\Events\\LabelAssignedEvent' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Events/LabelAssignedEvent.php', + 'NCU\\Governance\\Events\\LabelRemovedEvent' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Events/LabelRemovedEvent.php', + 'NCU\\Governance\\Exception\\GovernanceException' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Exception/GovernanceException.php', + 'NCU\\Governance\\Exception\\GovernanceUnavailableException' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Exception/GovernanceUnavailableException.php', + 'NCU\\Governance\\Exception\\LabelNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Exception/LabelNotFoundException.php', + 'NCU\\Governance\\Exception\\LabelPermissionException' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Exception/LabelPermissionException.php', + 'NCU\\Governance\\Exception\\ResourceNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Governance/Exception/ResourceNotFoundException.php', + 'NCU\\Governance\\ILabelManager' => __DIR__ . '/../../..' . '/lib/unstable/Governance/ILabelManager.php', + 'NCU\\Governance\\LabelModification' => __DIR__ . '/../../..' . '/lib/unstable/Governance/LabelModification.php', + 'NCU\\Governance\\LabelType' => __DIR__ . '/../../..' . '/lib/unstable/Governance/LabelType.php', + 'NCU\\Governance\\ResourceLabel' => __DIR__ . '/../../..' . '/lib/unstable/Governance/ResourceLabel.php', 'NCU\\Resource\\IResourceType' => __DIR__ . '/../../..' . '/lib/unstable/Resource/IResourceType.php', 'NCU\\Resource\\Permission\\APermission' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/APermission.php', 'NCU\\Resource\\Permission\\AccessEntry' => __DIR__ . '/../../..' . '/lib/unstable/Resource/Permission/AccessEntry.php', @@ -2110,6 +2121,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\FullTextSearch\\Model\\SearchRequestSimpleQuery' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Model/SearchRequestSimpleQuery.php', 'OC\\FullTextSearch\\Model\\SearchTemplate' => __DIR__ . '/../../..' . '/lib/private/FullTextSearch/Model/SearchTemplate.php', 'OC\\GlobalScale\\Config' => __DIR__ . '/../../..' . '/lib/private/GlobalScale/Config.php', + 'OC\\Governance\\LabelManager' => __DIR__ . '/../../..' . '/lib/private/Governance/LabelManager.php', 'OC\\Group\\Backend' => __DIR__ . '/../../..' . '/lib/private/Group/Backend.php', 'OC\\Group\\Database' => __DIR__ . '/../../..' . '/lib/private/Group/Database.php', 'OC\\Group\\DisplayNameCache' => __DIR__ . '/../../..' . '/lib/private/Group/DisplayNameCache.php', diff --git a/lib/private/Governance/LabelManager.php b/lib/private/Governance/LabelManager.php new file mode 100644 index 0000000000000..01833cd8f34e8 --- /dev/null +++ b/lib/private/Governance/LabelManager.php @@ -0,0 +1,135 @@ +getBackend()?->isAvailable() ?? false; + } + + #[\Override] + public function getAssignedLabelsForResources(Actor $actor, array $resources): array { + $this->assertRegistered($resources); + + $labels = []; + foreach ($resources as $resource) { + $labels[$resource->key()] = []; + } + + $backend = $this->getBackend(); + if ($backend === null || $resources === []) { + return $labels; + } + + foreach ($backend->getAssignedLabelsForResources($actor, $resources) as $resourceKey => $resourceLabels) { + if (isset($labels[$resourceKey])) { + $labels[$resourceKey] = array_values($resourceLabels); + } + } + + return $labels; + } + + #[\Override] + public function getAssignedLabels(Actor $actor, ResourceReference $resource): array { + return $this->getAssignedLabelsForResources($actor, [$resource])[$resource->key()]; + } + + #[\Override] + public function getAssignableLabels(Actor $actor, ResourceReference $resource, ?LabelType $type = null): array { + $this->assertRegistered([$resource]); + + return $this->getBackend()?->getAssignableLabels($actor, $resource, $type) ?? []; + } + + #[\Override] + public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->assertRegistered([$resource]); + $this->getBackendOrThrow()->assignLabel($actor, $resource, $labelId, $reason); + } + + #[\Override] + public function removeLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->assertRegistered([$resource]); + $this->getBackendOrThrow()->removeLabel($actor, $resource, $labelId, $reason); + } + + /** + * @param list $resources + * @throws InvalidArgumentException if a resource type is not registered + */ + private function assertRegistered(array $resources): void { + foreach ($resources as $resource) { + if ($this->permissionManager->getResourceType($resource->type) === null) { + throw new InvalidArgumentException('The resource type ' . $resource->type . ' is not registered'); + } + } + } + + /** + * @throws GovernanceUnavailableException + */ + private function getBackendOrThrow(): ILabelManager { + return $this->getBackend() ?? throw new GovernanceUnavailableException('The governance app is not enabled'); + } + + private function getBackend(): ?ILabelManager { + if ($this->backend !== null) { + return $this->backend; + } + if (!$this->appManager->isEnabledForAnyone(self::APP_ID)) { + return null; + } + + try { + $backend = $this->container->get(self::BACKEND); + } catch (Throwable $e) { + $this->logger->error('Could not load the label manager of the governance app', ['exception' => $e]); + return null; + } + if (!$backend instanceof ILabelManager) { + $this->logger->error('{class} does not implement {interface}', ['class' => self::BACKEND, 'interface' => ILabelManager::class]); + return null; + } + + $this->backend = $backend; + + return $backend; + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 8676dfae26bd7..c8984442b2a2f 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1119,6 +1119,7 @@ public function runInitialRegistrations(): void { $this->registerAlias(\NCU\Resource\Permission\IPermissionManager::class, \OC\Resource\Permission\PermissionManager::class); $this->registerAlias(\NCU\Resource\Permission\IAccessManager::class, \OC\Resource\Permission\AccessManager::class); + $this->registerAlias(\NCU\Governance\ILabelManager::class, \OC\Governance\LabelManager::class); $this->registerService(IGlobalScaleService::class, static function (ContainerInterface $c): IGlobalScaleService { /** @var Coordinator $coordinator */ diff --git a/lib/unstable/Governance/Events/LabelAssignedEvent.php b/lib/unstable/Governance/Events/LabelAssignedEvent.php new file mode 100644 index 0000000000000..35df240440e73 --- /dev/null +++ b/lib/unstable/Governance/Events/LabelAssignedEvent.php @@ -0,0 +1,39 @@ + $resources + * @return array> keyed by {@see ResourceReference::key}, with an entry for every resource + * @throws \InvalidArgumentException if a resource type is not registered + * @experimental 36.0.0 + */ + public function getAssignedLabelsForResources(Actor $actor, array $resources): array; + + /** + * Same as {@see self::getAssignedLabelsForResources} for a single resource. + * + * @return list + * @throws \InvalidArgumentException if the resource type is not registered + * @experimental 36.0.0 + */ + public function getAssignedLabels(Actor $actor, ResourceReference $resource): array; + + /** + * Get the labels that apply to the resource, including the ones already assigned. + * + * Whether the actor may assign or remove a label is part of the label. + * No labels are returned if the actor may not label the resource. + * + * @param ?LabelType $type Only return labels of this type + * @return list + * @throws \InvalidArgumentException if the resource type is not registered + * @experimental 36.0.0 + */ + public function getAssignableLabels(Actor $actor, ResourceReference $resource, ?LabelType $type = null): array; + + /** + * Assign the label to the resource. + * + * A sensitivity label replaces the one currently assigned. + * + * @param non-empty-string $labelId + * @param ?string $reason Required if the label can only be assigned with a reason, see {@see ResourceLabel::$canAssign} + * @throws \InvalidArgumentException if the resource type is not registered + * @throws GovernanceUnavailableException + * @throws ResourceNotFoundException + * @throws LabelNotFoundException + * @throws LabelPermissionException + * @experimental 36.0.0 + */ + public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void; + + /** + * Remove the label from the resource. + * + * @param non-empty-string $labelId + * @param ?string $reason Required if the label can only be removed with a reason, see {@see ResourceLabel::$canRemove} + * @throws \InvalidArgumentException if the resource type is not registered + * @throws GovernanceUnavailableException + * @throws ResourceNotFoundException + * @throws LabelNotFoundException + * @throws LabelPermissionException + * @experimental 36.0.0 + */ + public function removeLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void; +} diff --git a/lib/unstable/Governance/LabelModification.php b/lib/unstable/Governance/LabelModification.php new file mode 100644 index 0000000000000..a01f1aa6a8337 --- /dev/null +++ b/lib/unstable/Governance/LabelModification.php @@ -0,0 +1,39 @@ +alice = Actor::of(Principal::user('alice')); + $this->table = new ResourceReference('tables:table', '1'); + $this->otherTable = new ResourceReference('tables:table', '2'); + + $this->backend = $this->createMock(ILabelManager::class); + $this->service = $this->backend; + $this->container = $this->createMock(ContainerInterface::class); + $this->container->method('get')->with(LabelManager::BACKEND)->willReturnCallback(fn (): object => $this->service); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->manager = $this->createManager(true); + } + + private function createManager(bool $governanceEnabled): LabelManager { + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('isEnabledForAnyone')->with('governance')->willReturn($governanceEnabled); + + $permissionManager = $this->createMock(IPermissionManager::class); + $permissionManager->method('getResourceType')->willReturnCallback( + fn (string $id): ?IResourceType => $id === 'tables:table' ? $this->createMock(IResourceType::class) : null, + ); + + return new LabelManager($this->appManager, $this->container, $permissionManager, $this->logger); + } + + private function createLabel(string $id, LabelType $type = LabelType::Sensitivity): ResourceLabel { + return new ResourceLabel($id, $type, 'Label ' . $id, null, null, 0, true, LabelModification::Yes, LabelModification::Yes); + } + + public function testIsRegistered(): void { + $manager = Server::get(ILabelManager::class); + + $this->assertInstanceOf(LabelManager::class, $manager); + $this->assertFalse($manager->isAvailable()); + } + + public function testIsAvailable(): void { + $this->backend->method('isAvailable')->willReturn(true); + + $this->assertTrue($this->manager->isAvailable()); + } + + public function testWithoutGovernanceNoLabelsAreReturned(): void { + $manager = $this->createManager(false); + $this->container->expects($this->never())->method('get'); + + $this->assertFalse($manager->isAvailable()); + $this->assertSame(['tables:table/1' => [], 'tables:table/2' => []], $manager->getAssignedLabelsForResources($this->alice, [$this->table, $this->otherTable])); + $this->assertSame([], $manager->getAssignedLabels($this->alice, $this->table)); + $this->assertSame([], $manager->getAssignableLabels($this->alice, $this->table)); + } + + public function testWithoutGovernanceLabelsCannotBeAssigned(): void { + $this->expectException(GovernanceUnavailableException::class); + $this->createManager(false)->assignLabel($this->alice, $this->table, '1'); + } + + public function testWithoutGovernanceLabelsCannotBeRemoved(): void { + $this->expectException(GovernanceUnavailableException::class); + $this->createManager(false)->removeLabel($this->alice, $this->table, '1'); + } + + public function testServiceThatIsNoLabelManagerIsUnavailable(): void { + $this->service = new \stdClass(); + $this->logger->expects($this->atLeastOnce())->method('error'); + + $this->assertFalse($this->manager->isAvailable()); + $this->assertSame([], $this->manager->getAssignedLabels($this->alice, $this->table)); + } + + public function testServiceIsLoadedOnce(): void { + $this->container->expects($this->once())->method('get'); + + $this->manager->isAvailable(); + $this->manager->getAssignableLabels($this->alice, $this->table); + } + + public function testUnregisteredResourceType(): void { + $this->backend->expects($this->never())->method('getAssignedLabelsForResources'); + + $this->expectException(\InvalidArgumentException::class); + $this->manager->getAssignedLabelsForResources($this->alice, [$this->table, new ResourceReference('forms:form', '1')]); + } + + public function testUnregisteredResourceTypeWithoutGovernance(): void { + $this->expectException(\InvalidArgumentException::class); + $this->createManager(false)->getAssignableLabels($this->alice, new ResourceReference('forms:form', '1')); + } + + public function testLabelsCannotBeAssignedToAnUnregisteredResourceType(): void { + $this->backend->expects($this->never())->method('assignLabel'); + + $this->expectException(\InvalidArgumentException::class); + $this->manager->assignLabel($this->alice, new ResourceReference('forms:form', '1'), '1'); + } + + /** + * The governance app decides which labels the actor sees, every resource is passed on. + */ + public function testAssignedLabelsAreReadFromGovernance(): void { + $internal = $this->createLabel('1'); + $hold = $this->createLabel('3', LabelType::Hold); + $this->backend->expects($this->once())->method('getAssignedLabelsForResources') + ->with($this->alice, [$this->otherTable, $this->table]) + ->willReturn([ + 'tables:table/1' => [5 => $internal, 7 => $hold], + // A resource that was not asked for + 'tables:table/99' => [$hold], + ]); + + $labels = $this->manager->getAssignedLabelsForResources($this->alice, [$this->otherTable, $this->table]); + + $this->assertSame(['tables:table/2' => [], 'tables:table/1' => [$internal, $hold]], $labels); + } + + public function testGovernanceIsNotAskedWithoutResources(): void { + $this->backend->expects($this->never())->method('getAssignedLabelsForResources'); + + $this->assertSame([], $this->manager->getAssignedLabelsForResources($this->alice, [])); + } + + public function testGetAssignedLabels(): void { + $internal = $this->createLabel('1'); + $this->backend->method('getAssignedLabelsForResources')->willReturn(['tables:table/1' => [$internal]]); + + $this->assertSame([$internal], $this->manager->getAssignedLabels($this->alice, $this->table)); + } + + public function testAssignableLabelsAreReadFromGovernance(): void { + $labels = [$this->createLabel('1'), $this->createLabel('2')]; + $this->backend->expects($this->once())->method('getAssignableLabels') + ->with($this->alice, $this->table, LabelType::Sensitivity) + ->willReturn($labels); + + $this->assertSame($labels, $this->manager->getAssignableLabels($this->alice, $this->table, LabelType::Sensitivity)); + } + + public function testAssignLabel(): void { + $this->backend->expects($this->once())->method('assignLabel')->with($this->alice, $this->table, '1', 'Contains salaries'); + + $this->manager->assignLabel($this->alice, $this->table, '1', 'Contains salaries'); + } + + public function testRemoveLabel(): void { + $this->backend->expects($this->once())->method('removeLabel')->with($this->alice, $this->table, '1', 'Outdated'); + + $this->manager->removeLabel($this->alice, $this->table, '1', 'Outdated'); + } + + public function testExceptionsOfGovernanceArePassedOn(): void { + $this->backend->method('assignLabel')->willThrowException(new ResourceNotFoundException()); + $this->backend->method('removeLabel')->willThrowException(new LabelPermissionException()); + + try { + $this->manager->assignLabel($this->alice, $this->table, '1'); + $this->fail('Expected a ResourceNotFoundException'); + } catch (ResourceNotFoundException) { + } + + $this->expectException(LabelPermissionException::class); + $this->manager->removeLabel($this->alice, $this->table, '1'); + } +}