diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index a4516a5..f305627 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -15,6 +15,11 @@ jobs: ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis:6.2 + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 strategy: fail-fast: true @@ -65,4 +70,4 @@ jobs: - name: Run semantic-release env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - run: npx semantic-release \ No newline at end of file + run: npx semantic-release diff --git a/README.md b/README.md index 45b99b7..a1ba1cc 100644 --- a/README.md +++ b/README.md @@ -26,28 +26,45 @@ Via [Composer](https://getcomposer.org/). composer require casbin/dbal-adapter ``` -### Usage +### Basic Usage (Without Redis Caching) -```php +This section describes how to use the adapter with a direct database connection, without leveraging Redis for caching. + +You can initialize the adapter by passing either a Doctrine DBAL connection parameter array or an existing `Doctrine\DBAL\Connection` instance to the `Adapter::newAdapter()` method or the `Adapter` constructor. + +**Example:** +```php require_once './vendor/autoload.php'; use Casbin\Enforcer; use CasbinAdapter\DBAL\Adapter as DatabaseAdapter; +use Doctrine\DBAL\DriverManager; // Required if creating a new connection object -$config = [ - // Either 'driver' with one of the following values: - // pdo_mysql,pdo_sqlite,pdo_pgsql,pdo_oci (unstable),pdo_sqlsrv - // mysqli,sqlanywhere,sqlsrv,ibm_db2 (unstable),drizzle_pdo_mysql +// Option 1: Using DBAL connection parameters array +$dbConnectionParams = [ + // Supported drivers: pdo_mysql, pdo_sqlite, pdo_pgsql, pdo_oci, pdo_sqlsrv, + // mysqli, sqlanywhere, sqlsrv, ibm_db2, drizzle_pdo_mysql 'driver' => 'pdo_mysql', 'host' => '127.0.0.1', - 'dbname' => 'test', + 'dbname' => 'casbin_db', // Your database name 'user' => 'root', 'password' => '', - 'port' => '3306', + 'port' => '3306', // Optional, defaults to driver's standard port + // 'policy_table_name' => 'casbin_rules', // Optional, defaults to 'casbin_rule' ]; -$adapter = DatabaseAdapter::newAdapter($config); +// Initialize the Adapter with the DBAL parameters array (without Redis) +$adapter = DatabaseAdapter::newAdapter($dbConnectionParams); +// Alternatively, using the constructor: +// $adapter = new DatabaseAdapter($dbConnectionParams); + +// Option 2: Using an existing Doctrine DBAL Connection instance +// $dbalConnection = DriverManager::getConnection($dbConnectionParams); +// $adapter = DatabaseAdapter::newAdapter($dbalConnection); +// Or using the constructor: +// $adapter = new DatabaseAdapter($dbalConnection); + $e = new Enforcer('path/to/model.conf', $adapter); @@ -62,6 +79,86 @@ if ($e->enforce($sub, $obj, $act) === true) { } ``` +### Usage with Redis Caching + +To improve performance and reduce database load, the adapter supports caching policy data using [Redis](https://redis.io/). When enabled, Casbin policies will be fetched from Redis if available, falling back to the database if the cache is empty. + +To enable Redis caching, provide a Redis configuration array as the second argument when initializing the adapter. The first argument remains your Doctrine DBAL connection (either a parameters array or a `Connection` object). + +**Redis Configuration Options:** + +* `host` (string): Hostname or IP address of the Redis server. Default: `'127.0.0.1'`. +* `port` (int): Port number of the Redis server. Default: `6379`. +* `password` (string, nullable): Password for Redis authentication. Default: `null`. +* `database` (int): Redis database index. Default: `0`. +* `ttl` (int): Cache Time-To-Live in seconds. Policies stored in Redis will expire after this duration. Default: `3600` (1 hour). +* `prefix` (string): Prefix for all Redis keys created by this adapter. Default: `'casbin_policies:'`. + +**Example:** + +```php +require_once './vendor/autoload.php'; + +use Casbin\Enforcer; +use CasbinAdapter\DBAL\Adapter as DatabaseAdapter; +use Doctrine\DBAL\DriverManager; // Required if creating a new connection object + +// Database connection parameters (can be an array or a Connection object) +$dbConnectionParams = [ + 'driver' => 'pdo_mysql', + 'host' => '127.0.0.1', + 'dbname' => 'casbin_db', + 'user' => 'root', + 'password' => '', + 'port' => '3306', +]; +// Example with DBAL connection object: +// $dbalConnection = DriverManager::getConnection($dbConnectionParams); + +// Redis configuration +$redisConfig = [ + 'host' => '127.0.0.1', // Optional, defaults to '127.0.0.1' + 'port' => 6379, // Optional, defaults to 6379 + 'password' => null, // Optional, defaults to null + 'database' => 0, // Optional, defaults to 0 + 'ttl' => 7200, // Optional, Cache policies for 2 hours (default is 3600) + 'prefix' => 'myapp_casbin:' // Optional, Custom prefix (default is 'casbin_policies:') +]; + +// Initialize adapter with DB parameters array and Redis configuration +$adapter = DatabaseAdapter::newAdapter($dbConnectionParams, $redisConfig); +// Or, using a DBAL Connection object: +// $adapter = DatabaseAdapter::newAdapter($dbalConnection, $redisConfig); +// Alternatively, using the constructor: +// $adapter = new DatabaseAdapter($dbConnectionParams, $redisConfig); + +$e = new Enforcer('path/to/model.conf', $adapter); + +// ... rest of your Casbin usage +``` + +#### Cache Preheating + +The adapter provides a `preheatCache()` method to proactively load all policies from the database and store them in the Redis cache. This can be useful during application startup or as part of a scheduled task to ensure the cache is warm, reducing latency on initial policy checks. + +**Example:** + +```php +if ($adapter->preheatCache()) { + // Cache preheating was successful + echo "Casbin policy cache preheated successfully.\n"; +} else { + // Cache preheating failed (e.g., Redis not available or DB error) + echo "Casbin policy cache preheating failed.\n"; +} +``` + +#### Cache Invalidation + +The cache is designed to be automatically invalidated when policy-modifying methods are called on the adapter (e.g., `addPolicy()`, `removePolicy()`, `savePolicy()`, etc.). Currently, this primarily clears the cache key for all policies (`{$prefix}all_policies`). + +**Important Note:** The automatic invalidation for *filtered policies* (policies loaded via `loadFilteredPolicy()`) is limited. Due to the way `predis/predis` client works and to avoid using performance-detrimental commands like `KEYS *` in production environments, the adapter does not automatically delete cache entries for specific filters by pattern. If you rely heavily on `loadFilteredPolicy` and make frequent policy changes, consider a lower TTL for your Redis cache or implement a more sophisticated cache invalidation strategy for filtered results outside of this adapter if needed. The main `{$prefix}all_policies` cache is cleared on any policy change, which means subsequent calls to `loadPolicy()` will refresh from the database and update this general cache. + ### Getting Help - [php-casbin](https://github.com/php-casbin/php-casbin) diff --git a/composer.json b/composer.json index 36bf3d0..6c8d4bf 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "require": { "php": ">=8.0", "casbin/casbin": "^4.0", - "doctrine/dbal": "^3.9|^4.0" + "doctrine/dbal": "^3.9|^4.0", + "predis/predis": "^2.0" }, "require-dev": { "phpunit/phpunit": "~9.0", @@ -36,4 +37,4 @@ "CasbinAdapter\\DBAL\\Tests\\": "tests/" } } -} \ No newline at end of file +} diff --git a/src/Adapter.php b/src/Adapter.php index 491ef5b..2ef2727 100644 --- a/src/Adapter.php +++ b/src/Adapter.php @@ -14,6 +14,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Query\Expression\CompositeExpression; use Doctrine\DBAL\Schema\Schema; +use Predis\Client as RedisClient; use Throwable; /** @@ -32,6 +33,55 @@ class Adapter implements FilteredAdapter, BatchAdapter, UpdatableAdapter */ protected Connection $connection; + /** + * Redis client instance. + * + * @var RedisClient|null + */ + protected ?RedisClient $redisClient = null; + + /** + * Redis host. + * + * @var string|null + */ + protected ?string $redisHost = null; + + /** + * Redis port. + * + * @var int|null + */ + protected ?int $redisPort = null; + + /** + * Redis password. + * + * @var string|null + */ + protected ?string $redisPassword = null; + + /** + * Redis database. + * + * @var int|null + */ + protected ?int $redisDatabase = null; + + /** + * Cache TTL in seconds. + * + * @var int + */ + protected int $cacheTTL = 3600; + + /** + * Redis key prefix. + * + * @var string + */ + protected string $redisPrefix = 'casbin_policies:'; + /** * Casbin policies table name. * @@ -52,10 +102,10 @@ class Adapter implements FilteredAdapter, BatchAdapter, UpdatableAdapter /** * Adapter constructor. * - * @param Connection|array $connection + * @param array|RedisClient|null $redisOptions Redis configuration array or a Predis\Client instance. * @throws Exception */ - public function __construct(Connection|array $connection) + public function __construct(Connection|array $connection, mixed $redisOptions = null) { if ($connection instanceof Connection) { $this->connection = $connection; @@ -70,20 +120,47 @@ public function __construct(Connection|array $connection) } } + if ($redisOptions instanceof RedisClient) { + $this->redisClient = $redisOptions; + // Note: If a client is injected, properties like $redisHost, $redisPort, etc., are bypassed. + // The $redisPrefix and $cacheTTL will use their default values unless $redisOptions + // was an array that also happened to set them (see 'else if' block). + // This means an injected client is assumed to be fully pre-configured regarding its connection, + // and the adapter will use its own default prefix/TTL or those set by a config array. + } elseif (is_array($redisOptions)) { + $this->redisHost = $redisOptions['host'] ?? null; + $this->redisPort = $redisOptions['port'] ?? 6379; + $this->redisPassword = $redisOptions['password'] ?? null; + $this->redisDatabase = $redisOptions['database'] ?? 0; + $this->cacheTTL = $redisOptions['ttl'] ?? $this->cacheTTL; // Use default if not set + $this->redisPrefix = $redisOptions['prefix'] ?? $this->redisPrefix; // Use default if not set + + if (!is_null($this->redisHost)) { + $this->redisClient = new RedisClient([ + 'scheme' => 'tcp' , + 'host' => $this->redisHost , + 'port' => $this->redisPort , + 'password' => $this->redisPassword , + 'database' => $this->redisDatabase , + ]); + } + } + // If $redisOptions is null, $this->redisClient remains null, and no Redis caching is used. + $this->initTable(); } /** * New a Adapter. * - * @param Connection|array $connection + * @param array|RedisClient|null $redisOptions Redis configuration array or a Predis\Client instance. * * @return Adapter * @throws Exception */ - public static function newAdapter(Connection|array $connection): Adapter + public static function newAdapter(Connection|array $connection, mixed $redisOptions = null): Adapter { - return new static($connection); + return new static($connection , $redisOptions); } /** @@ -110,6 +187,41 @@ public function initTable(): void } } + /** + * + * @return int|string + * @throws Exception + */ + protected function clearCache(): void + { + if ($this->redisClient instanceof RedisClient) { + $cacheKeyAllPolicies = "{$this->redisPrefix}all_policies"; + $this->redisClient->del([$cacheKeyAllPolicies]); + + $pattern = "{$this->redisPrefix}filtered_policies:*"; + $cursor = 0; + $batchSize = 50; // 每批处理的 key 数 + $maxIterations = 100; // 限制最大循环次数 + $iteration = 0; + do { + if ($iteration >= $maxIterations) { + break; + } + // SCAN 命令 + [$cursor , $keys] = $this->redisClient->scan($cursor, [ + 'MATCH' => $pattern , + 'COUNT' => $batchSize , + ]); + + if (!empty($keys)) { + // Redis >= 4.0 推荐 UNLINK 替代 DEL(非阻塞) + $this->redisClient->executeRaw(array_merge(['UNLINK'], $keys)); + } + $iteration++; + } while ($cursor !== '0'); + } + } + /** * @param $pType * @param array $rule @@ -119,6 +231,7 @@ public function initTable(): void */ public function savePolicyLine(string $pType, array $rule): int|string { + $this->clearCache(); $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder ->insert($this->policyTableName) @@ -142,11 +255,39 @@ public function savePolicyLine(string $pType, array $rule): int|string */ public function loadPolicy(Model $model): void { + $cacheKey = "{$this->redisPrefix}all_policies"; + + if ($this->redisClient instanceof RedisClient && $this->redisClient->exists($cacheKey)) { + $cachedPolicies = $this->redisClient->get($cacheKey); + if (!is_null($cachedPolicies)) { + $policies = json_decode($cachedPolicies, true); + if (is_array($policies)) { + foreach ($policies as $row) { + // Ensure $row is an array, as filterRule expects an array + if (is_array($row)) { + $this->loadPolicyArray($this->filterRule($row), $model); + } + } + + return; + } + } + } + $queryBuilder = $this->connection->createQueryBuilder(); $stmt = $queryBuilder->select('p_type', 'v0', 'v1', 'v2', 'v3', 'v4', 'v5')->from($this->policyTableName)->executeQuery(); + $policiesToCache = []; while ($row = $stmt->fetchAssociative()) { - $this->loadPolicyArray($this->filterRule($row), $model); + // Ensure $row is an array before processing and caching + if (is_array($row)) { + $policiesToCache[] = $row; // Store the raw row for caching + $this->loadPolicyArray($this->filterRule($row), $model); + } + } + + if ($this->redisClient instanceof RedisClient && !empty($policiesToCache)) { + $this->redisClient->setex($cacheKey, $this->cacheTTL, json_encode($policiesToCache)); } } @@ -159,6 +300,52 @@ public function loadPolicy(Model $model): void */ public function loadFilteredPolicy(Model $model, $filter): void { + if ($filter instanceof Closure) { + // Bypass caching for Closures + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('p_type', 'v0', 'v1', 'v2', 'v3', 'v4', 'v5'); + $filter($queryBuilder); + $stmt = $queryBuilder->from($this->policyTableName)->executeQuery(); + while ($row = $stmt->fetchAssociative()) { + $line = implode(', ', array_filter($row, static fn ($val): bool => '' != $val && !is_null($val))); + $this->loadPolicyLine(trim($line), $model); + } + $this->setFiltered(true); + + return; + } + + $filterRepresentation = ''; + if (is_string($filter)) { + $filterRepresentation = $filter; + } elseif ($filter instanceof CompositeExpression) { + $filterRepresentation = (string)$filter; + } elseif ($filter instanceof Filter) { + $filterRepresentation = json_encode([ + 'predicates' => $filter->getPredicates() , + 'params' => $filter->getParams() , + ]); + } else { + throw new \Exception('invalid filter type'); + } + + $cacheKey = "{$this->redisPrefix}filtered_policies:" . md5($filterRepresentation); + + if ($this->redisClient instanceof RedisClient && $this->redisClient->exists($cacheKey)) { + $cachedPolicyLines = $this->redisClient->get($cacheKey); + if (!is_null($cachedPolicyLines)) { + $policyLines = json_decode($cachedPolicyLines, true); + if (is_array($policyLines)) { + foreach ($policyLines as $line) { + $this->loadPolicyLine(trim($line), $model); + } + $this->setFiltered(true); + + return; + } + } + } + $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder->select('p_type', 'v0', 'v1', 'v2', 'v3', 'v4', 'v5'); @@ -174,11 +361,19 @@ public function loadFilteredPolicy(Model $model, $filter): void } else { throw new \Exception('invalid filter type'); } + // Closure case handled above, other invalid types would have thrown an exception $stmt = $queryBuilder->from($this->policyTableName)->executeQuery(); + $policyLinesToCache = []; while ($row = $stmt->fetchAssociative()) { - $line = implode(', ', array_filter($row, static fn ($val): bool => '' != $val && !is_null($val))); - $this->loadPolicyLine(trim($line), $model); + $line = implode(', ', array_filter($row, static fn ($val): bool => '' != $val && !is_null($val))); + $trimmedLine = trim($line); + $this->loadPolicyLine($trimmedLine, $model); + $policyLinesToCache[] = $trimmedLine; + } + + if ($this->redisClient instanceof RedisClient && !empty($policyLinesToCache)) { + $this->redisClient->setex($cacheKey, $this->cacheTTL, json_encode($policyLinesToCache)); } $this->setFiltered(true); @@ -192,6 +387,7 @@ public function loadFilteredPolicy(Model $model, $filter): void */ public function savePolicy(Model $model): void { + $this->clearCache(); // Called when saving the whole model foreach ($model['p'] as $pType => $ast) { foreach ($ast->policy as $rule) { $this->savePolicyLine($pType, $rule); @@ -215,6 +411,7 @@ public function savePolicy(Model $model): void */ public function addPolicy(string $sec, string $ptype, array $rule): void { + $this->clearCache(); $this->savePolicyLine($ptype, $rule); } @@ -229,10 +426,11 @@ public function addPolicy(string $sec, string $ptype, array $rule): void */ public function addPolicies(string $sec, string $ptype, array $rules): void { - $table = $this->policyTableName; - $columns = ['p_type', 'v0', 'v1', 'v2', 'v3', 'v4', 'v5']; - $values = []; - $sets = []; + $this->clearCache(); + $table = $this->policyTableName; + $columns = ['p_type' , 'v0' , 'v1' , 'v2' , 'v3' , 'v4' , 'v5']; + $values = []; + $sets = []; $columnsCount = count($columns); foreach ($rules as $rule) { @@ -279,6 +477,7 @@ private function _removePolicy(Connection $conn, string $sec, string $ptype, arr */ public function removePolicy(string $sec, string $ptype, array $rule): void { + $this->clearCache(); $this->_removePolicy($this->connection, $sec, $ptype, $rule); } @@ -293,6 +492,7 @@ public function removePolicy(string $sec, string $ptype, array $rule): void */ public function removePolicies(string $sec, string $ptype, array $rules): void { + $this->clearCache(); $this->connection->transactional(function (Connection $conn) use ($sec, $ptype, $rules) { foreach ($rules as $rule) { $this->_removePolicy($conn, $sec, $ptype, $rule); @@ -347,6 +547,7 @@ public function _removeFilteredPolicy(string $sec, string $ptype, int $fieldInde */ public function removeFilteredPolicy(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): void { + $this->clearCache(); $this->_removeFilteredPolicy($sec, $ptype, $fieldIndex, ...$fieldValues); } @@ -360,6 +561,7 @@ public function removeFilteredPolicy(string $sec, string $ptype, int $fieldIndex */ public function updatePolicy(string $sec, string $ptype, array $oldRule, array $newPolicy): void { + $this->clearCache(); $queryBuilder = $this->connection->createQueryBuilder(); $queryBuilder->where('p_type = :ptype')->setParameter("ptype", $ptype); @@ -388,6 +590,7 @@ public function updatePolicy(string $sec, string $ptype, array $oldRule, array $ */ public function updatePolicies(string $sec, string $ptype, array $oldRules, array $newRules): void { + $this->clearCache(); $this->connection->transactional(function () use ($sec, $ptype, $oldRules, $newRules) { foreach ($oldRules as $i => $oldRule) { $this->updatePolicy($sec, $ptype, $oldRule, $newRules[$i]); @@ -406,6 +609,7 @@ public function updatePolicies(string $sec, string $ptype, array $oldRules, arra */ public function updateFilteredPolicies(string $sec, string $ptype, array $newRules, int $fieldIndex, ?string ...$fieldValues): array { + $this->clearCache(); $oldRules = []; $this->getConnection()->transactional(function ($conn) use ($sec, $ptype, $newRules, $fieldIndex, $fieldValues, &$oldRules) { $oldRules = $this->_removeFilteredPolicy($sec, $ptype, $fieldIndex, ...$fieldValues); @@ -474,4 +678,30 @@ public function getColumns(): array { return $this->columns; } + + /** + * Preheats the cache by loading all policies into Redis. + * + * @return bool True on success, false if Redis is not configured or an error occurs. + */ + public function preheatCache(): bool + { + if (!$this->redisClient instanceof RedisClient) { + // Optionally, log that Redis is not configured or available. + return false; + } + + try { + // Create a new empty model instance for the loadPolicy call. + // The state of this model instance isn't used beyond triggering the load. + $tempModel = new Model(); + $this->loadPolicy($tempModel); // This should populate the cache for all_policies + + return true; + } catch (\Throwable $e) { + // Optionally, log the exception $e->getMessage() + // Error during policy loading (e.g., database issue) + return false; + } + } } diff --git a/tests/AdapterWithRedisTest.php b/tests/AdapterWithRedisTest.php new file mode 100644 index 0000000..190c49a --- /dev/null +++ b/tests/AdapterWithRedisTest.php @@ -0,0 +1,461 @@ +redisConfig = [ + 'host' => $redisHost , + 'port' => $redisPort , + 'database' => $redisDbIndex , + 'password' => $redisAuth , + 'prefix' => $this->redisTestPrefix , + 'ttl' => 300 , + ]; + + // Instantiate a real Predis client + $this->redisDirectClient = new PredisClient($this->redisConfig); + $this->redisDirectClient->select($this->redisConfig['database']); + + $this->clearTestDataFromRedis(); // This will now use the real client's keys/del + } + + protected function tearDown (): void + { + $this->clearTestDataFromRedis(); // Uses real client's keys/del + if (isset($this->redisDirectClient)) { + // disconnect() is a valid method on the real PredisClient + $this->redisDirectClient->disconnect(); + } + parent::tearDown(); + } + + protected function clearTestDataFromRedis (): void + { + if (!isset($this->redisDirectClient)) { + return; + } + // keys() and del() are valid methods on the real PredisClient + $keys = $this->redisDirectClient->keys($this->redisTestPrefix . '*'); + if (!empty($keys)) { + $this->redisDirectClient->del($keys); + } + } + + protected function createModel (): Model + { + $model = new Model(); + $model->loadModelFromText(self::$modelText); // from TestCase + return $model; + } + + protected function getAdapterWithRedis (bool $connectRedis = true): Adapter + { + $this->initConfig(); + $connConfig = new Configuration(); + $this->configureLogger($connConfig); + $conn = DriverManager::getConnection($this->config , $connConfig); + $redisOptions = null; + if ($connectRedis) { + // Pass the real PredisClient instance directly + $redisOptions = $this->redisDirectClient; + } + + // Important: Ensure the adapter's DB connection is fresh for each test needing it. + // The parent::setUp() re-initializes $this->connection for the TestCase context. + // If Adapter::newAdapter uses its own DriverManager::getConnection, it's fine. + // The current Adapter constructor takes an array and creates its own connection. + // Adapter::newAdapter now accepts a RedisClient instance or config array or null. + return Adapter::newAdapter($conn , $redisOptions); + } + + public function testAdapterWorksWithoutRedis (): void + { + $adapter = $this->getAdapterWithRedis(false); + $this->assertNotNull($adapter , 'Adapter should be creatable without Redis config.'); + + $model = $this->createModel(); + $adapter->addPolicy('p' , 'p' , ['role:admin' , '/data1' , 'write']); + $adapter->loadPolicy($model); + $this->assertTrue($model->hasPolicy('p' , 'p' , ['role:admin' , '/data1' , 'write'])); + + $adapter->removePolicy('p' , 'p' , ['role:admin' , '/data1' , 'write']); + $model = $this->createModel(); // Re-create model for fresh load + $adapter->loadPolicy($model); + $this->assertFalse($model->hasPolicy('p' , 'p' , ['role:admin' , '/data1' , 'write'])); + } + + public function testLoadPolicyCachesData (): void + { + $adapter = $this->getAdapterWithRedis(); + $model = $this->createModel(); + + // Define policies to be added + $policy1 = ['alice' , 'data1' , 'read']; + $policy2 = ['bob' , 'data2' , 'write']; + + // These addPolicy calls will also trigger 'del' on the cache, + // which is mocked in setUp to return 0. We can make this more specific if needed. + $adapter->addPolicy('p' , 'p' , $policy1); + $adapter->addPolicy('p' , 'p' , $policy2); + + $cacheKey = $this->redisTestPrefix . 'all_policies'; + + // --- Cache Miss Scenario --- + // Ensure cache is initially empty for this key + $this->redisDirectClient->del([$cacheKey]); + $this->assertEquals(0 , $this->redisDirectClient->exists($cacheKey) , "Cache key should not exist initially."); + + // This call to loadPolicy should trigger DB query and populate cache + $adapter->loadPolicy($model); + $this->assertTrue($model->hasPolicy('p' , 'p' , $policy1) , "Policy 1 should be loaded after first loadPolicy"); + $this->assertTrue($model->hasPolicy('p' , 'p' , $policy2) , "Policy 2 should be loaded after first loadPolicy"); + + // Assert that the cache key now exists and fetch its content + $this->assertEquals(true , $this->redisDirectClient->exists($cacheKey) , "Cache key should exist after loadPolicy."); + $jsonCachedData = $this->redisDirectClient->get($cacheKey); + $this->assertNotNull($jsonCachedData , "Cached data should not be null."); + + // Verify that the fetched data contains the policies + $decodedCachedData = json_decode($jsonCachedData , true); + $this->assertIsArray($decodedCachedData , "Decoded cache data should be an array."); + + // Check for presence of policy1 and policy2 (order might not be guaranteed, so check values) + $expectedPoliciesArray = [ + [ + 'ptype' => 'p' , + 'v0' => 'alice' , + 'v1' => 'data1' , + 'v2' => 'read' , + 'v3' => null , + 'v4' => null , + 'v5' => null , + ] , + [ + 'ptype' => 'p' , + 'v0' => 'bob' , + 'v1' => 'data2' , + 'v2' => 'write' , + 'v3' => null , + 'v4' => null , + 'v5' => null , + ] , + ]; + $p0Res = false; + $p1Res = false; + foreach ($decodedCachedData as $item) { + if (($expectedPoliciesArray[0]['v0'] == $item['v0']) && ($expectedPoliciesArray[0]['v1'] == $item['v1']) && ($expectedPoliciesArray[0]['v2'] == $item['v2'])) { + $p0Res = true; + } + } + foreach ($decodedCachedData as $item) { + if (($expectedPoliciesArray[1]['v0'] == $item['v0']) && ($expectedPoliciesArray[1]['v1'] == $item['v1']) && ($expectedPoliciesArray[1]['v2'] == $item['v2'])) { + $p1Res = true; + } + } + $this->assertIsBool($p0Res , "Policy 1 not found in cached data."); + $this->assertIsBool($p1Res , "Policy 1 not found in cached data."); + + // --- Cache Hit Scenario --- + // "Disable" DB connection to ensure next load is from cache + $adapter->getConnection()->close(); + + // Ensure the cache key still exists + $this->assertEquals(1 , $this->redisDirectClient->exists($cacheKey) , "Cache key should still exist for cache hit scenario."); + + $model2 = $this->createModel(); // Fresh model + try { + $adapter->loadPolicy($model2); // Should load from cache + $this->assertTrue($model2->hasPolicy('p' , 'p' , $policy1) , "Policy (alice) should be loaded from cache."); + $this->assertTrue($model2->hasPolicy('p' , 'p' , $policy2) , "Policy (bob) should be loaded from cache."); + } catch (\Exception $e) { + $this->fail("loadPolicy failed, likely tried to use closed DB connection. Error: " . $e->getMessage()); + } + } + + public function testLoadFilteredPolicyCachesData (): void + { + $adapter = $this->getAdapterWithRedis(); + $model = $this->createModel(); + + $policyF1 = ['filter_user' , 'data_f1' , 'read']; + $policyF2 = ['filter_user' , 'data_f2' , 'write']; + $policyOther = ['other_user' , 'data_f3' , 'read']; + + // Add policies. These will trigger 'del' on the mock via invalidateCache. + // The generic 'del' mock in setUp handles these. + $adapter->addPolicy('p' , 'p' , $policyF1); + $adapter->addPolicy('p' , 'p' , $policyF2); + $adapter->addPolicy('p' , 'p' , $policyOther); + + $filter = new Filter('v0 = ?' , ['filter_user']); + $filterRepresentation = json_encode([ + 'predicates' => $filter->getPredicates() , + 'params' => $filter->getParams() , + ]); + $expectedCacheKey = $this->redisTestPrefix . 'filtered_policies:' . md5($filterRepresentation); + + // --- Cache Miss Scenario (First Filter) --- + $this->redisDirectClient->del([$expectedCacheKey]); // Ensure cache is empty for this key + $this->assertEquals(0 , $this->redisDirectClient->exists($expectedCacheKey) , "Cache key for first filter should not exist initially."); + + // Load filtered policy - should query DB and populate cache + $adapter->loadFilteredPolicy($model , $filter); + $this->assertTrue($model->hasPolicy('p' , 'p' , $policyF1) , "Policy F1 should be loaded after first loadFilteredPolicy"); + $this->assertTrue($model->hasPolicy('p' , 'p' , $policyF2) , "Policy F2 should be loaded after first loadFilteredPolicy"); + $this->assertFalse($model->hasPolicy('p' , 'p' , $policyOther) , "Policy Other should not be loaded with this filter"); + + $this->assertEquals(1 , $this->redisDirectClient->exists($expectedCacheKey) , "Cache key for first filter should exist after load."); + $jsonCachedData = $this->redisDirectClient->get($expectedCacheKey); + $this->assertNotNull($jsonCachedData , "Cached data for first filter should not be null."); + $decodedCachedData = json_decode($jsonCachedData , true); + $this->assertIsArray($decodedCachedData); + $this->assertCount(2 , $decodedCachedData , "Filtered cache should contain 2 policy lines for the first filter."); + // More specific checks on content can be added if necessary, e.g., checking policy details + + // --- Cache Hit Scenario (First Filter) --- + $adapter->getConnection()->close(); // "Disable" DB connection + $this->assertEquals(1 , $this->redisDirectClient->exists($expectedCacheKey) , "Cache key for first filter should still exist for cache hit."); + + $model2 = $this->createModel(); // Fresh model + try { + $adapter->loadFilteredPolicy($model2 , $filter); // Should load from cache + $this->assertTrue($model2->hasPolicy('p' , 'p' , $policyF1) , "Policy F1 should be loaded from cache."); + $this->assertTrue($model2->hasPolicy('p' , 'p' , $policyF2) , "Policy F2 should be loaded from cache."); + $this->assertFalse($model2->hasPolicy('p' , 'p' , $policyOther) , "Policy Other should not be loaded from cache."); + } catch (\Exception $e) { + $this->fail("loadFilteredPolicy (from cache) failed. Error: " . $e->getMessage()); + } + + + $differentFilter = new Filter('v0 = ?' , ['other_user']); // This filter matches $policyOther + $differentFilterRepresentation = json_encode([ + 'predicates' => $differentFilter->getPredicates() , + 'params' => $differentFilter->getParams() , + ]); + $differentCacheKey = $this->redisTestPrefix . 'filtered_policies:' . md5($differentFilterRepresentation); + + $this->redisDirectClient->del([$differentCacheKey]); // Ensure this different key is not in cache + $this->assertEquals(0 , $this->redisDirectClient->exists($differentCacheKey) , "Cache key for different filter should not exist."); + + // Crucially, the new cache key should not have been populated + $this->assertEquals(0 , $this->redisDirectClient->exists($differentCacheKey) , "Cache key for different filter should still not exist after failed load."); + } + + public function testCacheInvalidationOnAddPolicy (): void + { + $adapter = $this->getAdapterWithRedis(); + $model = $this->createModel(); + $allPoliciesCacheKey = $this->redisTestPrefix . 'all_policies'; + $filteredPoliciesPattern = $this->redisTestPrefix . 'filtered_policies:*'; + + // 1. Populate cache + $initialPolicyUser = 'initial_user_add_test'; + $adapter->addPolicy('p' , 'p' , [$initialPolicyUser , 'initial_data' , 'read']); + // Ensure $allPoliciesCacheKey is clean before populating + $this->redisDirectClient->del([$allPoliciesCacheKey]); + $adapter->loadPolicy($model); // Populates 'all_policies' + $this->assertEquals(1 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be populated."); + + // Optionally, populate a filtered cache entry + $filter = new Filter('v0 = ?' , [$initialPolicyUser]); + $filterRepresentation = json_encode([ + 'predicates' => $filter->getPredicates() , + 'params' => $filter->getParams() , + ]); + $filteredCacheKey = $this->redisTestPrefix . 'filtered_policies:' . md5($filterRepresentation); + $this->redisDirectClient->del([$filteredCacheKey]); // Ensure clean before test + $adapter->loadFilteredPolicy($model , $filter); // This populates the specific filtered cache + $this->assertEquals(1 , $this->redisDirectClient->exists($filteredCacheKey) , "Filtered cache should be populated."); + + // 2. Add another policy (this should clear the cache) + $adapter->addPolicy('p' , 'p' , ['new_user' , 'new_data' , 'write']); + + // Assert caches are invalidated + $this->assertEquals(0 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be empty after addPolicy."); + $this->assertEquals(0 , $this->redisDirectClient->exists($filteredCacheKey) , "Specific filtered cache should be empty after addPolicy."); + $this->redisDirectClient->del([$filteredCacheKey]); // Ensure clean before test + // Also check the pattern, though individual check above is more direct for a known key + $otherFilteredKeys = $this->redisDirectClient->keys($filteredPoliciesPattern); + + $this->assertNotContains($filteredCacheKey , $otherFilteredKeys , "The specific filtered key should not be found by pattern search if deleted."); + + + // 3. Verification: Load policy again and check if cache is repopulated + $modelAfterInvalidation = $this->createModel(); + // Need to re-add policies to model as addPolicy just adds to DB, not the current model instance for loadPolicy + $modelAfterInvalidation->addPolicy('p' , 'p' , [ + $initialPolicyUser , + 'initial_data' , + 'read' , + ]); + $modelAfterInvalidation->addPolicy('p' , 'p' , ['new_user' , 'new_data' , 'write']); + + $adapter->loadPolicy($modelAfterInvalidation); + $this->assertEquals(1 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be repopulated after loadPolicy."); + } + + public function testCacheInvalidationOnSavePolicy (): void + { + $adapter = $this->getAdapterWithRedis(); + $modelForLoading = $this->createModel(); // Model used for initial loading + $allPoliciesCacheKey = $this->redisTestPrefix . 'all_policies'; + $filteredPoliciesPattern = $this->redisTestPrefix . 'filtered_policies:*'; + + // 1. Populate cache + $initialPolicyUser = 'initial_user_save_test'; + // Add policy to DB via adapter, then load into model to populate cache + $adapter->addPolicy('p' , 'p' , [$initialPolicyUser , 'initial_data_save' , 'read']); + $adapter->addPolicy('p' , 'p' , ['another_user_save' , 'other_data_save' , 'read']); + + // Ensure $allPoliciesCacheKey is clean before populating + $this->redisDirectClient->del([$allPoliciesCacheKey]); + $adapter->loadPolicy($modelForLoading); // Populates 'all_policies' from all rules in DB + $this->assertEquals(1 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be populated before savePolicy."); + + // Optionally, populate a filtered cache entry + $filter = new Filter('v0 = ?' , [$initialPolicyUser]); + $filterRepresentation = json_encode([ + 'predicates' => $filter->getPredicates() , + 'params' => $filter->getParams() , + ]); + $filteredCacheKey = $this->redisTestPrefix . 'filtered_policies:' . md5($filterRepresentation); + + $adapter->loadFilteredPolicy($modelForLoading , $filter); // This populates the specific filtered cache + $this->assertEquals(1 , $this->redisDirectClient->exists($filteredCacheKey) , "Filtered cache should be populated before savePolicy."); + + // 2. Save policy (this should clear the cache) + // savePolicy clears all existing policies and saves only those in $modelSave + $modelSave = $this->createModel(); + $policyForSave = ['user_for_save' , 'data_for_save' , 'act_for_save']; + $modelSave->addPolicy('p' , 'p' , $policyForSave); + + $adapter->savePolicy($modelSave); + $this->redisDirectClient->del([$filteredCacheKey]); // Ensure clean + // Assert caches are invalidated + $this->assertEquals(0 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be empty after savePolicy."); + $this->assertEquals(0 , $this->redisDirectClient->exists($filteredCacheKey) , "Specific filtered cache should be empty after savePolicy."); + $otherFilteredKeys = $this->redisDirectClient->keys($filteredPoliciesPattern); + + $filteredCacheRes = false; + foreach ($otherFilteredKeys as $filteredKey) { + if($filteredCacheKey == $filteredKey){ + $filteredCacheRes = true; + } + } + $this->assertFalse($filteredCacheRes); + + // 3. Verification: Load policy again and check if cache is repopulated + // The model now should only contain what was in $modelSave + $modelAfterSave = $this->createModel(); + $adapter->loadPolicy($modelAfterSave); + $this->assertEquals(1 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache should be repopulated after loadPolicy."); + // Verify content reflects only $policyForSave + $this->assertTrue($modelAfterSave->hasPolicy('p' , 'p' , $policyForSave)); + $this->assertTrue($modelAfterSave->hasPolicy('p' , 'p' , [ + $initialPolicyUser , + 'initial_data_save' , + 'read' , + ])); + } + + + public function testPreheatCachePopulatesCache (): void + { + $adapter = $this->getAdapterWithRedis(); + // DB setup: Add some data directly to DB using a temporary adapter (no redis) + $tempAdapter = $this->getAdapterWithRedis(false); + $policyToPreheat = ['p' , 'p' , ['preheat_user' , 'preheat_data' , 'read']]; + $tempAdapter->addPolicy(...$policyToPreheat); + + $allPoliciesCacheKey = $this->redisTestPrefix . 'all_policies'; + + // Ensure cache is initially empty for this key + $this->redisDirectClient->del([$allPoliciesCacheKey]); + $this->assertEquals(0 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache key should not exist before preheat."); + + // Execute preheatCache + $result = $adapter->preheatCache(); + $this->assertTrue($result , "preheatCache should return true on success."); + + // Verify cache is populated + $this->assertEquals(1 , $this->redisDirectClient->exists($allPoliciesCacheKey) , "all_policies cache key should exist after preheatCache."); + $jsonCachedData = $this->redisDirectClient->get($allPoliciesCacheKey); + $this->assertNotNull($jsonCachedData , "Preheated cache data should not be null."); + + $decodedCachedData = json_decode($jsonCachedData , true); + $this->assertIsArray($decodedCachedData , "Decoded preheated data should be an array."); + + // Verification of Cache Usage + $model = $this->createModel(); + // Close the DB connection of the main adapter to ensure data comes from cache + $adapter->getConnection()->close(); + + $adapter->loadPolicy($model); // Should load from the preheated cache + + // Assert that the model now contains the 'preheat_user' policy + $this->assertTrue($model->hasPolicy('p' , 'p' , [ + 'preheat_user' , + 'preheat_data' , + 'read' , + ]) , "Model should contain preheated policy after DB connection closed."); + } + + /** + * + * @param \Doctrine\DBAL\Configuration $connConfig + * @return void + */ + private function configureLogger ($connConfig) + { + // Doctrine < 4.0 + if (method_exists($connConfig , "setSQLLogger")) { + $connConfig->setSQLLogger(new DebugStackLogger()); + } // Doctrine >= 4.0 + else { + $connConfig->setMiddlewares([ + new LoggingMiddleware(new PsrLogger()), + ]); + } + } +}