Add Redis-based permission caching for users and subusers

- Introduced Redis-backed caching for user and subuser permission evaluations in the `route_t` trait, reducing database queries.
- Enhanced `Redis` class with methods for permission caching: `cache_permission`, `get_permission`, and `clear_permission`.
- Added test coverage for the new caching logic in `PermissionRedisCacheTest.php`.
- Implemented Redis caching for authentication sessions with `cache_auth_session`, `get_auth_session`, and `clear_auth_session`.
- Improved CORS handling for preflight requests in `index.php`.
This commit is contained in:
Jeppe Bundgaard
2026-02-24 15:00:22 +01:00
parent 2534ffb1b9
commit 5cc311ae32
7 changed files with 367 additions and 12 deletions
+58
View File
@@ -332,6 +332,34 @@ class redis implements redis_i
return $this;
}
/**
* Cache auth session payload for a token with TTL
*/
public function cache_auth_session(string $token, array $data, int $ttl = 60): self
{
$key = 'auth_session_' . $token;
$this->set_array($key, $data);
$this->expire($key, $ttl);
return $this;
}
/**
* Get cached auth session payload by token
*/
public function get_auth_session(string $token): array|null
{
return $this->get_array('auth_session_' . $token);
}
/**
* Clear cached auth session payload by token
*/
public function clear_auth_session(string $token): self
{
$this->delete('auth_session_' . $token);
return $this;
}
/**
* @inheritDoc
*/
@@ -427,4 +455,34 @@ class redis implements redis_i
// Get multiple keys from Redis
return $this->redis->mget($array_map);
}
/**
* @inheritDoc
*/
public function cache_permission(string $cache_key, bool $allowed, int $ttl = 300): self
{
$this->setEx('perm:' . $cache_key, $allowed ? '1' : '0', $ttl);
return $this;
}
/**
* @inheritDoc
*/
public function get_permission(string $cache_key): bool|null
{
$val = $this->get('perm:' . $cache_key);
if ($val === null) {
return null;
}
return $val === '1';
}
/**
* @inheritDoc
*/
public function clear_permission(string $cache_key): self
{
$this->delete('perm:' . $cache_key);
return $this;
}
}
+19 -5
View File
@@ -7,19 +7,33 @@ ini_set('zlib.output_compression', false);
* This is the main entry point to the Truck Wash API.
*/
const WD = __DIR__;
/** CORS */
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: *');
header('Content-Type: application/json');
http_response_code(200);
exit;
}
require_once 'config.php';
/** Debug */
if ($DEBUG) {
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
} else {
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
}
/** CORS */
header("Access-Control-Allow-Origin: $CORS");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
/** Autoload */
require_once __DIR__ . '/vendor/autoload.php';
+46
View File
@@ -266,4 +266,50 @@ interface redis_i
* @return self
*/
public function clear_customer_number_from_user_id(int $user_id): self;
/**
* Cache an auth session payload for a given token
* @param string $token
* @param array $session
* @param int $ttl Seconds to keep in cache
* @return self
*/
public function cache_auth_session(string $token, array $session, int $ttl = 60): self;
/**
* Get a cached auth session payload by token
* @param string $token
* @return array|null
*/
public function get_auth_session(string $token): array|null;
/**
* Clear a cached auth session payload by token
* @param string $token
* @return self
*/
public function clear_auth_session(string $token): self;
/**
* Cache a permission evaluation
* @param string $cache_key
* @param bool $allowed
* @param int $ttl
* @return self
*/
public function cache_permission(string $cache_key, bool $allowed, int $ttl = 300): self;
/**
* Get a cached permission evaluation
* @param string $cache_key
* @return bool|null
*/
public function get_permission(string $cache_key): bool|null;
/**
* Clear a cached permission evaluation
* @param string $cache_key
* @return self
*/
public function clear_permission(string $cache_key): self;
}
@@ -21,6 +21,8 @@ class subusers_o extends db
public object_property $email;
public object_property $phone_country_code;
public object_property $phone;
public object_property $two_factor_secret;
public object_property $two_factor_enabled;
public object_property $created_at;
public object_property $updated_at;
public object_property $suspended_at;
+39 -5
View File
@@ -46,8 +46,21 @@ class authRoute
if (!isset($data['password']) || empty($data['password']) || strlen($data['password']) < 1) {
$response->error('Password is required', 400);
}
// Try to log the user in
$isCredentialsValid = (new authentication())->authenticate($data['customer_number'], $data['password']);
// Use Redis-backed user and property caches to validate credentials with a single user load
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
if (!$user->exists() || !$user->hasPassword()) {
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
$response->error('Invalid credentials', 401);
}
$isCredentialsValid = false;
try {
$isCredentialsValid = $user->passwordMatches($data['password']);
} catch (Exception $e) {
$isCredentialsValid = false;
}
// Log the incident
if ($isCredentialsValid) {
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
@@ -55,14 +68,15 @@ class authRoute
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
$response->error('Invalid credentials', 401);
}
// If the credentials are valid, create a token
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
// If the credentials are valid, check 2FA and create a token
if ($user->exists() && $user->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
}
$token = (new authentication())->create_token($data['customer_number']);
// Create the auth token without reloading the user from DB
$token = (new authentication())->create_token_by_user_id((int)$user->id);
// Return the token
$response->success(['token' => $token]);
});
@@ -79,6 +93,8 @@ class authRoute
}
// Delete the token
(new tokens_o())->delete($token);
// Clear any cached session for this token
try { redis->clear_auth_session($token); } catch (\Throwable $e) {}
// Return a success message
$response->success(['message' => 'Logged out']);
});
@@ -93,6 +109,17 @@ class authRoute
if (!(new authentication())->validate_token($token)) {
$response->error('Invalid token', 401);
}
// Try Redis cache first for session payload
try {
$cached = redis->get_auth_session($token);
if (is_array($cached)) {
$response->success($cached);
}
} catch (\Throwable $e) {
// Ignore Redis errors and continue to compute session
}
// Get the user object
$user = (new authentication())->get_user();
// Check if the user exists
@@ -103,6 +130,13 @@ class authRoute
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
// Cache the session payload briefly to reduce DB load on hot paths
try {
redis->cache_auth_session($token, $user_data, 60);
} catch (\Throwable $e) {
// Best-effort caching only
}
// Return the (session) user object
$response->success($user_data);
});
@@ -0,0 +1,168 @@
<?php
namespace {
if (!defined('WD')) {
define('WD', dirname(__DIR__, 2));
}
$_SERVER['REQUEST_URI'] = '/test';
}
// Setup mock environment
namespace classes {
class authentication {
public static $user = false;
public static $subuser = false;
public function get_user() { return self::$user; }
public function get_subuser() { return self::$subuser; }
}
class response {
public function error($msg, $code) { throw new \Exception("Response Error ($code): $msg"); }
public function add_meta($k, $v) {}
}
class permission_node {
public $permission;
public $subusers_node_key;
public static function create($p, $k = null) {
$n = new self();
$n->permission = $p;
$n->subusers_node_key = $k;
return $n;
}
}
}
namespace objects {
class logs_o {
public function add($a, $b, $c, $d, $e, $f) {}
}
class users_o {
public $id = 1;
public $group_id;
public function __construct() {
$this->group_id = new class { public function value() { return 2; } };
}
public function hasPermission($p) {
echo "[DB] Checking permission: $p\n";
return $p === 'allowed_perm';
}
}
class subusers_o {
public $id = 10;
public function hasPermission($node) {
echo "[DB] Checking subuser node: " . $node->name . "\n";
return $node->name === 'ALLOWED_NODE';
}
}
}
namespace {
// Include Redis classes first so we can mock REDIS_CONFIG
global $REDIS_CONFIG;
$REDIS_CONFIG = [
'host' => getenv('REDIS_CONFIG_HOST') ?: 'redis',
'database' => 0,
'password' => ''
];
require_once WD . '/vendor/autoload.php';
require_once WD . '/interfaces/redis_i.php';
require_once WD . '/traits/redis_t.php';
require_once WD . '/classes/redis.php';
require_once WD . '/traits/route_t.php';
// Set the redis constant
global $response;
$response = new classes\response();
try {
$redis_instance = (new classes\redis())->connect();
if (!defined('redis')) {
define("redis", $redis_instance);
}
} catch (Exception $e) {
die("Could not connect to Redis: " . $e->getMessage());
}
use traits\route_t;
use classes\authentication;
use classes\permission_node;
use objects\users_o;
use objects\subusers_o;
class TestRoute {
use route_t;
public function testEval($p, $c = null, $t = false) {
return $this->evaluatePermission($p, $c, $t);
}
// Mocking resolveCustomerNumberForSubuser as it's private and uses headers
private function resolveCustomerNumberForSubuser($auth, ?int $customer_number = null): ?int {
return $customer_number ?? 999;
}
}
$route = new TestRoute();
function assertResult($actual, $expected, $msg) {
if ($actual === $expected) {
echo "\033[32m✔ $msg\033[0m\n";
} else {
echo "\033[31m✖ $msg (Expected " . var_export($expected, true) . ", got " . var_export($actual, true) . ")\033[0m\n";
exit(1);
}
}
// 1. Test classic user permission caching
echo "--- Testing Classic User Permission Caching ---\n";
$user = new users_o();
authentication::$user = $user;
// Clear previous cache if any
redis->clear_permission("user:1:allowed_perm");
redis->clear_permission("user:1:denied_perm");
echo "First call (should hit DB):\n";
assertResult($route->testEval('allowed_perm'), true, "User should have allowed_perm");
echo "Second call (should hit memory cache, no DB output expected):\n";
assertResult($route->testEval('allowed_perm'), true, "User should still have allowed_perm (from memory)");
// Clear memory cache by creating new route object
$route = new TestRoute();
echo "Third call after new object (should hit Redis, no DB output expected):\n";
assertResult($route->testEval('allowed_perm'), true, "User should still have allowed_perm (from Redis)");
// Test denied perm
echo "\nTesting denied perm:\n";
assertResult($route->testEval('denied_perm'), false, "User should NOT have denied_perm");
$route = new TestRoute();
echo "Call after new object (should hit Redis, no DB output expected):\n";
assertResult($route->testEval('denied_perm'), false, "User should still NOT have denied_perm (from Redis)");
// 2. Test subuser node caching
echo "\n--- Testing Subuser Node Caching ---\n";
$subuser = new subusers_o();
authentication::$subuser = $subuser;
$allowedNode = new stdClass(); $allowedNode->name = 'ALLOWED_NODE';
$deniedNode = new stdClass(); $deniedNode->name = 'DENIED_NODE';
$allowedPermNode = permission_node::create('allowed_node_perm', $allowedNode);
$deniedPermNode = permission_node::create('denied_node_perm', $deniedNode);
redis->clear_permission("subuser:10:999:ALLOWED_NODE");
redis->clear_permission("subuser:10:999:DENIED_NODE");
echo "First call (should hit DB):\n";
assertResult($route->testEval($allowedPermNode), true, "Subuser should have ALLOWED_NODE");
$route = new TestRoute();
echo "Second call after new object (should hit Redis, no DB output expected):\n";
assertResult($route->testEval($allowedPermNode), true, "Subuser should still have ALLOWED_NODE (from Redis)");
echo "\nTesting denied node:\n";
assertResult($route->testEval($deniedPermNode), false, "Subuser should NOT have DENIED_NODE");
$route = new TestRoute();
echo "Call after new object (should hit Redis, no DB output expected):\n";
assertResult($route->testEval($deniedPermNode), false, "Subuser should still NOT have DENIED_NODE (from Redis)");
echo "\nPermissionRedisCacheTest completed successfully.\n";
}
+35 -2
View File
@@ -346,7 +346,25 @@ trait route_t
}
// Expose resolved target customer in response meta for subuser requests
$response->add_meta('target_customer_number', (int)$resolvedCustomer);
$subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key);
$cacheKey = "subuser:{$subuser->id}:{$resolvedCustomer}:{$permission->subusers_node_key->name}";
if (!isset(self::$__perm_subuser_grant_cache[$cacheKey])) {
$cached = null;
if (defined('redis')) {
$cached = redis->get_permission($cacheKey);
}
if ($cached !== null) {
self::$__perm_subuser_grant_cache[$cacheKey] = $cached;
} else {
$subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key);
if (defined('redis')) {
redis->cache_permission($cacheKey, $subuser_has_permission);
}
self::$__perm_subuser_grant_cache[$cacheKey] = $subuser_has_permission;
}
}
$subuser_has_permission = self::$__perm_subuser_grant_cache[$cacheKey];
if (!$subuser_has_permission && $throwOnDeny) {
(new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied via subuser node: ' . $permission->permission . ' (Node: ' . $permission->subusers_node_key->name . ', Customer: ' . $resolvedCustomer . ')');
$response->error('Permission denied for subuser. Missing permission: ' . $permission->permission . ' (Customer context: ' . $resolvedCustomer . ')', 403);
@@ -365,10 +383,25 @@ trait route_t
}
$perm_string = $permission instanceof permission_node ? $permission->permission : $permission;
$userCacheKey = (string)$user->id . ':' . $perm_string;
$redisKey = "user:{$user->id}:{$perm_string}";
if (!isset(self::$__perm_user_cache[$userCacheKey])) {
self::$__perm_user_cache[$userCacheKey] = (bool)$user->hasPermission($perm_string);
$cached = null;
if (defined('redis')) {
$cached = redis->get_permission($redisKey);
}
if ($cached !== null) {
self::$__perm_user_cache[$userCacheKey] = $cached;
} else {
$allowed = (bool)$user->hasPermission($perm_string);
if (defined('redis')) {
redis->cache_permission($redisKey, $allowed);
}
self::$__perm_user_cache[$userCacheKey] = $allowed;
}
}
$allowed = (bool)self::$__perm_user_cache[$userCacheKey];
if (!$allowed && $throwOnDeny) {
(new logs_o())->add('global', 'global', 1, $user->id, 'PERMISSION_DENIED', 'Permission denied. Missing permission: ' . $perm_string);
$response->error('Permission denied. Missing permission: ' . $perm_string . ' for user: ' . $user->id . ' In group: ' . $user->group_id->value(), 403);