From 83c4d4933a3e192cd47953c638286ef7d4a17b07 Mon Sep 17 00:00:00 2001 From: Victor Westerlund Date: Tue, 19 Mar 2024 10:39:50 +0000 Subject: [PATCH] feat: add user groups and group ACL (#55) --- .env.example.ini | 42 ++--- composer.json | 2 - composer.lock | 100 ++-------- src/Init.php | 86 +++++---- src/api/builtin/Call.php | 17 +- src/api/builtin/Endpoint.php | 7 + src/{request => api/builtin}/Method.php | 2 +- src/api/builtin/Response.php | 37 ++-- src/database/Auth.php | 161 ---------------- src/database/Database.php | 209 +++++++++++++++++++-- src/database/Idemp.php | 62 ------ src/database/init/IDEMP.sql | 3 - src/database/init/LOG.sql | 9 - src/database/model/Acl.php | 17 -- src/database/model/AclModel.php | 21 +++ src/database/model/Endpoints.php | 14 -- src/database/model/EndpointsModel.php | 10 + src/database/model/GroupsModel.php | 11 ++ src/database/model/Keys.php | 17 -- src/database/model/KeysModel.php | 13 ++ src/database/model/RelUsersGroupsModel.php | 10 + src/database/model/Users.php | 15 -- src/database/model/UsersModel.php | 11 ++ src/request/Connection.php | 3 +- src/request/Router.php | 55 +++--- 25 files changed, 401 insertions(+), 533 deletions(-) create mode 100644 src/api/builtin/Endpoint.php rename src/{request => api/builtin}/Method.php (90%) delete mode 100644 src/database/Auth.php delete mode 100644 src/database/Idemp.php delete mode 100644 src/database/init/IDEMP.sql delete mode 100644 src/database/init/LOG.sql delete mode 100644 src/database/model/Acl.php create mode 100644 src/database/model/AclModel.php delete mode 100644 src/database/model/Endpoints.php create mode 100644 src/database/model/EndpointsModel.php create mode 100644 src/database/model/GroupsModel.php delete mode 100644 src/database/model/Keys.php create mode 100644 src/database/model/KeysModel.php create mode 100644 src/database/model/RelUsersGroupsModel.php delete mode 100644 src/database/model/Users.php create mode 100644 src/database/model/UsersModel.php diff --git a/.env.example.ini b/.env.example.ini index abcfde1..56302b5 100644 --- a/.env.example.ini +++ b/.env.example.ini @@ -1,38 +1,24 @@ -# -------------------------------------------- -# # Your endpoints -# Absolute path to the root folder of your API -# -------------------------------------------- +; -------------------------------------------- +; # Your endpoints +; Absolute path to the root folder of your API +; -------------------------------------------- endpoints = "" -# -------------------------------------------------- -# # Reflect database -# MySQL/MariaDB Credentials for the Reflect database -# -------------------------------------------------- +; -------------------------------------------------- +; # Reflect database +; MySQL/MariaDB Credentials for the Reflect database +; -------------------------------------------------- mysql_host = "" mysql_user = "" mysql_pass = "" mysql_db = "reflect" -# ---------------------------------------------------- -# # (Optional) UNIX Socket server -# Configuration for the optional Reflect socket server -# ---------------------------------------------------- +; -------------------------------------------------------------------------------- +; # Reflect internal endpoints prefix +; All requests starting with this prefix will be treated as an internal request. +; Internal requests are routed to src/api/reflect/* +; -------------------------------------------------------------------------------- -## Absolute path to the socket file to be created when the Reflect socket server is started -socket = "/run/reflect/api.sock" -## Socket file octal permissions (chmod) -socket_mode = 01750 - -# --------------------------------------------------------------------------- -# # Optional features and settings -# Uncomment lines prefixed with ";" to enable and configure optional features -# --------------------------------------------------------------------------- - -## Request idempotency -# This feature enforces idempotency on POST, PUT, and PATCH requests. -# - Read more at: https://github.com/victorwesterlund/reflect/wiki/idempotency -# -# Absolute path to the *directory* where an SQLite database for idempotency keys will be created. -;idempotency = "/var/lib/reflect/" +internal_request_prefix = "reflect/" \ No newline at end of file diff --git a/composer.json b/composer.json index c8e1d4e..3b1e528 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,6 @@ { "require": { "victorwesterlund/libmysqldriver": "^3.2", - "victorwesterlund/libsqlitedriver": "^1.0", - "victorwesterlund/xenum": "^1.1", "reflect/plugin-rules": "^1.0" } } diff --git a/composer.lock b/composer.lock index ae669e6..45e62ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "76277c412f3f6f3636b4491c026b5b70", + "content-hash": "d7b9032f03fb9b2b69f33263320baf05", "packages": [ { "name": "reflect/plugin-rules", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/VictorWesterlund/reflect-rules-plugin.git", - "reference": "5ea11cadf1acaa81c69a9241b616f301ad93e2eb" + "reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/5ea11cadf1acaa81c69a9241b616f301ad93e2eb", - "reference": "5ea11cadf1acaa81c69a9241b616f301ad93e2eb", + "url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/9c837fd1944133edfed70a63ce8b32bf67f0d94b", + "reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b", "shasum": "" }, "type": "library", @@ -39,22 +39,22 @@ "description": "Add request search paramter and request body constraints to an API built with Reflect", "support": { "issues": "https://github.com/VictorWesterlund/reflect-rules-plugin/issues", - "source": "https://github.com/VictorWesterlund/reflect-rules-plugin/tree/1.4.0" + "source": "https://github.com/VictorWesterlund/reflect-rules-plugin/tree/1.5.0" }, - "time": "2024-01-06T13:24:16+00:00" + "time": "2024-01-17T11:07:44+00:00" }, { "name": "victorwesterlund/libmysqldriver", - "version": "3.3.0", + "version": "3.5.1", "source": { "type": "git", "url": "https://github.com/VictorWesterlund/php-libmysqldriver.git", - "reference": "17fa248edba0859f3cdaefcd7f633475999fe0c7" + "reference": "73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VictorWesterlund/php-libmysqldriver/zipball/17fa248edba0859f3cdaefcd7f633475999fe0c7", - "reference": "17fa248edba0859f3cdaefcd7f633475999fe0c7", + "url": "https://api.github.com/repos/VictorWesterlund/php-libmysqldriver/zipball/73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5", + "reference": "73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5", "shasum": "" }, "type": "library", @@ -76,83 +76,9 @@ "description": "Abstraction library for common mysqli features", "support": { "issues": "https://github.com/VictorWesterlund/php-libmysqldriver/issues", - "source": "https://github.com/VictorWesterlund/php-libmysqldriver/tree/3.3.0" + "source": "https://github.com/VictorWesterlund/php-libmysqldriver/tree/3.5.1" }, - "time": "2024-01-12T12:24:04+00:00" - }, - { - "name": "victorwesterlund/libsqlitedriver", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/VictorWesterlund/php-libsqlitedriver.git", - "reference": "302d0cbad060176de114181a63a3a6d98d05fc26" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/VictorWesterlund/php-libsqlitedriver/zipball/302d0cbad060176de114181a63a3a6d98d05fc26", - "reference": "302d0cbad060176de114181a63a3a6d98d05fc26", - "shasum": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "libsqlitedriver\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0-only" - ], - "authors": [ - { - "name": "Victor Westerlund", - "email": "victor.vesterlund@gmail.com" - } - ], - "description": "Abstraction library for common SQLite features", - "support": { - "issues": "https://github.com/VictorWesterlund/php-libsqlitedriver/issues", - "source": "https://github.com/VictorWesterlund/php-libsqlitedriver/tree/1.1.0" - }, - "time": "2023-05-30T10:55:56+00:00" - }, - { - "name": "victorwesterlund/xenum", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/VictorWesterlund/php-xenum.git", - "reference": "8972f06f42abd1f382807a67e937d5564bb89699" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699", - "reference": "8972f06f42abd1f382807a67e937d5564bb89699", - "shasum": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "victorwesterlund\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0-only" - ], - "authors": [ - { - "name": "Victor Westerlund", - "email": "victor.vesterlund@gmail.com" - } - ], - "description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums", - "support": { - "issues": "https://github.com/VictorWesterlund/php-xenum/issues", - "source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1" - }, - "time": "2023-11-20T10:10:39+00:00" + "time": "2024-02-26T12:51:52+00:00" } ], "packages-dev": [], diff --git a/src/Init.php b/src/Init.php index 145739d..c2001ce 100644 --- a/src/Init.php +++ b/src/Init.php @@ -7,65 +7,66 @@ Everything here is loaded before endpoint request processing begins. */ - /* - # Default endpoint interface - This interface need to be implemented by all endpoints - */ - interface Endpoint { - public function main(); - } + use Reflect\ENV; + use Reflect\Path; + enum ENV: string { + protected const NAMESPACE = "_reflect"; + protected const ENV_INI = ".env.ini"; + protected const COMPOSER = "vendor/autoload.php"; - /* - # Reflect environment abstractions - This class contains abstractions for Reflect environment variables - */ - class ENV { - // Reflect environment variables are placed in $_ENV as an assoc array with this as the array key. - // Example: $_ENV[self::NS][] - private const NS = "_REFLECT"; + // START User configurable environment variables + + case ENDPOINTS = "endpoints"; + + case MYSQL_HOST = "mysql_host"; + case MYSQL_USER = "mysql_user"; + case MYSQL_PASS = "mysql_pass"; + case MYSQL_DB = "mysql_db"; + + case INTERNAL_REQUEST_PREFIX = "internal_request_prefix"; - // Name of the .ini file containing environment variables to be loaded (internal and userspace) - private const INI = ".env.ini"; + // END User configurable environment variables - // Path to the composer autoload file (internal and userspace) - private const COMPOSER_AUTLOAD = "vendor/autoload.php"; + case INTERNAL_STDOUT = "internal_stdout"; - // Returns true if Reflect environment variable is present and not empty in - public static function isset(string $key): bool { - return in_array($key, array_keys($_ENV[self::NS])) && !empty($_ENV[self::NS][$key]); - } + // Returns true if Reflect environment variable is present and not empty in + public static function isset(ENV $key): bool { + return in_array($key->value, array_keys($_ENV[self::NAMESPACE])) && !empty($_ENV[self::NAMESPACE][$key->value]); + } // Get environment variable by key - public static function get(string $key): mixed { - return self::isset($key) ? $_ENV[self::NS][$key] : null; + public static function get(ENV $key): mixed { + return self::isset($key) ? $_ENV[self::NAMESPACE][$key->value] : null; } // Set environment variable key, value pair - public static function set(string $key, mixed $value = null) { - $_ENV[self::NS][$key] = $value; + public static function set(ENV $key, mixed $value = null) { + $_ENV[self::NAMESPACE][$key->value] = $value; } - /* ---- */ - // Load environment variables and dependancies public static function init() { - // Initialize namespaced environment variables from .ini config file - $_ENV[self::NS] = parse_ini_file(Path::reflect(self::INI), true) ?? die("Environment variable file '" . self::INI . "' not found"); + // Put environment variables from Vegvisir .ini into namespaced superglobal + $_ENV[self::NAMESPACE] = parse_ini_file(Path::reflect(self::ENV_INI), true); - require_once Path::reflect(self::COMPOSER_AUTLOAD) ?? die("Failed to load dependencies. Install dependencies with 'composer install'"); + // Don't perform loopback responses by default + ENV::set(ENV::INTERNAL_STDOUT, false); - // Merge environment variables from userspace if present - if (file_exists(Path::root(self::INI))) { - $_ENV = array_merge($_ENV, parse_ini_file(Path::root(self::INI), true)); + // Load Composer dependencies + require_once Path::reflect(self::COMPOSER); + + // Merge environment variables from user site into superglobal + if (file_exists(Path::root(self::ENV_INI))) { + $_ENV = array_merge($_ENV, parse_ini_file(Path::root(self::ENV_INI), true)); } // Load composer dependencies from userspace if exists - if (file_exists(Path::root(self::COMPOSER_AUTLOAD))) { - require_once Path::root(self::COMPOSER_AUTLOAD); + if (file_exists(Path::root(self::COMPOSER))) { + require_once Path::root(self::COMPOSER); } } - } + } /* # Path abstractions @@ -73,19 +74,16 @@ public static function init() { A tailing "/" is appended to each path to prevent peer dirname attacks from endpoints. */ class Path { + const ENDPOINTS_FOLDER = "endpoints"; + // Get path to or relative path from the Reflect install directory public static function reflect(string $crumbs = ""): string { return dirname(__DIR__) . "/" . $crumbs; } - // Get path to the default API class - public static function init(): string { - return self::reflect("src/api/API.php"); - } - // Get path to or relative path from the user's configured root public static function root(string $crumbs = ""): string { - return ENV::get("endpoints") . (substr($crumbs, 0, 1) === "/" ? "" : "/") . $crumbs; + return ENV::get(ENV::ENDPOINTS) . (substr($crumbs, 0, 1) === "/" ? "" : "/") . $crumbs; } } diff --git a/src/api/builtin/Call.php b/src/api/builtin/Call.php index 3f1d758..9593739 100644 --- a/src/api/builtin/Call.php +++ b/src/api/builtin/Call.php @@ -2,15 +2,16 @@ namespace Reflect; - use \Reflect\ENV; - use \Reflect\Path; - use \Reflect\Response; - use \Reflect\Request\Router; - use \Reflect\Request\Method; - use \Reflect\Request\Connection; - use \Reflect\Helpers\GlobalSnapshot; + use Reflect\ENV; + use Reflect\Path; + use Reflect\Method; + use Reflect\Response; + use Reflect\Request\Router; + use Reflect\Request\Connection; + use Reflect\Helpers\GlobalSnapshot; require_once Path::reflect("src/request/Router.php"); + require_once Path::reflect("src/api/builtin/Method.php"); require_once Path::reflect("src/api/builtin/Response.php"); require_once Path::reflect("src/api/helpers/GlobalSnapshot.php"); @@ -56,7 +57,7 @@ function Call(string $endpoint, string|Method $method = null, array $payload = n } // Set flag to let stdout() know that we wish to return instead of exit. - ENV::set("INTERNAL_STDOUT", true); + ENV::set(ENV::INTERNAL_STDOUT, true); // Start "proxied" Router (internal request) $resp = (new Router(Connection::INTERNAL))->main(); diff --git a/src/api/builtin/Endpoint.php b/src/api/builtin/Endpoint.php new file mode 100644 index 0000000..8375d44 --- /dev/null +++ b/src/api/builtin/Endpoint.php @@ -0,0 +1,7 @@ +code = $code; $this->output = $output; - // MIME Type of the response + + // Set MIME of the Response $this->type = $type ? $type : self::DEFAULT_TYPE; - // Similar to JavaScript's "Response.ok" for easy check if response is, well, OK. + // Response code is within HTTP Success range $this->ok = $code < 300 && $code >= 200; // Set Content-Type of response with MIME type from enum header("Content-Type: {$this->type}"); // Response is not an internal request (from Call()) so we need to trigger an output from here - if (!ENV::isset("INTERNAL_STDOUT")) { - if (ENV::isset("SOCKET_STDOUT")) { - $this->stdout_socket(); - } else { - $this->stdout_http(); - } - } else { - $this->output(); - } + ENV::isset(ENV::INTERNAL_STDOUT) ? $this->stdout_internal() : $this->stdout_http(); + } + + // Return output data directly. This method can be accessed from Reflect Call() + public function output(): mixed { + return $this->output; } - // Echo the output + // Write data to PHP's default standard output (HTTP response) private function stdout_http(): never { http_response_code($this->code); @@ -46,13 +44,8 @@ private function stdout_http(): never { exit($this->type === self::DEFAULT_TYPE ? json_encode($this->output) : $this->output); } - // Pass output to socker handler - private function stdout_socket() { - return ENV::get("SOCKET_STDOUT")($this->output, $this->code); - } - - // Get output for use with internal requests - public function output(): mixed { - return $this->output; + // Write data to Reflect Call() standard output + private function stdout_internal(): mixed { + return $this->output(); } } \ No newline at end of file diff --git a/src/database/Auth.php b/src/database/Auth.php deleted file mode 100644 index 99b792e..0000000 --- a/src/database/Auth.php +++ /dev/null @@ -1,161 +0,0 @@ -con = $con; - } - - // Return bool user id is enabled - private function user_active(string|null $user): bool { - // Internal connections have no API key, so return true - if ($this->con === Connection::INTERNAL) { - return true; - } - - // Return true if user exists and is active - return $this->for(UsersModel::TABLE) - ->with(UsersModel::values()) - ->where([ - UsersModel::ID->value => $user, - UsersModel::ACTIVE->value => true - ]) - ->limit(1) - ->select(null)->num_rows === 1; - } - - // Check if key and its user is active and not expired - private function key_active(string $key): bool { - $table = KeysModel::TABLE; - - // Check that key exists, is active, and not expired (now > created && now < expires) - $user_column = KeysModel::USER->value; - - // Get column names from backed enum - $id = KeysModel::ID->value; - $active = KeysModel::ACTIVE->value; - $expires = KeysModel::EXPIRES->value; - - $sql = "SELECT {$user_column} FROM {$table} WHERE {$id} = ? AND {$active} = 1 AND (NOW() BETWEEN NOW() AND FROM_UNIXTIME(COALESCE({$expires}, UNIX_TIMESTAMP())))"; - $res = $this->exec($sql, $key); - - // Return key from request or default to anonymous key if it's invalid - return $res->num_rows === 1 && $this->user_active($res->fetch_assoc()["user"]); - } - - // Return bool endpoint enabled - private function endpoint_active(string $endpoint): bool { - return $this->for(EndpointsModel::TABLE) - ->with(EndpointsModel::values()) - ->where([ - "endpoint" => $endpoint, - "active" => 1 - ]) - ->limit(1) - ->select(null)->num_rows === 1; - } - - // ---- - - // Validate API key from GET parameter - public function get_api_key(): ?string { - // No API key provided - if (empty($_SERVER["HTTP_AUTHORIZATION"])) { - // Mock Authorization header - $_SERVER["HTTP_AUTHORIZATION"] = "Bearer " . self::EMPTY_KEY; - } - - // Destruct Authorization header from - [$scheme, $key] = explode(" ", $_SERVER["HTTP_AUTHORIZATION"], 2); - - // Invalid authorization scheme or empty key, treat request as public - if ($scheme !== "Bearer" || $key === self::EMPTY_KEY) { - return null; - } - - // Return API key if user is active. Else return null and treat the request as public - return $this->key_active($key) ? $key : null; - } - - // Return all available request methods to endpoint with key - public function get_options(string $endpoint): array { - $api_key = $this->get_api_key(); - - $acl = $this->for(AclModel::TABLE) - ->with(AclModel::values()) - ->where([ - "api_key" => $api_key, - "endpoint" => $endpoint - ]) - // TODO: libmysqldriver - ->limit(6) - ->select(["method"]); - - // Flatten array to only values of "method" - return !empty($acl) ? array_column($acl, "method") : []; - } - - // Check if API key is authorized to call endpoint using method - public function check(string $endpoint, Method $method): bool { - // Return false if endpoint is not enabled - if (!$this->endpoint_active($endpoint)) { - return false; - } - - // Internal and connections are always allowed - if ($this->con === Connection::INTERNAL) { - return true; - } - - // Prepare filter for ACL check - $filter = [ - "api_key" => $this->get_api_key(), - "endpoint" => $endpoint, - "method" => $method->value - ]; - - // Check if the API key has access to the requested endpoint and method - $has_access = $this->for(AclModel::TABLE) - ->with(AclModel::values()) - ->where($filter) - ->limit(1) - ->select(null); - - // API key does not have access. So let's check again using null for public endpoints - if ($has_access->num_rows !== 1) { - $filter["api_key"] = null; - - $has_access = $this->for(AclModel::TABLE) - ->with(AclModel::values()) - ->where($filter) - ->limit(1) - ->select(null); - } - - return $has_access->num_rows === 1; - } - } \ No newline at end of file diff --git a/src/database/Database.php b/src/database/Database.php index 7a01cdd..652e172 100644 --- a/src/database/Database.php +++ b/src/database/Database.php @@ -1,24 +1,199 @@ in_array($key, $model) && !is_null($fields[$key]), ARRAY_FILTER_USE_KEY); - } - } \ No newline at end of file + $this->con = $con; + + $this->api_key = self::get_key_from_request(); + + if ($this->api_key) { + $this->user_id = $this->get_user_id(); + } + } + + // Get key from Authorization header + protected static function get_key_from_request(): ?string { + // No API key provided + if (empty($_SERVER["HTTP_AUTHORIZATION"])) { + // Mock Authorization header + $_SERVER["HTTP_AUTHORIZATION"] = "Bearer " . self::EMPTY_KEY; + } + + // Destruct Authorization header from + [$scheme, $key] = explode(" ", $_SERVER["HTTP_AUTHORIZATION"], 2); + + // Invalid authorization scheme or empty key, treat request as public + if ($scheme !== "Bearer" || $key === self::EMPTY_KEY) { + return null; + } + + // Return API key if user is active. Else return null and treat the request as public + return $key ? $key : null; + } + + // Return bool user id is enabled + private function user_active(string|null $user): bool { + // Internal connections have no API key, so return true + if ($this->con === Connection::INTERNAL) { + return true; + } + + // Return true if user exists and is active + return $this->for(UsersModel::TABLE) + ->where([ + UsersModel::ID->value => $user, + UsersModel::ACTIVE->value => true + ]) + ->limit(1) + ->select(null)->num_rows === 1; + } + + // Check if key and its user is active and not expired + private function get_user_id(): ?string { + // No key has been set + if (!$this->api_key) { + return null; + } + + // Values for SQL sprintf + $fvalues = [ + KeysModel::REF_USER->value, + KeysModel::TABLE, + KeysModel::ID->value, + KeysModel::ACTIVE->value, + KeysModel::EXPIRES->value + ]; + + // Return user id for key if it exists and is not expired + // NOTE: libmysqldriver\MySQL does not implement range operators (yet), so the questy string has to be built manually + $sql = "SELECT `%s` FROM `%s` WHERE `%s` = ? AND `%s` = 1 AND (NOW() BETWEEN NOW() AND FROM_UNIXTIME(COALESCE(`%s`, UNIX_TIMESTAMP())))"; + $res = $this->exec(sprintf($sql, ...$fvalues), $this->api_key); + + // Key is not active or invalid + if ($res->num_rows !== 1) { + return null; + } + + $user_id = $res->fetch_assoc()[KeysModel::REF_USER->value]; + + // Return user id from key or null if user is inactive + return $this->user_active($user_id) ? $user_id : null; + } + + // Return list of all group names associated with the current user + private function get_user_groups(): array { + $resp = $this->for(RelUsersGroupsModel::TABLE) + ->where([ + RelUsersGroupsModel::REF_USER->value => $this->user_id + ]) + ->select(RelUsersGroupsModel::REF_GROUP->value); + + // Extract group ID from database response + return array_column($resp->fetch_all(MYSQLI_ASSOC), RelUsersGroupsModel::REF_GROUP->value); + } + + // Returns true if the provided endpoint string is active + private function endpoint_active(string $endpoint): bool { + return $this->for(EndpointsModel::TABLE) + ->where([ + EndpointsModel::ID->value => $endpoint, + EndpointsModel::ACTIVE->value => 1 + ]) + ->limit(1) + ->select(null)->num_rows === 1; + } + + // Return all available request methods to endpoint with key + public function get_options(string $endpoint): array { + $api_key = $this->get_api_key(); + + $acl = $this->for(AclModel::TABLE) + ->with(AclModel::values()) + ->where([ + "api_key" => $api_key, + "endpoint" => $endpoint + ]) + // TODO: libmysqldriver + ->limit(6) + ->select(["method"]); + + // Flatten array to only values of "method" + return !empty($acl) ? array_column($acl, "method") : []; + } + + // Check if API key is authorized to call endpoint using method + public function has_access(string $endpoint, Method $method): bool { + // Return false if endpoint is disabled or invalid + if (!$this->endpoint_active($endpoint)) { + return false; + } + + // Internal and connections are always allowed if the endpoint is active + if ($this->con === Connection::INTERNAL) { + return true; + } + + // No API key provided, or user is dissabled. Check if the endpoint is public + if (!$this->api_key || !$this->user_id) { + return $this->for(AclModel::TABLE) + ->where([ + AclModel::REF_GROUP->value => null, + AclModel::REF_ENDPOINT->value => $endpoint, + AclModel::METHOD->value => $method->value + ]) + ->limit(1) + ->select(null)->num_rows === 1; + } + + // Build ACL conditions for each group the user is a member of + $group_queries = []; + foreach ($this->get_user_groups() as $group_name) { + $group_queries[] = [ + AclModel::REF_GROUP->value => $group_name, + AclModel::REF_ENDPOINT->value => $endpoint, + AclModel::METHOD->value => $method->value + ]; + } + + // Return true if the user has access to endpoint and method through group id + return $this->for(AclModel::TABLE) + ->where(...$group_queries) + ->limit(1) + ->select(AclModel::REF_GROUP->value)->num_rows === 1; + } + } \ No newline at end of file diff --git a/src/database/Idemp.php b/src/database/Idemp.php deleted file mode 100644 index 2c0c033..0000000 --- a/src/database/Idemp.php +++ /dev/null @@ -1,62 +0,0 @@ -get_db_name(), Path::reflect("src/database/init/IDEMP.sql")); - } - - // Check if string is valid UUID4 - private static function is_uuid4(string $value): bool { - $pattern = "/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i"; - return preg_match($pattern, $value); - } - - // Generate a psuedo-random UUID4 - public static function uuidv4(): string { - $bytes = random_bytes(16); - - $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); // Set version to 0100 - $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); // Set bytes 6-7 to 10 - - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } - - // Get path to SQLite database file - private function get_db_name(): string { - // Use CRC32 of the configured ACL database as name for the idempotency database. - // This allows the same idempotency key to be used for separate databases and hosts - $db = crc32(implode("", [$_ENV[ENV]["mysql_host"], $_ENV[ENV]["mysql_db"]])); - - // Build path from root and database name with extension - return "{$_ENV[ENV]["idempotency"]}{$db}.db"; - } - - // Returns true if a provided UUID exists in database - public function check(string $uuid): bool { - $sql = "SELECT NULL FROM keys WHERE uuid = ?"; - return $this->return_bool($sql, $uuid); - } - - // Returns true if insert was successful, meaing that this - // key has not been seen before due to the PRIMARY constraint on - // the database column. - public function set(string $uuid): bool|null { - // Value must be a valid UUID4 string - if (!IdempDb::is_uuid4($uuid)) { - return null; - } - - $sql = "INSERT INTO keys (uuid) VALUES (?)"; - return $this->return_bool($sql, $uuid); - } - } \ No newline at end of file diff --git a/src/database/init/IDEMP.sql b/src/database/init/IDEMP.sql deleted file mode 100644 index 6e8475e..0000000 --- a/src/database/init/IDEMP.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE keys ( - uuid TEXT PRIMARY KEY NOT NULL -); \ No newline at end of file diff --git a/src/database/init/LOG.sql b/src/database/init/LOG.sql deleted file mode 100644 index 6fc7b72..0000000 --- a/src/database/init/LOG.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE syslog ( - id TEXT PRIMARY KEY NOT NULL, - level INT NO NULL, - data BLOB, - initiator TEXT, - date_created INT NOT NULL -); - -CREATE INDEX idx_initiator ON syslog(initiator); \ No newline at end of file diff --git a/src/database/model/Acl.php b/src/database/model/Acl.php deleted file mode 100644 index 2054011..0000000 --- a/src/database/model/Acl.php +++ /dev/null @@ -1,17 +0,0 @@ -endpoint, 0, 8) !== "reflect/" - // User endpoints are kept in folders with 'index.php' as the file to run - ? Path::root("endpoints/{$this->endpoint}/{$this->method->value}.php") - // Internal endpoints are stored as named files - : Path::reflect("src/api/{$this->endpoint}/{$this->method->value}.php"); + // Get internal request prefix string from environment variable + $internal_prefix = ENV::get(ENV::INTERNAL_REQUEST_PREFIX); + + // Request is to an internal Reflect endpoint + if (substr($this->endpoint, 0, strlen($internal_prefix)) === $internal_prefix) { + return Path::reflect("src/api/{$this->endpoint}/{$this->method->value}.php"); + } + + // Return path to endpoint method file from user endpoints + return Path::root(implode("/", [ + Path::ENDPOINTS_FOLDER, + $this->endpoint, + $this->method->value . ".php" + ])); } // Call an API endpoint by parsing a RESTful request, checking key permissions against AuthDB, @@ -103,7 +110,7 @@ public function main(): Response { $class = $this->get_endpoint_class(); // Check that the endpoint exists and that the user is allowed to call it - if (!file_exists($file) || !$this->check($this->endpoint, $this->method)) { + if (!file_exists($file) || !$this->has_access($this->endpoint, $this->method)) { return new Response(["No endpoint", "Endpoint not found or insufficient permissions for the requested method"], 404); }