Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions docs/cache.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Cache facade
============

``Infocyph\CacheLayer\Cache\Cache`` implements PSR-6, PSR-16, and
``ArrayAccess``. It adds generation-tagged records, native bulk operations, bounded
stampede protection, tiering, metrics, and per-instance payload policy.
``Infocyph\CacheLayer\Cache\Cache`` implements PSR-6, PSR-16, ``ArrayAccess``,
and ``AuthenticationStateCacheInterface``. It adds generation-tagged records,
native bulk operations, bounded stampede protection, tiering, metrics, and
per-instance payload policy.

Factories
---------
Expand Down Expand Up @@ -91,6 +92,31 @@ Adapters can be used directly as PSR-6 pools, but CacheLayer's tagging,
stampede protection, metrics, and fail-open policy live in the ``Cache``
facade. Prefer the facade unless the narrower adapter behavior is intentional.

Authentication-state capability
--------------------------------

Security-sensitive consumers can inspect only the effective policy they need:

.. code-block:: php

$cache->isFailOpen();
$cache->hasPayloadIntegrity();
$cache->isAuthoritative();
$cache->authenticationStateLock(); // ?LockProviderInterface

``isAuthoritative()`` is false for the null and tiered facades. A tiered cache
cannot safely provide a current monotonic authentication value because a stale
L1 read may hide newer lower-tier state. CacheLayer cannot detect whether an
injected Redis/Valkey/SQL client reads from a replica; applications must provide
a primary/authoritative connection.

``authenticationStateLock()`` returns a provider only when one was explicitly
configured by the facade factory, constructor, or ``setLockProvider()`` and the
cache is authoritative. Direct construction without a lock, ``nullStore()``,
and ``tiered()`` return null. This lets a consumer reject unsupported state
configuration instead of silently coordinating a distributed cache with a
process-local lock.

Tiering
-------

Expand Down
8 changes: 8 additions & 0 deletions docs/metrics-and-locking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Facade helpers:
* ``useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self``
* ``useValkeyLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self``
* ``useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self``
* ``authenticationStateLock(): ?LockProviderInterface``

Custom lock providers can implement ``LockProviderInterface``:

Expand Down Expand Up @@ -128,6 +129,13 @@ Adapter defaults:
* Redis adapter factory sets ``RedisLockProvider``
* Valkey adapter factory sets ``RedisLockProvider``
* Memcached adapter factory sets ``MemcachedLockProvider``
* PDO/SQLite adapter factories set ``PdoLockProvider`` or its file fallback
* local memory/file/APCu/shared-memory factories set ``FileLockProvider``

MongoDB, ScyllaDB, Redis Cluster, null-store, and directly constructed caches do
not claim an authentication-state lock until the caller explicitly configures
one. Tiered caches never expose an authentication-state lock because their read
path is not authoritative for monotonic state.
* PDO/SQLite adapter factories set ``PdoLockProvider``; SQLite uses its
file-lock fallback
* all other adapters use ``FileLockProvider`` by default
17 changes: 17 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ Recommended Production Profile
4. Prefer non-executable file storage adapters over ``phpFiles`` where
possible.

Authentication State
--------------------

For replay counters, one-time consumption, authorization state, or another
security decision, require all of the following:

* ``isFailOpen() === false``;
* ``hasPayloadIntegrity() === true``;
* ``isAuthoritative() === true``; and
* ``authenticationStateLock()`` returns a provider in the same coordination
domain as the state backend.

Use one direct primary backend. Do not use tiered/local-L1 reads, replica reads,
or an eventually consistent cache for monotonic authentication state. The
capability API exposes CacheLayer's effective local policy; deployment topology
such as replica routing remains the application's responsibility.

Backend-Specific Notes
----------------------

Expand Down
21 changes: 21 additions & 0 deletions src/Cache/AuthenticationStateCacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Infocyph\CacheLayer\Cache;

use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface;

/**
* CacheLayer capability required for security-sensitive authentication state.
*/
interface AuthenticationStateCacheInterface extends CacheInterface
{
public function authenticationStateLock(): ?LockProviderInterface;

public function hasPayloadIntegrity(): bool;

public function isAuthoritative(): bool;

public function isFailOpen(): bool;
}
77 changes: 70 additions & 7 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,39 @@
use Psr\Cache\CacheItemInterface;
use Throwable;

