From 5555bdbc8fbdcce8eebec037268383a2884571b8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 14 Nov 2026 09:20:21 +0100 Subject: [PATCH 1/2] feat: add API to read and change the labels of resources `NCU\Governance\ILabelManager` reads the labels assigned to resources and assigns or removes them. The labels are managed by the governance app, core checks the access of the actor to the resource before delegating to it and dispatches an event for every label that was assigned or removed. Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- lib/composer/composer/autoload_classmap.php | 12 + lib/composer/composer/autoload_static.php | 12 + lib/private/Governance/LabelManager.php | 183 ++++++++++ lib/private/Server.php | 1 + .../Governance/Events/LabelAssignedEvent.php | 37 ++ .../Governance/Events/LabelRemovedEvent.php | 37 ++ .../Exception/GovernanceException.php | 23 ++ .../GovernanceUnavailableException.php | 23 ++ .../Exception/LabelNotFoundException.php | 23 ++ .../Exception/LabelPermissionException.php | 23 ++ .../Exception/ResourceNotFoundException.php | 23 ++ lib/unstable/Governance/ILabelManager.php | 105 ++++++ lib/unstable/Governance/LabelModification.php | 39 ++ lib/unstable/Governance/LabelType.php | 41 +++ lib/unstable/Governance/ResourceLabel.php | 44 +++ tests/lib/Governance/LabelManagerTest.php | 344 ++++++++++++++++++ 16 files changed, 970 insertions(+) create mode 100644 lib/private/Governance/LabelManager.php create mode 100644 lib/unstable/Governance/Events/LabelAssignedEvent.php create mode 100644 lib/unstable/Governance/Events/LabelRemovedEvent.php create mode 100644 lib/unstable/Governance/Exception/GovernanceException.php create mode 100644 lib/unstable/Governance/Exception/GovernanceUnavailableException.php create mode 100644 lib/unstable/Governance/Exception/LabelNotFoundException.php create mode 100644 lib/unstable/Governance/Exception/LabelPermissionException.php create mode 100644 lib/unstable/Governance/Exception/ResourceNotFoundException.php create mode 100644 lib/unstable/Governance/ILabelManager.php create mode 100644 lib/unstable/Governance/LabelModification.php create mode 100644 lib/unstable/Governance/LabelType.php create mode 100644 lib/unstable/Governance/ResourceLabel.php create mode 100644 tests/lib/Governance/LabelManagerTest.php 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..3223ef184df7c --- /dev/null +++ b/lib/private/Governance/LabelManager.php @@ -0,0 +1,183 @@ +getBackend()?->isAvailable() ?? false; + } + + #[\Override] + public function getAssignedLabelsForResources(Actor $actor, array $resources): array { + $accessible = $this->filterAccessible($actor, $resources); + + $labels = []; + foreach ($resources as $resource) { + $labels[$resource->key()] = []; + } + + $backend = $this->getBackend(); + if ($backend === null || $accessible === []) { + return $labels; + } + + foreach ($backend->getAssignedLabelsForResources($actor, $accessible) 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 { + $accessible = $this->filterAccessible($actor, [$resource]); + $backend = $this->getBackend(); + if ($backend === null || $accessible === []) { + return []; + } + + return $backend->getAssignableLabels($actor, $resource, $type); + } + + #[\Override] + public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->change($actor, $resource, static fn (ILabelManager $backend) => $backend->assignLabel($actor, $resource, $labelId, $reason)); + } + + #[\Override] + public function removeLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->change($actor, $resource, static fn (ILabelManager $backend) => $backend->removeLabel($actor, $resource, $labelId, $reason)); + } + + /** + * Run the change and dispatch an event for every label that is no longer or newly assigned afterwards. + * + * @param \Closure(ILabelManager): void $change + * @throws InvalidArgumentException + * @throws GovernanceUnavailableException + * @throws ResourceNotFoundException + */ + private function change(Actor $actor, ResourceReference $resource, \Closure $change): void { + $accessible = $this->filterAccessible($actor, [$resource]); + $backend = $this->getBackend(); + if ($backend === null) { + throw new GovernanceUnavailableException('The governance app is not enabled'); + } + if ($accessible === []) { + throw new ResourceNotFoundException('The resource ' . $resource->key() . ' does not exist or cannot be accessed'); + } + + $before = $this->getAssignedTypes($backend, $actor, $resource); + $change($backend); + $after = $this->getAssignedTypes($backend, $actor, $resource); + + foreach (array_diff_key($before, $after) as $labelId => $labelType) { + $this->eventDispatcher->dispatchTyped(new LabelRemovedEvent($actor, $resource, (string)$labelId, $labelType)); + } + foreach (array_diff_key($after, $before) as $labelId => $labelType) { + $this->eventDispatcher->dispatchTyped(new LabelAssignedEvent($actor, $resource, (string)$labelId, $labelType)); + } + } + + /** + * @return array types of the assigned labels by label ID, numeric IDs are integer keys + */ + private function getAssignedTypes(ILabelManager $backend, Actor $actor, ResourceReference $resource): array { + $types = []; + foreach ($backend->getAssignedLabels($actor, $resource) as $label) { + $types[$label->id] = $label->type; + } + + return $types; + } + + /** + * @param list $resources + * @return list the resources the actor has access to + * @throws InvalidArgumentException if a resource type is not registered + */ + private function filterAccessible(Actor $actor, array $resources): array { + $access = $this->accessManager->getAccess($actor, $resources); + + $accessible = []; + foreach ($resources as $resource) { + if (isset($access[$resource->key()])) { + $accessible[$resource->key()] = $resource; + } + } + + return array_values($accessible); + } + + 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..7211871a65a05 --- /dev/null +++ b/lib/unstable/Governance/Events/LabelAssignedEvent.php @@ -0,0 +1,37 @@ + $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 has no access to 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 @@ + */ + private array $events = []; + + /** @var ILabelManager&object{assigned: array>, calls: list} */ + private object $backend; + + protected function setUp(): void { + parent::setUp(); + + $this->alice = Actor::of(Principal::user('alice')); + $this->table = new ResourceReference('tables:table', '1'); + $this->hidden = new ResourceReference('tables:table', '2'); + + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('isEnabledForAnyone')->with('governance')->willReturn(true); + + $this->backend = $this->createBackend(); + $this->container = $this->createMock(ContainerInterface::class); + $this->container->method('get')->with(LabelManager::BACKEND)->willReturnCallback(fn (): object => $this->backend); + + // Alice has access to every resource but the hidden table + $this->accessManager = $this->createMock(IAccessManager::class); + $this->accessManager->method('getAccess')->willReturnCallback(function (Actor $actor, array $resources): array { + $access = []; + foreach ($resources as $resource) { + if ($resource->type !== 'tables:table') { + throw new \InvalidArgumentException('The resource type ' . $resource->type . ' is not registered'); + } + if ($resource->key() !== $this->hidden->key()) { + $access[$resource->key()] = [new AccessEntry(AccessEntry::RECIPIENT_USER, 'alice', [CorePermission::Read->value])]; + } + } + return $access; + }); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->method('dispatchTyped')->willReturnCallback(function (Event $event): void { + $this->events[] = $event; + }); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->manager = new LabelManager($this->appManager, $this->container, $this->accessManager, $eventDispatcher, $this->logger); + } + + /** + * A label manager with two sensitivity labels, of which a resource has at most one, and a hold label. + */ + private function createBackend(): ILabelManager { + return new class implements ILabelManager { + private const array LABELS = [ + '1' => [LabelType::Sensitivity, 'Internal'], + '2' => [LabelType::Sensitivity, 'Confidential'], + '3' => [LabelType::Hold, 'Audit 2026'], + ]; + + /** @var array> label IDs by resource key */ + public array $assigned = []; + + /** @var list */ + public array $calls = []; + + public function isAvailable(): bool { + return true; + } + + public function getAssignedLabelsForResources(Actor $actor, array $resources): array { + $this->calls[] = ['getAssignedLabelsForResources', $resources]; + $labels = []; + foreach ($resources as $resource) { + $labels[$resource->key()] = array_map($this->createLabel(...), $this->assigned[$resource->key()] ?? []); + } + // A resource that was not asked for + $labels['tables:table/99'] = [$this->createLabel('3')]; + return $labels; + } + + public function getAssignedLabels(Actor $actor, ResourceReference $resource): array { + return array_map($this->createLabel(...), $this->assigned[$resource->key()] ?? []); + } + + public function getAssignableLabels(Actor $actor, ResourceReference $resource, ?LabelType $type = null): array { + $this->calls[] = ['getAssignableLabels', $resource, $type]; + return array_values(array_filter( + array_map($this->createLabel(...), array_map(strval(...), array_keys(self::LABELS))), + static fn (ResourceLabel $label): bool => $type === null || $label->type === $type, + )); + } + + public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->calls[] = ['assignLabel', $resource, $labelId, $reason]; + if (!isset(self::LABELS[$labelId])) { + throw new LabelNotFoundException(); + } + + $assigned = $this->assigned[$resource->key()] ?? []; + if (self::LABELS[$labelId][0] === LabelType::Sensitivity) { + $assigned = array_filter($assigned, static fn (string $id): bool => self::LABELS[$id][0] !== LabelType::Sensitivity); + } + $this->assigned[$resource->key()] = array_values(array_unique([...$assigned, $labelId])); + } + + public function removeLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { + $this->calls[] = ['removeLabel', $resource, $labelId, $reason]; + if (self::LABELS[$labelId][0] === LabelType::Hold) { + throw new LabelPermissionException(); + } + $this->assigned[$resource->key()] = array_values(array_diff($this->assigned[$resource->key()] ?? [], [$labelId])); + } + + private function createLabel(string $id): ResourceLabel { + return new ResourceLabel($id, self::LABELS[$id][0], self::LABELS[$id][1], null, null, (int)$id, true, LabelModification::Yes, LabelModification::Yes); + } + }; + } + + private function disableGovernance(): void { + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('isEnabledForAnyone')->willReturn(false); + $this->container->expects($this->never())->method('get'); + $this->manager = new LabelManager($this->appManager, $this->container, $this->accessManager, $this->createMock(IEventDispatcher::class), $this->logger); + } + + /** + * @return list + */ + private function getDispatched(): array { + return array_map(static fn (LabelAssignedEvent|LabelRemovedEvent $event): array => [$event::class, $event->labelId, $event->labelType], $this->events); + } + + public function testIsRegistered(): void { + $manager = Server::get(ILabelManager::class); + + $this->assertInstanceOf(LabelManager::class, $manager); + $this->assertFalse($manager->isAvailable()); + } + + public function testWithoutGovernanceNoLabelsAreReturned(): void { + $this->disableGovernance(); + + $this->assertFalse($this->manager->isAvailable()); + $this->assertSame(['tables:table/1' => [], 'tables:table/2' => []], $this->manager->getAssignedLabelsForResources($this->alice, [$this->table, $this->hidden])); + $this->assertSame([], $this->manager->getAssignedLabels($this->alice, $this->table)); + $this->assertSame([], $this->manager->getAssignableLabels($this->alice, $this->table)); + } + + public function testWithoutGovernanceLabelsCannotBeAssigned(): void { + $this->disableGovernance(); + + $this->expectException(GovernanceUnavailableException::class); + $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL); + } + + public function testWithoutGovernanceLabelsCannotBeRemoved(): void { + $this->disableGovernance(); + + $this->expectException(GovernanceUnavailableException::class); + $this->manager->removeLabel($this->alice, $this->table, self::INTERNAL); + } + + public function testBackendThatCannotBeLoadedIsUnavailable(): void { + $this->backend = 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 testIsAvailable(): void { + $this->assertTrue($this->manager->isAvailable()); + } + + public function testUnregisteredResourceType(): void { + $this->expectException(\InvalidArgumentException::class); + $this->manager->getAssignedLabels($this->alice, new ResourceReference('forms:form', '1')); + } + + public function testUnregisteredResourceTypeWithoutGovernance(): void { + $this->disableGovernance(); + + $this->expectException(\InvalidArgumentException::class); + $this->manager->getAssignedLabelsForResources($this->alice, [new ResourceReference('forms:form', '1')]); + } + + public function testAssignedLabelsOfResourcesWithoutAccessAreNotRead(): void { + $this->backend->assigned = [ + 'tables:table/1' => [self::INTERNAL, self::HOLD], + 'tables:table/2' => [self::CONFIDENTIAL], + ]; + $other = new ResourceReference('tables:table', '3'); + + $labels = $this->manager->getAssignedLabelsForResources($this->alice, [$this->hidden, $this->table, $other]); + + $this->assertSame(['tables:table/2', 'tables:table/1', 'tables:table/3'], array_keys($labels)); + $this->assertSame([self::INTERNAL, self::HOLD], array_column($labels['tables:table/1'], 'id')); + $this->assertSame([], $labels['tables:table/2']); + $this->assertSame([], $labels['tables:table/3']); + $this->assertEquals([['getAssignedLabelsForResources', [$this->table, $other]]], $this->backend->calls); + + $this->assertSame([], $this->manager->getAssignedLabels($this->alice, $this->hidden)); + $this->assertCount(1, $this->backend->calls); + } + + public function testAssignableLabels(): void { + $this->assertSame( + [self::INTERNAL, self::CONFIDENTIAL], + array_column($this->manager->getAssignableLabels($this->alice, $this->table, LabelType::Sensitivity), 'id'), + ); + $this->assertCount(3, $this->manager->getAssignableLabels($this->alice, $this->table)); + + $this->assertSame([], $this->manager->getAssignableLabels($this->alice, $this->hidden)); + $this->assertCount(2, $this->backend->calls); + } + + public function testAssignLabel(): void { + $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL, 'Contains salaries'); + + $this->assertSame([self::INTERNAL], $this->backend->assigned['tables:table/1']); + $this->assertEquals([['assignLabel', $this->table, self::INTERNAL, 'Contains salaries']], $this->backend->calls); + $this->assertSame([[LabelAssignedEvent::class, self::INTERNAL, LabelType::Sensitivity]], $this->getDispatched()); + $this->assertSame($this->alice, $this->events[0]->actor); + $this->assertSame($this->table, $this->events[0]->resource); + } + + public function testAssigningAnAssignedLabelDispatchesNothing(): void { + $this->backend->assigned['tables:table/1'] = [self::INTERNAL]; + + $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL); + + $this->assertSame([], $this->events); + } + + public function testReplacedLabelIsRemovedBeforeTheNewOneIsAssigned(): void { + $this->backend->assigned['tables:table/1'] = [self::HOLD, self::INTERNAL]; + + $this->manager->assignLabel($this->alice, $this->table, self::CONFIDENTIAL); + + $this->assertSame([ + [LabelRemovedEvent::class, self::INTERNAL, LabelType::Sensitivity], + [LabelAssignedEvent::class, self::CONFIDENTIAL, LabelType::Sensitivity], + ], $this->getDispatched()); + } + + public function testRemoveLabel(): void { + $this->backend->assigned['tables:table/1'] = [self::INTERNAL]; + + $this->manager->removeLabel($this->alice, $this->table, self::INTERNAL, 'Outdated'); + + $this->assertSame([], $this->backend->assigned['tables:table/1']); + $this->assertEquals([['removeLabel', $this->table, self::INTERNAL, 'Outdated']], $this->backend->calls); + $this->assertSame([[LabelRemovedEvent::class, self::INTERNAL, LabelType::Sensitivity]], $this->getDispatched()); + } + + public function testAssignLabelWithoutAccess(): void { + try { + $this->manager->assignLabel($this->alice, $this->hidden, self::INTERNAL); + $this->fail('Expected a ResourceNotFoundException'); + } catch (ResourceNotFoundException) { + } + + $this->assertSame([], $this->backend->calls); + $this->assertSame([], $this->events); + } + + public function testRemoveLabelWithoutAccess(): void { + $this->backend->assigned['tables:table/2'] = [self::INTERNAL]; + + try { + $this->manager->removeLabel($this->alice, $this->hidden, self::INTERNAL); + $this->fail('Expected a ResourceNotFoundException'); + } catch (ResourceNotFoundException) { + } + + $this->assertSame([self::INTERNAL], $this->backend->assigned['tables:table/2']); + $this->assertSame([], $this->backend->calls); + } + + public function testExceptionsOfTheBackendArePassedOn(): void { + $this->backend->assigned['tables:table/1'] = [self::HOLD]; + + try { + $this->manager->assignLabel($this->alice, $this->table, 'unknown'); + $this->fail('Expected a LabelNotFoundException'); + } catch (LabelNotFoundException) { + } + try { + $this->manager->removeLabel($this->alice, $this->table, self::HOLD); + $this->fail('Expected a LabelPermissionException'); + } catch (LabelPermissionException) { + } + + $this->assertSame([], $this->events); + } +} From 04cf0a3c8657cf5bc90c44a29cbb9c6514c2db3a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 14 Nov 2026 15:47:54 +0100 Subject: [PATCH 2/2] refactor: let the governance app decide who may see and change labels Who may label a resource is not only a question of access: a legal hold is placed by someone the resource is not shared with. The label manager no longer checks the access of the actor before asking the governance app, it only handles the app not being enabled and unknown resource types. The governance app also dispatches the events for assigned and removed labels itself, so that changes made through its own API are announced too. Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- lib/private/Governance/LabelManager.php | 90 ++--- .../Governance/Events/LabelAssignedEvent.php | 4 +- .../Governance/Events/LabelRemovedEvent.php | 5 +- .../Exception/ResourceNotFoundException.php | 2 +- lib/unstable/Governance/ILabelManager.php | 7 +- tests/lib/Governance/LabelManagerTest.php | 318 +++++------------- 6 files changed, 126 insertions(+), 300 deletions(-) diff --git a/lib/private/Governance/LabelManager.php b/lib/private/Governance/LabelManager.php index 3223ef184df7c..01833cd8f34e8 100644 --- a/lib/private/Governance/LabelManager.php +++ b/lib/private/Governance/LabelManager.php @@ -10,23 +10,19 @@ namespace OC\Governance; use InvalidArgumentException; -use NCU\Governance\Events\LabelAssignedEvent; -use NCU\Governance\Events\LabelRemovedEvent; use NCU\Governance\Exception\GovernanceUnavailableException; -use NCU\Governance\Exception\ResourceNotFoundException; use NCU\Governance\ILabelManager; use NCU\Governance\LabelType; -use NCU\Resource\Permission\IAccessManager; +use NCU\Resource\Permission\IPermissionManager; use NCU\Resource\ResourceReference; use OCP\Actor\Actor; use OCP\App\IAppManager; -use OCP\EventDispatcher\IEventDispatcher; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; /** - * Delegates to the governance app after checking the access of the actor to the resources. + * Passes everything on to the governance app, which decides who may see and change a label. */ final class LabelManager implements ILabelManager { public const string APP_ID = 'governance'; @@ -37,8 +33,7 @@ final class LabelManager implements ILabelManager { public function __construct( private readonly IAppManager $appManager, private readonly ContainerInterface $container, - private readonly IAccessManager $accessManager, - private readonly IEventDispatcher $eventDispatcher, + private readonly IPermissionManager $permissionManager, private readonly LoggerInterface $logger, ) { } @@ -50,7 +45,7 @@ public function isAvailable(): bool { #[\Override] public function getAssignedLabelsForResources(Actor $actor, array $resources): array { - $accessible = $this->filterAccessible($actor, $resources); + $this->assertRegistered($resources); $labels = []; foreach ($resources as $resource) { @@ -58,11 +53,11 @@ public function getAssignedLabelsForResources(Actor $actor, array $resources): a } $backend = $this->getBackend(); - if ($backend === null || $accessible === []) { + if ($backend === null || $resources === []) { return $labels; } - foreach ($backend->getAssignedLabelsForResources($actor, $accessible) as $resourceKey => $resourceLabels) { + foreach ($backend->getAssignedLabelsForResources($actor, $resources) as $resourceKey => $resourceLabels) { if (isset($labels[$resourceKey])) { $labels[$resourceKey] = array_values($resourceLabels); } @@ -78,83 +73,40 @@ public function getAssignedLabels(Actor $actor, ResourceReference $resource): ar #[\Override] public function getAssignableLabels(Actor $actor, ResourceReference $resource, ?LabelType $type = null): array { - $accessible = $this->filterAccessible($actor, [$resource]); - $backend = $this->getBackend(); - if ($backend === null || $accessible === []) { - return []; - } + $this->assertRegistered([$resource]); - return $backend->getAssignableLabels($actor, $resource, $type); + return $this->getBackend()?->getAssignableLabels($actor, $resource, $type) ?? []; } #[\Override] public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { - $this->change($actor, $resource, static fn (ILabelManager $backend) => $backend->assignLabel($actor, $resource, $labelId, $reason)); + $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->change($actor, $resource, static fn (ILabelManager $backend) => $backend->removeLabel($actor, $resource, $labelId, $reason)); - } - - /** - * Run the change and dispatch an event for every label that is no longer or newly assigned afterwards. - * - * @param \Closure(ILabelManager): void $change - * @throws InvalidArgumentException - * @throws GovernanceUnavailableException - * @throws ResourceNotFoundException - */ - private function change(Actor $actor, ResourceReference $resource, \Closure $change): void { - $accessible = $this->filterAccessible($actor, [$resource]); - $backend = $this->getBackend(); - if ($backend === null) { - throw new GovernanceUnavailableException('The governance app is not enabled'); - } - if ($accessible === []) { - throw new ResourceNotFoundException('The resource ' . $resource->key() . ' does not exist or cannot be accessed'); - } - - $before = $this->getAssignedTypes($backend, $actor, $resource); - $change($backend); - $after = $this->getAssignedTypes($backend, $actor, $resource); - - foreach (array_diff_key($before, $after) as $labelId => $labelType) { - $this->eventDispatcher->dispatchTyped(new LabelRemovedEvent($actor, $resource, (string)$labelId, $labelType)); - } - foreach (array_diff_key($after, $before) as $labelId => $labelType) { - $this->eventDispatcher->dispatchTyped(new LabelAssignedEvent($actor, $resource, (string)$labelId, $labelType)); - } - } - - /** - * @return array types of the assigned labels by label ID, numeric IDs are integer keys - */ - private function getAssignedTypes(ILabelManager $backend, Actor $actor, ResourceReference $resource): array { - $types = []; - foreach ($backend->getAssignedLabels($actor, $resource) as $label) { - $types[$label->id] = $label->type; - } - - return $types; + $this->assertRegistered([$resource]); + $this->getBackendOrThrow()->removeLabel($actor, $resource, $labelId, $reason); } /** * @param list $resources - * @return list the resources the actor has access to * @throws InvalidArgumentException if a resource type is not registered */ - private function filterAccessible(Actor $actor, array $resources): array { - $access = $this->accessManager->getAccess($actor, $resources); - - $accessible = []; + private function assertRegistered(array $resources): void { foreach ($resources as $resource) { - if (isset($access[$resource->key()])) { - $accessible[$resource->key()] = $resource; + if ($this->permissionManager->getResourceType($resource->type) === null) { + throw new InvalidArgumentException('The resource type ' . $resource->type . ' is not registered'); } } + } - return array_values($accessible); + /** + * @throws GovernanceUnavailableException + */ + private function getBackendOrThrow(): ILabelManager { + return $this->getBackend() ?? throw new GovernanceUnavailableException('The governance app is not enabled'); } private function getBackend(): ?ILabelManager { diff --git a/lib/unstable/Governance/Events/LabelAssignedEvent.php b/lib/unstable/Governance/Events/LabelAssignedEvent.php index 7211871a65a05..35df240440e73 100644 --- a/lib/unstable/Governance/Events/LabelAssignedEvent.php +++ b/lib/unstable/Governance/Events/LabelAssignedEvent.php @@ -12,15 +12,17 @@ use NCU\Governance\LabelType; use NCU\Resource\ResourceReference; use OCP\Actor\Actor; +use OCP\AppFramework\Attribute\ExceptionalImplementable; use OCP\AppFramework\Attribute\Listenable; use OCP\EventDispatcher\Event; /** - * Dispatched after a label was assigned to a resource. + * Dispatched by the governance app after a label was assigned to a resource. * * @experimental 36.0.0 */ #[Listenable(since: '36.0.0')] +#[ExceptionalImplementable(app: 'governance')] final class LabelAssignedEvent extends Event { /** * @param non-empty-string $labelId diff --git a/lib/unstable/Governance/Events/LabelRemovedEvent.php b/lib/unstable/Governance/Events/LabelRemovedEvent.php index 82b6ee60ea211..fd4019f501d25 100644 --- a/lib/unstable/Governance/Events/LabelRemovedEvent.php +++ b/lib/unstable/Governance/Events/LabelRemovedEvent.php @@ -12,15 +12,18 @@ use NCU\Governance\LabelType; use NCU\Resource\ResourceReference; use OCP\Actor\Actor; +use OCP\AppFramework\Attribute\ExceptionalImplementable; use OCP\AppFramework\Attribute\Listenable; use OCP\EventDispatcher\Event; /** - * Dispatched after a label was removed from a resource, also when another label replaced it. + * Dispatched by the governance app after a label was removed from a resource, also when another label replaced it. + * It is not dispatched when the label or the resource is deleted. * * @experimental 36.0.0 */ #[Listenable(since: '36.0.0')] +#[ExceptionalImplementable(app: 'governance')] final class LabelRemovedEvent extends Event { /** * @param non-empty-string $labelId diff --git a/lib/unstable/Governance/Exception/ResourceNotFoundException.php b/lib/unstable/Governance/Exception/ResourceNotFoundException.php index 74c5d3c768961..34ab43d8e1cbd 100644 --- a/lib/unstable/Governance/Exception/ResourceNotFoundException.php +++ b/lib/unstable/Governance/Exception/ResourceNotFoundException.php @@ -13,7 +13,7 @@ use OCP\AppFramework\Attribute\ExceptionalImplementable; /** - * The actor has no access to the resource. + * The resource does not exist or the actor may not label it for lack of access. * * @experimental 36.0.0 */ diff --git a/lib/unstable/Governance/ILabelManager.php b/lib/unstable/Governance/ILabelManager.php index c6dc8eacfb68a..adcebc76b3653 100644 --- a/lib/unstable/Governance/ILabelManager.php +++ b/lib/unstable/Governance/ILabelManager.php @@ -24,6 +24,9 @@ * The labels are managed by the governance app. Without it no labels are returned and they cannot be changed. * This is not an extension point, only the governance app implements it. * + * The governance app decides which labels an actor sees and may change. This usually requires access + * to the resource, but not always, e.g. a legal hold is placed by someone the resource is not shared with. + * * Only labels assigned to the resource itself are returned, * e.g. not the ones a file inherits from its parent folders. * @@ -42,7 +45,7 @@ public function isAvailable(): bool; /** * Get the labels assigned to the resources, the resources can be of different types. * - * The labels can be of any type. Resources the actor has no access to have no labels. + * The labels can be of any type. A resource has no labels if the actor may not see any of them. * * @param list $resources * @return array> keyed by {@see ResourceReference::key}, with an entry for every resource @@ -64,7 +67,7 @@ public function getAssignedLabels(Actor $actor, ResourceReference $resource): ar * 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 has no access to the resource. + * No labels are returned if the actor may not label the resource. * * @param ?LabelType $type Only return labels of this type * @return list diff --git a/tests/lib/Governance/LabelManagerTest.php b/tests/lib/Governance/LabelManagerTest.php index 591e167162093..a2a65382a1619 100644 --- a/tests/lib/Governance/LabelManagerTest.php +++ b/tests/lib/Governance/LabelManagerTest.php @@ -9,26 +9,20 @@ namespace Test\Governance; -use NCU\Governance\Events\LabelAssignedEvent; -use NCU\Governance\Events\LabelRemovedEvent; use NCU\Governance\Exception\GovernanceUnavailableException; -use NCU\Governance\Exception\LabelNotFoundException; use NCU\Governance\Exception\LabelPermissionException; use NCU\Governance\Exception\ResourceNotFoundException; use NCU\Governance\ILabelManager; use NCU\Governance\LabelModification; use NCU\Governance\LabelType; use NCU\Governance\ResourceLabel; -use NCU\Resource\Permission\AccessEntry; -use NCU\Resource\Permission\CorePermission; -use NCU\Resource\Permission\IAccessManager; +use NCU\Resource\IResourceType; +use NCU\Resource\Permission\IPermissionManager; use NCU\Resource\ResourceReference; use OC\Governance\LabelManager; use OCP\Actor\Actor; use OCP\Actor\Principal; use OCP\App\IAppManager; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventDispatcher; use OCP\Server; use PHPUnit\Framework\MockObject\MockObject; use Psr\Container\ContainerInterface; @@ -36,146 +30,48 @@ use Test\TestCase; class LabelManagerTest extends TestCase { - private const string INTERNAL = '1'; - private const string CONFIDENTIAL = '2'; - private const string HOLD = '3'; - private IAppManager&MockObject $appManager; private ContainerInterface&MockObject $container; - private IAccessManager&MockObject $accessManager; private LoggerInterface&MockObject $logger; + private ILabelManager&MockObject $backend; private LabelManager $manager; private Actor $alice; private ResourceReference $table; - private ResourceReference $hidden; - - /** @var list */ - private array $events = []; + private ResourceReference $otherTable; - /** @var ILabelManager&object{assigned: array>, calls: list} */ - private object $backend; + /** @var object the service the container returns for the governance app */ + private object $service; protected function setUp(): void { parent::setUp(); $this->alice = Actor::of(Principal::user('alice')); $this->table = new ResourceReference('tables:table', '1'); - $this->hidden = new ResourceReference('tables:table', '2'); - - $this->appManager = $this->createMock(IAppManager::class); - $this->appManager->method('isEnabledForAnyone')->with('governance')->willReturn(true); + $this->otherTable = new ResourceReference('tables:table', '2'); - $this->backend = $this->createBackend(); + $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->backend); - - // Alice has access to every resource but the hidden table - $this->accessManager = $this->createMock(IAccessManager::class); - $this->accessManager->method('getAccess')->willReturnCallback(function (Actor $actor, array $resources): array { - $access = []; - foreach ($resources as $resource) { - if ($resource->type !== 'tables:table') { - throw new \InvalidArgumentException('The resource type ' . $resource->type . ' is not registered'); - } - if ($resource->key() !== $this->hidden->key()) { - $access[$resource->key()] = [new AccessEntry(AccessEntry::RECIPIENT_USER, 'alice', [CorePermission::Read->value])]; - } - } - return $access; - }); - - $eventDispatcher = $this->createMock(IEventDispatcher::class); - $eventDispatcher->method('dispatchTyped')->willReturnCallback(function (Event $event): void { - $this->events[] = $event; - }); - + $this->container->method('get')->with(LabelManager::BACKEND)->willReturnCallback(fn (): object => $this->service); $this->logger = $this->createMock(LoggerInterface::class); - $this->manager = new LabelManager($this->appManager, $this->container, $this->accessManager, $eventDispatcher, $this->logger); - } - /** - * A label manager with two sensitivity labels, of which a resource has at most one, and a hold label. - */ - private function createBackend(): ILabelManager { - return new class implements ILabelManager { - private const array LABELS = [ - '1' => [LabelType::Sensitivity, 'Internal'], - '2' => [LabelType::Sensitivity, 'Confidential'], - '3' => [LabelType::Hold, 'Audit 2026'], - ]; - - /** @var array> label IDs by resource key */ - public array $assigned = []; - - /** @var list */ - public array $calls = []; - - public function isAvailable(): bool { - return true; - } - - public function getAssignedLabelsForResources(Actor $actor, array $resources): array { - $this->calls[] = ['getAssignedLabelsForResources', $resources]; - $labels = []; - foreach ($resources as $resource) { - $labels[$resource->key()] = array_map($this->createLabel(...), $this->assigned[$resource->key()] ?? []); - } - // A resource that was not asked for - $labels['tables:table/99'] = [$this->createLabel('3')]; - return $labels; - } - - public function getAssignedLabels(Actor $actor, ResourceReference $resource): array { - return array_map($this->createLabel(...), $this->assigned[$resource->key()] ?? []); - } - - public function getAssignableLabels(Actor $actor, ResourceReference $resource, ?LabelType $type = null): array { - $this->calls[] = ['getAssignableLabels', $resource, $type]; - return array_values(array_filter( - array_map($this->createLabel(...), array_map(strval(...), array_keys(self::LABELS))), - static fn (ResourceLabel $label): bool => $type === null || $label->type === $type, - )); - } - - public function assignLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { - $this->calls[] = ['assignLabel', $resource, $labelId, $reason]; - if (!isset(self::LABELS[$labelId])) { - throw new LabelNotFoundException(); - } - - $assigned = $this->assigned[$resource->key()] ?? []; - if (self::LABELS[$labelId][0] === LabelType::Sensitivity) { - $assigned = array_filter($assigned, static fn (string $id): bool => self::LABELS[$id][0] !== LabelType::Sensitivity); - } - $this->assigned[$resource->key()] = array_values(array_unique([...$assigned, $labelId])); - } - - public function removeLabel(Actor $actor, ResourceReference $resource, string $labelId, ?string $reason = null): void { - $this->calls[] = ['removeLabel', $resource, $labelId, $reason]; - if (self::LABELS[$labelId][0] === LabelType::Hold) { - throw new LabelPermissionException(); - } - $this->assigned[$resource->key()] = array_values(array_diff($this->assigned[$resource->key()] ?? [], [$labelId])); - } - - private function createLabel(string $id): ResourceLabel { - return new ResourceLabel($id, self::LABELS[$id][0], self::LABELS[$id][1], null, null, (int)$id, true, LabelModification::Yes, LabelModification::Yes); - } - }; + $this->manager = $this->createManager(true); } - private function disableGovernance(): void { + private function createManager(bool $governanceEnabled): LabelManager { $this->appManager = $this->createMock(IAppManager::class); - $this->appManager->method('isEnabledForAnyone')->willReturn(false); - $this->container->expects($this->never())->method('get'); - $this->manager = new LabelManager($this->appManager, $this->container, $this->accessManager, $this->createMock(IEventDispatcher::class), $this->logger); + $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); } - /** - * @return list - */ - private function getDispatched(): array { - return array_map(static fn (LabelAssignedEvent|LabelRemovedEvent $event): array => [$event::class, $event->labelId, $event->labelType], $this->events); + 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 { @@ -185,160 +81,130 @@ public function testIsRegistered(): void { $this->assertFalse($manager->isAvailable()); } + public function testIsAvailable(): void { + $this->backend->method('isAvailable')->willReturn(true); + + $this->assertTrue($this->manager->isAvailable()); + } + public function testWithoutGovernanceNoLabelsAreReturned(): void { - $this->disableGovernance(); + $manager = $this->createManager(false); + $this->container->expects($this->never())->method('get'); - $this->assertFalse($this->manager->isAvailable()); - $this->assertSame(['tables:table/1' => [], 'tables:table/2' => []], $this->manager->getAssignedLabelsForResources($this->alice, [$this->table, $this->hidden])); - $this->assertSame([], $this->manager->getAssignedLabels($this->alice, $this->table)); - $this->assertSame([], $this->manager->getAssignableLabels($this->alice, $this->table)); + $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->disableGovernance(); - $this->expectException(GovernanceUnavailableException::class); - $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL); + $this->createManager(false)->assignLabel($this->alice, $this->table, '1'); } public function testWithoutGovernanceLabelsCannotBeRemoved(): void { - $this->disableGovernance(); - $this->expectException(GovernanceUnavailableException::class); - $this->manager->removeLabel($this->alice, $this->table, self::INTERNAL); + $this->createManager(false)->removeLabel($this->alice, $this->table, '1'); } - public function testBackendThatCannotBeLoadedIsUnavailable(): void { - $this->backend = new \stdClass(); + 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 testIsAvailable(): void { - $this->assertTrue($this->manager->isAvailable()); + 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->getAssignedLabels($this->alice, new ResourceReference('forms:form', '1')); + $this->manager->getAssignedLabelsForResources($this->alice, [$this->table, new ResourceReference('forms:form', '1')]); } public function testUnregisteredResourceTypeWithoutGovernance(): void { - $this->disableGovernance(); - $this->expectException(\InvalidArgumentException::class); - $this->manager->getAssignedLabelsForResources($this->alice, [new ResourceReference('forms:form', '1')]); + $this->createManager(false)->getAssignableLabels($this->alice, new ResourceReference('forms:form', '1')); } - public function testAssignedLabelsOfResourcesWithoutAccessAreNotRead(): void { - $this->backend->assigned = [ - 'tables:table/1' => [self::INTERNAL, self::HOLD], - 'tables:table/2' => [self::CONFIDENTIAL], - ]; - $other = new ResourceReference('tables:table', '3'); - - $labels = $this->manager->getAssignedLabelsForResources($this->alice, [$this->hidden, $this->table, $other]); - - $this->assertSame(['tables:table/2', 'tables:table/1', 'tables:table/3'], array_keys($labels)); - $this->assertSame([self::INTERNAL, self::HOLD], array_column($labels['tables:table/1'], 'id')); - $this->assertSame([], $labels['tables:table/2']); - $this->assertSame([], $labels['tables:table/3']); - $this->assertEquals([['getAssignedLabelsForResources', [$this->table, $other]]], $this->backend->calls); + public function testLabelsCannotBeAssignedToAnUnregisteredResourceType(): void { + $this->backend->expects($this->never())->method('assignLabel'); - $this->assertSame([], $this->manager->getAssignedLabels($this->alice, $this->hidden)); - $this->assertCount(1, $this->backend->calls); + $this->expectException(\InvalidArgumentException::class); + $this->manager->assignLabel($this->alice, new ResourceReference('forms:form', '1'), '1'); } - public function testAssignableLabels(): void { - $this->assertSame( - [self::INTERNAL, self::CONFIDENTIAL], - array_column($this->manager->getAssignableLabels($this->alice, $this->table, LabelType::Sensitivity), 'id'), - ); - $this->assertCount(3, $this->manager->getAssignableLabels($this->alice, $this->table)); + /** + * 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([], $this->manager->getAssignableLabels($this->alice, $this->hidden)); - $this->assertCount(2, $this->backend->calls); + $this->assertSame(['tables:table/2' => [], 'tables:table/1' => [$internal, $hold]], $labels); } - public function testAssignLabel(): void { - $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL, 'Contains salaries'); + public function testGovernanceIsNotAskedWithoutResources(): void { + $this->backend->expects($this->never())->method('getAssignedLabelsForResources'); - $this->assertSame([self::INTERNAL], $this->backend->assigned['tables:table/1']); - $this->assertEquals([['assignLabel', $this->table, self::INTERNAL, 'Contains salaries']], $this->backend->calls); - $this->assertSame([[LabelAssignedEvent::class, self::INTERNAL, LabelType::Sensitivity]], $this->getDispatched()); - $this->assertSame($this->alice, $this->events[0]->actor); - $this->assertSame($this->table, $this->events[0]->resource); + $this->assertSame([], $this->manager->getAssignedLabelsForResources($this->alice, [])); } - public function testAssigningAnAssignedLabelDispatchesNothing(): void { - $this->backend->assigned['tables:table/1'] = [self::INTERNAL]; - - $this->manager->assignLabel($this->alice, $this->table, self::INTERNAL); + public function testGetAssignedLabels(): void { + $internal = $this->createLabel('1'); + $this->backend->method('getAssignedLabelsForResources')->willReturn(['tables:table/1' => [$internal]]); - $this->assertSame([], $this->events); + $this->assertSame([$internal], $this->manager->getAssignedLabels($this->alice, $this->table)); } - public function testReplacedLabelIsRemovedBeforeTheNewOneIsAssigned(): void { - $this->backend->assigned['tables:table/1'] = [self::HOLD, self::INTERNAL]; + 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->manager->assignLabel($this->alice, $this->table, self::CONFIDENTIAL); - - $this->assertSame([ - [LabelRemovedEvent::class, self::INTERNAL, LabelType::Sensitivity], - [LabelAssignedEvent::class, self::CONFIDENTIAL, LabelType::Sensitivity], - ], $this->getDispatched()); + $this->assertSame($labels, $this->manager->getAssignableLabels($this->alice, $this->table, LabelType::Sensitivity)); } - public function testRemoveLabel(): void { - $this->backend->assigned['tables:table/1'] = [self::INTERNAL]; - - $this->manager->removeLabel($this->alice, $this->table, self::INTERNAL, 'Outdated'); + public function testAssignLabel(): void { + $this->backend->expects($this->once())->method('assignLabel')->with($this->alice, $this->table, '1', 'Contains salaries'); - $this->assertSame([], $this->backend->assigned['tables:table/1']); - $this->assertEquals([['removeLabel', $this->table, self::INTERNAL, 'Outdated']], $this->backend->calls); - $this->assertSame([[LabelRemovedEvent::class, self::INTERNAL, LabelType::Sensitivity]], $this->getDispatched()); + $this->manager->assignLabel($this->alice, $this->table, '1', 'Contains salaries'); } - public function testAssignLabelWithoutAccess(): void { - try { - $this->manager->assignLabel($this->alice, $this->hidden, self::INTERNAL); - $this->fail('Expected a ResourceNotFoundException'); - } catch (ResourceNotFoundException) { - } + public function testRemoveLabel(): void { + $this->backend->expects($this->once())->method('removeLabel')->with($this->alice, $this->table, '1', 'Outdated'); - $this->assertSame([], $this->backend->calls); - $this->assertSame([], $this->events); + $this->manager->removeLabel($this->alice, $this->table, '1', 'Outdated'); } - public function testRemoveLabelWithoutAccess(): void { - $this->backend->assigned['tables:table/2'] = [self::INTERNAL]; + public function testExceptionsOfGovernanceArePassedOn(): void { + $this->backend->method('assignLabel')->willThrowException(new ResourceNotFoundException()); + $this->backend->method('removeLabel')->willThrowException(new LabelPermissionException()); try { - $this->manager->removeLabel($this->alice, $this->hidden, self::INTERNAL); + $this->manager->assignLabel($this->alice, $this->table, '1'); $this->fail('Expected a ResourceNotFoundException'); } catch (ResourceNotFoundException) { } - $this->assertSame([self::INTERNAL], $this->backend->assigned['tables:table/2']); - $this->assertSame([], $this->backend->calls); - } - - public function testExceptionsOfTheBackendArePassedOn(): void { - $this->backend->assigned['tables:table/1'] = [self::HOLD]; - - try { - $this->manager->assignLabel($this->alice, $this->table, 'unknown'); - $this->fail('Expected a LabelNotFoundException'); - } catch (LabelNotFoundException) { - } - try { - $this->manager->removeLabel($this->alice, $this->table, self::HOLD); - $this->fail('Expected a LabelPermissionException'); - } catch (LabelPermissionException) { - } - - $this->assertSame([], $this->events); + $this->expectException(LabelPermissionException::class); + $this->manager->removeLabel($this->alice, $this->table, '1'); } }