feat(core): implement tenant-scoped system store manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\SystemStore;
|
||||||
|
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use KTXF\SystemStore\InvalidKeyException;
|
||||||
|
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||||
|
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||||
|
use KTXF\SystemStore\StoreReference;
|
||||||
|
use KTXF\SystemStore\SystemStoreException;
|
||||||
|
use KTXF\SystemStore\SystemStoreManagerInterface;
|
||||||
|
use KTXF\SystemStore\WriteCondition;
|
||||||
|
|
||||||
|
final readonly class SystemStoreManager implements SystemStoreManagerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantService $tenants,
|
||||||
|
private ProviderManager $providers,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
|
||||||
|
{
|
||||||
|
[$service, $reference] = $this->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<BlobInfo> $items
|
||||||
|
* @return iterable<BlobInfo>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a logical system-store key is not safely normalized.
|
||||||
|
*/
|
||||||
|
final class InvalidKeyException extends InvalidArgumentException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -24,8 +24,17 @@ final readonly class StoreReference
|
|||||||
throw new InvalidArgumentException('System-store service cannot be empty');
|
throw new InvalidArgumentException('System-store service cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->namespace === '') {
|
if (
|
||||||
throw new InvalidArgumentException('System-store namespace cannot be empty');
|
$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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a tenant system store cannot be resolved or trusted.
|
||||||
|
*/
|
||||||
|
final class SystemStoreException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\SystemStore;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\SystemStore\SystemStoreManager;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use KTXF\SystemStore\InvalidKeyException;
|
||||||
|
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||||
|
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||||
|
use KTXF\SystemStore\SystemStoreException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SystemStoreManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
private TenantService&MockObject $tenants;
|
||||||
|
private ProviderManager&MockObject $providers;
|
||||||
|
private ProviderBaseInterface&MockObject $provider;
|
||||||
|
private SystemStoreServiceInterface&MockObject $service;
|
||||||
|
private SystemStoreManager $manager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$configuration = (new TenantConfiguration())->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';
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user