final class Cache implements CacheInterface
final class Cache implements AuthenticationStateCacheInterface
{
private const float LOCK_LEASE_SECONDS = 30.0;

private const float LOCK_WAIT_SECONDS = 5.0;

private const int TTL_JITTER_PERCENT = 8;

private readonly bool $authoritative;

private readonly CacheOptions $options;

private bool $authenticationStateLockCapable;

private LockProviderInterface $lockProvider;

private ?Closure $metricsExportHook = null;

public function __construct(
private readonly InternalCachePoolInterface $adapter,
private LockProviderInterface $lockProvider = new FileLockProvider(),
?LockProviderInterface $lockProvider = null,
private CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(),
?CacheOptions $options = null,
private readonly string $namespace = 'default',
) {
CacheInput::namespace($namespace);
$this->authoritative = !in_array(
$adapter::class,
[Adapter\TieredCacheAdapter::class, Adapter\NullCacheAdapter::class],
true,
);
$this->lockProvider = $lockProvider ?? new FileLockProvider();
$this->authenticationStateLockCapable = $lockProvider !== null;
$this->options = $options ?? new CacheOptions();
if ($adapter instanceof AbstractCacheAdapter) {
$adapter->configureOptions($this->options);
Expand All @@ -51,15 +64,25 @@ public function __construct(

public static function apcu(string $namespace = 'default', ?CacheOptions $options = null): self
{
return new self(new Adapter\ApcuCacheAdapter($namespace), options: $options, namespace: $namespace);
return new self(
new Adapter\ApcuCacheAdapter($namespace),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
}

public static function file(
string $namespace = 'default',
?string $dir = null,
?CacheOptions $options = null,
): self {
return new self(new Adapter\FileCacheAdapter($namespace, $dir), options: $options, namespace: $namespace);
return new self(
new Adapter\FileCacheAdapter($namespace, $dir),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
}

/** @param list<array{0:string, 1:int, 2:int}> $servers */
Expand All @@ -81,7 +104,12 @@ public static function memcached(

public static function memory(string $namespace = 'default', ?CacheOptions $options = null): self
{
return new self(new Adapter\ArrayCacheAdapter($namespace), options: $options, namespace: $namespace);
return new self(
new Adapter\ArrayCacheAdapter($namespace),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
}

public static function mongodb(
Expand Down Expand Up @@ -145,7 +173,12 @@ public static function phpFiles(
?string $dir = null,
?CacheOptions $options = null,
): self {
return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir), options: $options, namespace: $namespace);
return new self(
new Adapter\PhpFilesCacheAdapter($namespace, $dir),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
}

public static function redis(
Expand Down Expand Up @@ -219,6 +252,7 @@ public static function sharedMemory(
): self {
return new self(
new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
Expand Down Expand Up @@ -269,7 +303,20 @@ public static function valkey(

public static function weakMap(string $namespace = 'default', ?CacheOptions $options = null): self
{
return new self(new Adapter\WeakMapCacheAdapter($namespace), options: $options, namespace: $namespace);
return new self(
new Adapter\WeakMapCacheAdapter($namespace),
new FileLockProvider(),
options: $options,
namespace: $namespace,
);
}

public function authenticationStateLock(): ?LockProviderInterface
{
return match ([$this->authenticationStateLockCapable, $this->authoritative]) {
[true, true] => $this->lockProvider,
default => null,
};
}

public function clear(): bool
Expand Down Expand Up @@ -397,6 +444,11 @@ public function hasItem(string $key): bool
return $this->getItem($key)->isHit();
}

public function hasPayloadIntegrity(): bool
{
return $this->options->integrityKey !== null;
}

public function invalidateTag(string $tag): bool
{
return $this->invalidateTags([$tag]);
Expand All @@ -411,6 +463,16 @@ public function invalidateTags(array $tags): bool
return $invalidated;
}

public function isAuthoritative(): bool
{
return $this->authoritative;
}

public function isFailOpen(): bool
{
return $this->options->failOpen;
}

public function offsetExists(mixed $offset): bool
{
return $this->has($this->requireStringOffset($offset));
Expand Down Expand Up @@ -546,6 +608,7 @@ public function set(string $key, mixed $value, mixed $ttl = null): bool
public function setLockProvider(LockProviderInterface $lockProvider): self
{
$this->lockProvider = $lockProvider;
$this->authenticationStateLockCapable = true;

return $this;
}
Expand Down
31 changes: 31 additions & 0 deletions tests/Cache/ArchitectureHardeningTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Infocyph\CacheLayer\Cache\Cache;
use Infocyph\CacheLayer\Cache\CacheOptions;
use Infocyph\CacheLayer\Cache\Item\CacheItem;
use Infocyph\CacheLayer\Cache\Lock\FileLockProvider;
use Infocyph\CacheLayer\Exceptions\CacheBackendException;
use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException;
use Psr\Cache\CacheItemInterface;
Expand Down Expand Up @@ -261,6 +262,36 @@ public function resetOperationCounts(): void
expect(fn() => $closed->get('key'))->toThrow(CacheBackendException::class);
});

test('authentication state capabilities expose effective cache policy', function () {
$safe = Cache::memory(
'authentication-state',
new CacheOptions(integrityKey: 'state-integrity-key', failOpen: false),
);

expect($safe->isFailOpen())->toBeFalse()
->and($safe->hasPayloadIntegrity())->toBeTrue()
->and($safe->isAuthoritative())->toBeTrue()
->and($safe->authenticationStateLock())->toBeInstanceOf(FileLockProvider::class);

$default = new Cache(new ArchitectureHardeningTest());
expect($default->isFailOpen())->toBeTrue()
->and($default->hasPayloadIntegrity())->toBeFalse()
->and($default->authenticationStateLock())->toBeNull();

$default->setLockProvider(new FileLockProvider());
expect($default->authenticationStateLock())->toBeInstanceOf(FileLockProvider::class);
});

test('non-authoritative caches cannot expose an authentication state lock', function () {
$tiered = Cache::tiered([new ArchitectureHardeningTest()]);
$tiered->setLockProvider(new FileLockProvider());

expect($tiered->isAuthoritative())->toBeFalse()
->and($tiered->authenticationStateLock())->toBeNull()
->and(Cache::nullStore()->isAuthoritative())->toBeFalse()
->and(Cache::nullStore()->authenticationStateLock())->toBeNull();
});

test('fail-closed backend failures satisfy both PSR cache exception contracts', function () {
$exception = new CacheBackendException('failed');

Expand Down