From e137de1183ac363b7f24f1d2eae8b1868149387c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 28 Aug 2026 20:47:27 -0400 Subject: [PATCH] feat(core): implement tenant-scoped system store manager Signed-off-by: Sebastian Krupinski --- core/lib/SystemStore/SystemStoreManager.php | 167 +++++++++++++++++ .../lib/SystemStore/InvalidKeyException.php | 14 ++ shared/lib/SystemStore/StoreReference.php | 13 +- .../lib/SystemStore/SystemStoreException.php | 14 ++ .../SystemStore/SystemStoreManagerTest.php | 177 ++++++++++++++++++ 5 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 core/lib/SystemStore/SystemStoreManager.php create mode 100644 shared/lib/SystemStore/InvalidKeyException.php create mode 100644 shared/lib/SystemStore/SystemStoreException.php create mode 100644 tests/php/Unit/SystemStore/SystemStoreManagerTest.php diff --git a/core/lib/SystemStore/SystemStoreManager.php b/core/lib/SystemStore/SystemStoreManager.php new file mode 100644 index 0000000..cc75eb3 --- /dev/null +++ b/core/lib/SystemStore/SystemStoreManager.php @@ -0,0 +1,167 @@ +resolve($tenantId, $store); + $info = $service->stat($this->qualify($reference, $key)); + + return $info === null ? null : $this->logicalInfo($info, $reference); + } + + public function read(string $tenantId, string $store, string $key): ?BinaryResource + { + [$service, $reference] = $this->resolve($tenantId, $store); + return $service->read($this->qualify($reference, $key)); + } + + public function write( + string $tenantId, + string $store, + string $key, + BinaryResource $content, + array $metadata = [], + ?WriteCondition $condition = null, + ): BlobInfo { + [$service, $reference] = $this->resolve($tenantId, $store); + $info = $service->write( + $this->qualify($reference, $key), + $content, + $metadata, + $condition, + ); + + return $this->logicalInfo($info, $reference); + } + + public function delete( + string $tenantId, + string $store, + string $key, + ?WriteCondition $condition = null, + ): bool { + [$service, $reference] = $this->resolve($tenantId, $store); + return $service->delete($this->qualify($reference, $key), $condition); + } + + public function list(string $tenantId, string $store, string $prefix = ''): iterable + { + [$service, $reference] = $this->resolve($tenantId, $store); + $physicalPrefix = $reference->namespace . '/'; + if ($prefix !== '') { + $physicalPrefix .= $this->normalizeKey($prefix, true); + } + + return $this->logicalItems($service->list($physicalPrefix), $reference); + } + + /** @return array{SystemStoreServiceInterface, StoreReference} */ + private function resolve(string $tenantId, string $store): array + { + $tenant = $this->tenants->fetchById($tenantId); + if ($tenant === null) { + throw new SystemStoreException("Tenant '{$tenantId}' was not found"); + } + + $reference = $tenant->getConfiguration()->stores()->store($store); + if ($reference === null) { + throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'"); + } + + $provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider); + if (!$provider instanceof ProviderBaseInterface) { + throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible"); + } + + $service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service); + if (!$service instanceof SystemStoreServiceInterface) { + throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible"); + } + + return [$service, $reference]; + } + + private function qualify(StoreReference $reference, string $key): string + { + return $reference->namespace . '/' . $this->normalizeKey($key); + } + + private function normalizeKey(string $key, bool $allowTrailingSlash = false): string + { + if ( + $key === '' + || str_starts_with($key, '/') + || (!$allowTrailingSlash && str_ends_with($key, '/')) + || str_contains($key, '\\') + || str_contains($key, "\0") + ) { + throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys'); + } + + $segments = explode('/', $key); + if ($allowTrailingSlash && end($segments) === '') { + array_pop($segments); + } + if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) { + throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments'); + } + + return $key; + } + + private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo + { + $prefix = $reference->namespace . '/'; + if (!str_starts_with($info->key, $prefix)) { + throw new SystemStoreException('System-store provider returned a blob outside the configured namespace'); + } + + return new BlobInfo( + key: substr($info->key, strlen($prefix)), + mimeType: $info->mimeType, + size: $info->size, + etag: $info->etag, + modifiedAt: $info->modifiedAt, + attributes: $info->attributes, + ); + } + + /** + * @param iterable $items + * @return iterable + */ + private function logicalItems(iterable $items, StoreReference $reference): iterable + { + foreach ($items as $item) { + if (!$item instanceof BlobInfo) { + throw new SystemStoreException('System-store provider returned invalid listing metadata'); + } + yield $this->logicalInfo($item, $reference); + } + } +} diff --git a/shared/lib/SystemStore/InvalidKeyException.php b/shared/lib/SystemStore/InvalidKeyException.php new file mode 100644 index 0000000..0a214a4 --- /dev/null +++ b/shared/lib/SystemStore/InvalidKeyException.php @@ -0,0 +1,14 @@ +namespace === '') { - throw new InvalidArgumentException('System-store namespace cannot be empty'); + if ( + $this->namespace === '' + || str_starts_with($this->namespace, '/') + || str_ends_with($this->namespace, '/') + || str_contains($this->namespace, '\\') + || str_contains($this->namespace, "\0") + || in_array('', explode('/', $this->namespace), true) + || in_array('.', explode('/', $this->namespace), true) + || in_array('..', explode('/', $this->namespace), true) + ) { + throw new InvalidArgumentException('System-store namespace must be a normalized relative key'); } } diff --git a/shared/lib/SystemStore/SystemStoreException.php b/shared/lib/SystemStore/SystemStoreException.php new file mode 100644 index 0000000..f9ef962 --- /dev/null +++ b/shared/lib/SystemStore/SystemStoreException.php @@ -0,0 +1,14 @@ +jsonDeserialize([ + 'stores' => [ + 'previews' => [ + 'provider' => 'storage-provider', + 'service' => 'preview-storage', + 'namespace' => 'tenant-previews', + ], + ], + ]); + $tenant = (new TenantObject()) + ->setIdentifier('tenant-a') + ->setConfiguration($configuration); + + $this->tenants = $this->createMock(TenantService::class); + $this->tenants->method('fetchById')->with('tenant-a')->willReturn($tenant); + + $this->service = $this->createMock(SystemStoreServiceInterface::class); + $this->provider = $this->createMock(ProviderBaseInterface::class); + $this->provider->method('serviceFetch') + ->with('tenant-a', SystemIdentity::USER, 'preview-storage') + ->willReturn($this->service); + + $this->providers = $this->createMock(ProviderManager::class); + $this->providers->method('resolve') + ->with(ProviderInterface::TYPE_SYSTEM_STORE, 'storage-provider') + ->willReturn($this->provider); + + $this->manager = new SystemStoreManager($this->tenants, $this->providers); + } + + #[Test] + public function delegatesStatWithPhysicalNamespaceAndReturnsLogicalKey(): void + { + $this->service->expects(self::once()) + ->method('stat') + ->with('tenant-previews/documents/report.pdf') + ->willReturn($this->info('tenant-previews/documents/report.pdf')); + + $info = $this->manager->stat('tenant-a', 'previews', 'documents/report.pdf'); + + self::assertSame('documents/report.pdf', $info?->key); + } + + #[Test] + public function delegatesReadDeleteAndWriteWithPhysicalNamespace(): void + { + $resource = $this->resource(); + + $this->service->expects(self::once()) + ->method('read') + ->with('tenant-previews/image.webp') + ->willReturn($resource); + $this->service->expects(self::once()) + ->method('delete') + ->with('tenant-previews/image.webp', null) + ->willReturn(true); + $this->service->expects(self::once()) + ->method('write') + ->with('tenant-previews/image.webp', $resource, ['variant' => 'popover'], null) + ->willReturn($this->info('tenant-previews/image.webp')); + + self::assertSame($resource, $this->manager->read('tenant-a', 'previews', 'image.webp')); + self::assertTrue($this->manager->delete('tenant-a', 'previews', 'image.webp')); + self::assertSame( + 'image.webp', + $this->manager->write( + 'tenant-a', + 'previews', + 'image.webp', + $resource, + ['variant' => 'popover'], + )->key, + ); + } + + #[Test] + public function lazilyListsLogicalKeysWithinTheNamespace(): void + { + $this->service->expects(self::once()) + ->method('list') + ->with('tenant-previews/documents/') + ->willReturn((function (): iterable { + yield $this->info('tenant-previews/documents/one.webp'); + yield $this->info('tenant-previews/documents/two.webp'); + })()); + + $items = iterator_to_array($this->manager->list('tenant-a', 'previews', 'documents/')); + + self::assertSame(['documents/one.webp', 'documents/two.webp'], array_column($items, 'key')); + } + + #[Test] + public function rejectsTraversalKeys(): void + { + $this->service->expects(self::never())->method('read'); + $this->expectException(InvalidKeyException::class); + + $this->manager->read('tenant-a', 'previews', '../secret'); + } + + #[Test] + public function rejectsProviderResultsOutsideTheConfiguredNamespace(): void + { + $this->service->expects(self::once()) + ->method('stat') + ->willReturn($this->info('another-tenant/secret')); + $this->expectException(SystemStoreException::class); + + $this->manager->stat('tenant-a', 'previews', 'secret'); + } + + #[Test] + public function rejectsAnUnconfiguredLogicalStore(): void + { + $this->service->expects(self::never())->method('stat'); + $this->expectException(SystemStoreException::class); + $this->expectExceptionMessage("System store 'icons' is not configured"); + + $this->manager->stat('tenant-a', 'icons', 'logo.svg'); + } + + private function info(string $key): BlobInfo + { + return new BlobInfo( + key: $key, + mimeType: 'image/webp', + size: 123, + etag: 'revision-1', + modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'), + ); + } + + private function resource(): BinaryResource + { + return new BinaryResource( + 'image.webp', + 'image/webp', + (function (): \Generator { + yield 'content'; + })(), + ); + } +}