feat(core): implement tenant-scoped system store manager

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-28 20:47:27 -04:00
parent a1413db12f
commit e137de1183
5 changed files with 383 additions and 2 deletions
+167
View File
@@ -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
{
}
+11 -2
View File
@@ -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';
})(),
);
}
}