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');
|
||||
}
|
||||
|
||||
if ($this->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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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