From c4aa4b301033f867669f656cbf4f062637b71398 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:01:28 +0000 Subject: [PATCH 01/50] Feat: adapter and clickhouse adapter --- README.md | 140 +++++++- composer.json | 19 +- composer.lock | 295 ++++++++++------ example.php | 144 ++++++++ src/Audit/Adapter.php | 167 +++++++++ src/Audit/Adapter/ClickHouse.php | 573 +++++++++++++++++++++++++++++++ src/Audit/Adapter/Database.php | 317 +++++++++++++++++ src/Audit/Adapter/SQL.php | 220 ++++++++++++ src/Audit/Audit.php | 430 ++++++----------------- tests/Audit/AuditTest.php | 20 +- 10 files changed, 1874 insertions(+), 451 deletions(-) create mode 100644 example.php create mode 100644 src/Audit/Adapter.php create mode 100644 src/Audit/Adapter/ClickHouse.php create mode 100644 src/Audit/Adapter/Database.php create mode 100644 src/Audit/Adapter/SQL.php diff --git a/README.md b/README.md index 0cd79be..31b37ab 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Utopia framework audit library is simple and lite library for managing applicati Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework. +## Features + +- **Adapter Pattern**: Support for multiple storage backends through adapters +- **Default Database Adapter**: Built-in support for utopia-php/database +- **Extensible**: Easy to create custom adapters for different storage solutions +- **Batch Operations**: Support for logging multiple events at once +- **Query Support**: Rich querying capabilities for retrieving logs + ## Getting Started Install using composer: @@ -15,14 +23,17 @@ Install using composer: composer require utopia-php/audit ``` -Init the audit object: +## Usage + +### Using the Database Adapter (Default) + +The simplest way to use Utopia Audit is with the built-in Database adapter: ```php 3, // Seconds + PDO::ATTR_TIMEOUT => 3, PDO::ATTR_PERSISTENT => true, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, @@ -46,13 +57,35 @@ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $ $cache = new Cache(new NoCache()); -$database = new Database(new MySQL($pdo),$cache); +$database = new Database(new MySQL($pdo), $cache); $database->setNamespace('namespace'); -$audit = new Audit($database); +// Create audit instance with Database adapter +$audit = Audit::withDatabase($database); $audit->setup(); ``` +### Using a Custom Adapter + +You can create custom adapters by extending the `Utopia\Audit\Adapter` abstract class: + +```php +getLogsByResource( ); // Returns an array of all logs for the specific resource ``` +**Batch Logging** + +Log multiple events at once for better performance: + +```php +use Utopia\Database\DateTime; + +$events = [ + [ + 'userId' => 'user-1', + 'event' => 'create', + 'resource' => 'database/document/1', + 'userAgent' => 'Mozilla/5.0...', + 'ip' => '127.0.0.1', + 'location' => 'US', + 'data' => ['key' => 'value'], + 'timestamp' => DateTime::now() + ], + [ + 'userId' => 'user-2', + 'event' => 'update', + 'resource' => 'database/document/2', + 'userAgent' => 'Mozilla/5.0...', + 'ip' => '192.168.1.1', + 'location' => 'UK', + 'data' => ['key' => 'value'], + 'timestamp' => DateTime::now() + ] +]; + +$documents = $audit->logBatch($events); +``` + +## Adapters + +Utopia Audit uses an adapter pattern to support different storage backends. Currently available adapters: + +### Database Adapter (Default) + +The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a database. + +**Supported Databases:** +- MySQL/MariaDB +- PostgreSQL +- MongoDB +- And all other databases supported by utopia-php/database + +### Creating Custom Adapters + +To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and implement all required methods: + +```php +=8.0", - "utopia-php/database": "4.*" + "utopia-php/database": "4.*", + "utopia-php/fetch": "^0.4.2" }, "require-dev": { "phpunit/phpunit": "9.*", diff --git a/composer.lock b/composer.lock index c7224b5..ed1f019 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": "98086565cc67f908add596b771d1c23e", + "content-hash": "19cc937889f0e8fcc9f196c2d50be7e0", "packages": [ { "name": "brick/math", - "version": "0.14.0", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.0" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-08-29T12:40:03+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "composer/semver", @@ -145,16 +145,16 @@ }, { "name": "google/protobuf", - "version": "v4.33.0", + "version": "v4.33.2", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d" + "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/b50269e23204e5ae859a326ec3d90f09efe3047d", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318", + "reference": "fbd96b7bf1343f4b0d8fb358526c7ba4d72f1318", "shasum": "" }, "require": { @@ -183,22 +183,22 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.2" }, - "time": "2025-10-15T20:10:28+00:00" + "time": "2025-12-05T22:12:22+00:00" }, { "name": "mongodb/mongodb", - "version": "2.1.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "f399d24905dd42f97dfe0af9706129743ef247ac" + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/f399d24905dd42f97dfe0af9706129743ef247ac", - "reference": "f399d24905dd42f97dfe0af9706129743ef247ac", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", "shasum": "" }, "require": { @@ -214,7 +214,7 @@ "require-dev": { "doctrine/coding-standard": "^12.0", "phpunit/phpunit": "^10.5.35", - "rector/rector": "^1.2", + "rector/rector": "^2.1.4", "squizlabs/php_codesniffer": "^3.7", "vimeo/psalm": "6.5.*" }, @@ -260,9 +260,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.1" + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" }, - "time": "2025-08-13T20:50:05+00:00" + "time": "2025-10-06T12:12:40+00:00" }, { "name": "nyholm/psr7", @@ -410,16 +410,16 @@ }, { "name": "open-telemetry/api", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", - "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", + "reference": "45bda7efa8fcdd9bdb0daa2f26c8e31f062f49d4", "shasum": "" }, "require": { @@ -439,7 +439,7 @@ ] }, "branch-alias": { - "dev-main": "1.7.x-dev" + "dev-main": "1.8.x-dev" } }, "autoload": { @@ -472,11 +472,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-10-19T10:49:48+00:00" }, { "name": "open-telemetry/context", @@ -539,16 +539,16 @@ }, { "name": "open-telemetry/exporter-otlp", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2" + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", - "reference": "196f3a1dbce3b2c0f8110d164232c11ac00ddbb2", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/07b02bc71838463f6edcc78d3485c04b48fb263d", + "reference": "07b02bc71838463f6edcc78d3485c04b48fb263d", "shasum": "" }, "require": { @@ -595,11 +595,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-16T00:24:51+00:00" + "time": "2025-11-13T08:04:37+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -666,16 +666,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", - "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", + "reference": "3dfc3d1ad729ec7eb25f1b9a4ae39fe779affa99", "shasum": "" }, "require": { @@ -755,11 +755,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-02T23:44:28+00:00" + "time": "2025-11-25T10:59:15+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1383,16 +1383,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.4", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + "reference": "ee5e0e0139ab506f6063a230e631bed677c650a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ee5e0e0139ab506f6063a230e631bed677c650a4", + "reference": "ee5e0e0139ab506f6063a230e631bed677c650a4", "shasum": "" }, "require": { @@ -1423,12 +1423,13 @@ "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -1459,7 +1460,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.4" + "source": "https://github.com/symfony/http-client/tree/v7.4.0" }, "funding": [ { @@ -1479,7 +1480,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-11-20T12:32:50+00:00" }, { "name": "symfony/http-client-contracts", @@ -1886,16 +1887,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -1949,7 +1950,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1960,12 +1961,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -2119,16 +2124,16 @@ }, { "name": "utopia-php/database", - "version": "4.0.0", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "da9d021b2722abdf4df08a739126f1be2707cf6d" + "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/da9d021b2722abdf4df08a739126f1be2707cf6d", - "reference": "da9d021b2722abdf4df08a739126f1be2707cf6d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/fe7a1326ad623609e65587fe8c01a630a7075fee", + "reference": "fe7a1326ad623609e65587fe8c01a630a7075fee", "shasum": "" }, "require": { @@ -2171,28 +2176,68 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/4.0.0" + "source": "https://github.com/utopia-php/database/tree/4.3.0" + }, + "time": "2025-11-14T03:43:10+00:00" + }, + { + "name": "utopia-php/fetch", + "version": "0.4.2", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/fetch.git", + "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/83986d1be75a2fae4e684107fe70dd78a8e19b77", + "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Fetch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library that provides an interface for making HTTP Requests.", + "support": { + "issues": "https://github.com/utopia-php/fetch/issues", + "source": "https://github.com/utopia-php/fetch/tree/0.4.2" }, - "time": "2025-11-04T10:55:46+00:00" + "time": "2025-04-25T13:48:02+00:00" }, { "name": "utopia-php/framework", - "version": "0.33.28", + "version": "0.33.33", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "5aaa94d406577b0059ad28c78022606890dc6de0" + "reference": "838e3a28276e73187bc34a314f014096dc92191b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/5aaa94d406577b0059ad28c78022606890dc6de0", - "reference": "5aaa94d406577b0059ad28c78022606890dc6de0", + "url": "https://api.github.com/repos/utopia-php/http/zipball/838e3a28276e73187bc34a314f014096dc92191b", + "reference": "838e3a28276e73187bc34a314f014096dc92191b", "shasum": "" }, "require": { "php": ">=8.1", "utopia-php/compression": "0.1.*", - "utopia-php/telemetry": "0.1.*" + "utopia-php/telemetry": "0.1.*", + "utopia-php/validators": "0.1.*" }, "require-dev": { "laravel/pint": "^1.2", @@ -2218,9 +2263,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.28" + "source": "https://github.com/utopia-php/http/tree/0.33.33" }, - "time": "2025-09-25T10:44:24+00:00" + "time": "2025-11-25T10:21:13+00:00" }, { "name": "utopia-php/mongo", @@ -2384,6 +2429,51 @@ "source": "https://github.com/utopia-php/telemetry/tree/0.1.1" }, "time": "2025-03-17T11:57:52+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5c57d5b6cf964f8981807c1d3ea8df620c869080", + "reference": "5c57d5b6cf964f8981807c1d3ea8df620c869080", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "1.*", + "phpunit/phpunit": "11.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.1.0" + }, + "time": "2025-11-18T11:05:46+00:00" } ], "packages-dev": [ @@ -2459,16 +2549,16 @@ }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", "shasum": "" }, "require": { @@ -2479,13 +2569,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -2511,6 +2601,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -2521,7 +2612,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2025-11-25T21:15:52+00:00" }, { "name": "myclabs/deep-copy", @@ -2585,16 +2676,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -2637,9 +2728,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -3133,16 +3224,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.29", + "version": "9.6.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" + "reference": "945d0b7f346a084ce5549e95289962972c4272e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", - "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5", "shasum": "" }, "require": { @@ -3216,7 +3307,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" }, "funding": [ { @@ -3240,7 +3331,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:29:11+00:00" + "time": "2025-12-06T07:45:52+00:00" }, { "name": "sebastian/cli-parser", @@ -4255,16 +4346,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4293,7 +4384,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4301,17 +4392,17 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.0" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/example.php b/example.php new file mode 100644 index 0000000..2615693 --- /dev/null +++ b/example.php @@ -0,0 +1,144 @@ + 3, + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_STRINGIFY_FETCHES => true, + ]); + + // Create cache instance + $cache = new Cache(new NoCache()); + + // Create database instance + $database = new Database(new MySQL($pdo), $cache); + $database->setDatabase('auditExample'); + $database->setNamespace('example'); + + // Create database if it doesn't exist + if (!$database->exists('auditExample')) { + $database->create(); + } + + // Method 1: Create Audit instance using the Database adapter (recommended) + $audit = Audit::withDatabase($database); + + // Setup the audit collection (creates tables/collections and indexes) + $audit->setup(); + + echo "✓ Audit instance created and setup completed\n\n"; + + // Example 1: Log a single event + echo "Example 1: Logging a single event\n"; + $document = $audit->log( + userId: 'user-123', + event: 'document.create', + resource: 'database/collection/document-456', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + ip: '192.168.1.100', + location: 'US', + data: [ + 'documentId' => 'document-456', + 'collectionId' => 'collection-789', + 'action' => 'created new document' + ] + ); + echo "✓ Created log with ID: {$document->getId()}\n\n"; + + // Example 2: Log multiple events in batch + echo "Example 2: Batch logging multiple events\n"; + $batchEvents = [ + [ + 'userId' => 'user-123', + 'event' => 'document.update', + 'resource' => 'database/collection/document-456', + 'userAgent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + 'ip' => '192.168.1.100', + 'location' => 'US', + 'data' => ['field' => 'title', 'oldValue' => 'Old Title', 'newValue' => 'New Title'], + 'timestamp' => DateTime::now() + ], + [ + 'userId' => 'user-456', + 'event' => 'user.login', + 'resource' => 'auth/session/session-789', + 'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + 'ip' => '192.168.1.101', + 'location' => 'UK', + 'data' => ['sessionId' => 'session-789', 'method' => 'email'], + 'timestamp' => DateTime::now() + ], + ]; + + $documents = $audit->logBatch($batchEvents); + echo "✓ Created " . count($documents) . " logs in batch\n\n"; + + // Example 3: Retrieve logs by user + echo "Example 3: Retrieving logs by user\n"; + $userLogs = $audit->getLogsByUser('user-123'); + echo "✓ Found " . count($userLogs) . " logs for user-123\n"; + foreach ($userLogs as $log) { + echo " - Event: {$log->getAttribute('event')}, Resource: {$log->getAttribute('resource')}\n"; + } + echo "\n"; + + // Example 4: Retrieve logs by user and specific events + echo "Example 4: Retrieving logs by user and events\n"; + $eventLogs = $audit->getLogsByUserAndEvents('user-123', ['document.create', 'document.update']); + echo "✓ Found " . count($eventLogs) . " document events for user-123\n\n"; + + // Example 5: Retrieve logs by resource + echo "Example 5: Retrieving logs by resource\n"; + $resourceLogs = $audit->getLogsByResource('database/collection/document-456'); + echo "✓ Found " . count($resourceLogs) . " logs for resource\n\n"; + + // Example 6: Count logs + echo "Example 6: Counting logs\n"; + $userLogsCount = $audit->countLogsByUser('user-123'); + echo "✓ Total logs for user-123: {$userLogsCount}\n\n"; + + // Example 7: Using with custom adapter (alternative method) + echo "Example 7: Using custom adapter\n"; + $customAdapter = new \Utopia\Audit\Adapter\Database($database); + $auditWithAdapter = Audit::withAdapter($customAdapter); + echo "✓ Audit created with custom adapter: {$customAdapter->getName()}\n\n"; + + // Example 8: Cleanup old logs + echo "Example 8: Cleanup old logs (commented out to preserve examples)\n"; + // $oldDate = DateTime::addSeconds(new \DateTime(), -3600); // Logs older than 1 hour + // $audit->cleanup($oldDate); + echo "✓ Cleanup example available (currently commented out)\n\n"; + + echo "All examples completed successfully!\n"; +} catch (\Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + echo "Trace: " . $e->getTraceAsString() . "\n"; + exit(1); +} diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php new file mode 100644 index 0000000..fb568bd --- /dev/null +++ b/src/Audit/Adapter.php @@ -0,0 +1,167 @@ + + * } $log + * @return Document The created document + * + * @throws \Exception + */ + abstract public function create(array $log): Document; + + /** + * Create multiple audit log entries in batch. + * + * @param array + * }> $logs + * @return array + * + * @throws \Exception + */ + abstract public function createBatch(array $logs): array; + + /** + * Get logs by user ID. + * + * @param string $userId + * @param array $queries Additional query parameters + * @return array + * + * @throws \Exception + */ + abstract public function getByUser(string $userId, array $queries = []): array; + + /** + * Count logs by user ID. + * + * @param string $userId + * @param array $queries Additional query parameters + * @return int + * + * @throws \Exception + */ + abstract public function countByUser(string $userId, array $queries = []): int; + + /** + * Get logs by resource. + * + * @param string $resource + * @param array $queries Additional query parameters + * @return array + * + * @throws \Exception + */ + abstract public function getByResource(string $resource, array $queries = []): array; + + /** + * Count logs by resource. + * + * @param string $resource + * @param array $queries Additional query parameters + * @return int + * + * @throws \Exception + */ + abstract public function countByResource(string $resource, array $queries = []): int; + + /** + * Get logs by user and events. + * + * @param string $userId + * @param array $events + * @param array $queries Additional query parameters + * @return array + * + * @throws \Exception + */ + abstract public function getByUserAndEvents(string $userId, array $events, array $queries = []): array; + + /** + * Count logs by user and events. + * + * @param string $userId + * @param array $events + * @param array $queries Additional query parameters + * @return int + * + * @throws \Exception + */ + abstract public function countByUserAndEvents(string $userId, array $events, array $queries = []): int; + + /** + * Get logs by resource and events. + * + * @param string $resource + * @param array $events + * @param array $queries Additional query parameters + * @return array + * + * @throws \Exception + */ + abstract public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array; + + /** + * Count logs by resource and events. + * + * @param string $resource + * @param array $events + * @param array $queries Additional query parameters + * @return int + * + * @throws \Exception + */ + abstract public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int; + + /** + * Delete logs older than the specified datetime. + * + * @param string $datetime ISO 8601 datetime string + * @return bool + * + * @throws \Exception + */ + abstract public function cleanup(string $datetime): bool; +} diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php new file mode 100644 index 0000000..6019628 --- /dev/null +++ b/src/Audit/Adapter/ClickHouse.php @@ -0,0 +1,573 @@ +host = $host; + $this->port = $port; + $this->database = $database; + $this->table = $table; + $this->username = $username; + $this->password = $password; + } + + /** + * Get adapter name. + */ + public function getName(): string + { + return 'ClickHouse'; + } + + /** + * Execute a ClickHouse query via HTTP interface using Fetch Client. + * + * @throws Exception|FetchException + */ + private function query(string $sql, array $params = []): string + { + $url = "http://{$this->host}:{$this->port}/"; + + // Replace parameters in query + foreach ($params as $key => $value) { + if (is_string($value)) { + $value = "'" . addslashes($value) . "'"; + } elseif (is_null($value)) { + $value = 'NULL'; + } elseif (is_bool($value)) { + $value = $value ? '1' : '0'; + } elseif (is_array($value)) { + $value = "'" . addslashes(json_encode($value)) . "'"; + } + $sql = str_replace(":{$key}", (string) $value, $sql); + } + + // Build headers with authentication + $headers = [ + 'X-ClickHouse-User' => $this->username, + 'X-ClickHouse-Key' => $this->password, + 'X-ClickHouse-Database' => $this->database, + ]; + + try { + $response = Client::fetch( + url: $url, + method: Client::METHOD_POST, + headers: $headers, + body: ['query' => $sql], + timeout: 30 + ); + + if ($response->getStatusCode() !== 200) { + throw new Exception("ClickHouse query failed (HTTP {$response->getStatusCode()}): {$response->getBody()}"); + } + + return $response->getBody() ?: ''; + } catch (FetchException $e) { + throw new Exception("ClickHouse connection error: {$e->getMessage()}"); + } + } + + /** + * Setup ClickHouse table structure. + * + * Creates the database and table if they don't exist. + * Uses schema definitions from the base SQL adapter. + * + * @throws Exception + */ + public function setup(): void + { + // Create database if not exists + $createDbSql = "CREATE DATABASE IF NOT EXISTS {$this->database}"; + $this->query($createDbSql); + + // Build column definitions from base adapter schema + $columns = array_merge( + ['id String'], + $this->getAllColumnDefinitions() + ); + + // Build indexes from base adapter schema + $indexes = []; + foreach ($this->getIndexes() as $index) { + $indexName = $index['$id']; + $attributes = $index['attributes']; + $attributeList = implode(', ', $attributes); + $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; + } + + // Create table with MergeTree engine for optimal performance + $createTableSql = " + CREATE TABLE IF NOT EXISTS {$this->database}.{$this->table} ( + " . implode(",\n ", $columns) . ", + " . implode(",\n ", $indexes) . " + ) + ENGINE = MergeTree() + ORDER BY (time, id) + PARTITION BY toYYYYMM(time) + SETTINGS index_granularity = 8192 + "; + + $this->query($createTableSql); + } + + /** + * Create an audit log entry. + * + * @throws Exception + */ + public function create(array $log): Document + { + $id = uniqid('audit_', true); + $time = date('Y-m-d H:i:s.v'); + + $insertSql = " + INSERT INTO {$this->database}.{$this->table} + (id, userId, event, resource, userAgent, ip, location, time, data) + VALUES ( + :id, + :userId, + :event, + :resource, + :userAgent, + :ip, + :location, + :time, + :data + ) + "; + + $params = [ + 'id' => $id, + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'time' => $time, + 'data' => json_encode($log['data'] ?? []), + ]; + + $this->query($insertSql, $params); + + return new Document([ + '$id' => $id, + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'time' => $time, + 'data' => $log['data'] ?? [], + ]); + } + + /** + * Create multiple audit log entries in batch. + * + * @throws Exception + */ + public function createBatch(array $logs): array + { + if (empty($logs)) { + return []; + } + + $values = []; + foreach ($logs as $log) { + $id = uniqid('audit_', true); + $userId = isset($log['userId']) && $log['userId'] !== null + ? "'" . addslashes($log['userId']) . "'" + : 'NULL'; + $location = isset($log['location']) && $log['location'] !== null + ? "'" . addslashes($log['location']) . "'" + : 'NULL'; + + $values[] = sprintf( + "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", + $id, + $userId, + addslashes($log['event']), + addslashes($log['resource']), + addslashes($log['userAgent']), + addslashes($log['ip']), + $location, + $log['timestamp'], + addslashes(json_encode($log['data'] ?? [])) + ); + } + + $insertSql = " + INSERT INTO {$this->database}.{$this->table} + (id, userId, event, resource, userAgent, ip, location, time, data) + VALUES " . implode(', ', $values); + + $this->query($insertSql); + + // Return documents + $documents = []; + foreach ($logs as $log) { + $documents[] = new Document([ + '$id' => uniqid('audit_', true), + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'time' => $log['timestamp'], + 'data' => $log['data'] ?? [], + ]); + } + + return $documents; + } + + /** + * Parse ClickHouse query result into Documents. + */ + private function parseResults(string $result): array + { + if (empty(trim($result))) { + return []; + } + + $lines = explode("\n", trim($result)); + $documents = []; + + foreach ($lines as $line) { + if (empty(trim($line))) { + continue; + } + + $columns = explode("\t", $line); + if (count($columns) < 9) { + continue; + } + + $data = []; + try { + $data = json_decode($columns[8], true) ?? []; + } catch (\Exception $e) { + $data = []; + } + + $documents[] = new Document([ + '$id' => $columns[0], + 'userId' => $columns[1] === '\\N' ? null : $columns[1], + 'event' => $columns[2], + 'resource' => $columns[3], + 'userAgent' => $columns[4], + 'ip' => $columns[5], + 'location' => $columns[6] === '\\N' ? null : $columns[6], + 'time' => $columns[7], + 'data' => $data, + ]); + } + + return $documents; + } + + /** + * Get logs by user ID. + * + * @throws Exception + */ + public function getByUser(string $userId, array $queries = []): array + { + $limit = 25; + $offset = 0; + + // Parse simple limit/offset from queries (simplified version) + foreach ($queries as $query) { + if (is_object($query) && method_exists($query, 'getMethod')) { + if ($query->getMethod() === 'limit') { + $limit = $query->getValue(); + } elseif ($query->getMethod() === 'offset') { + $offset = $query->getValue(); + } + } + } + + $sql = " + SELECT id, userId, event, resource, userAgent, ip, location, time, data + FROM {$this->database}.{$this->table} + WHERE userId = :userId + ORDER BY time DESC + LIMIT :limit OFFSET :offset + FORMAT TabSeparated + "; + + $result = $this->query($sql, [ + 'userId' => $userId, + 'limit' => $limit, + 'offset' => $offset, + ]); + + return $this->parseResults($result); + } + + /** + * Count logs by user ID. + * + * @throws Exception + */ + public function countByUser(string $userId, array $queries = []): int + { + $sql = " + SELECT count() as count + FROM {$this->database}.{$this->table} + WHERE userId = :userId + FORMAT TabSeparated + "; + + $result = $this->query($sql, ['userId' => $userId]); + + return (int) trim($result); + } + + /** + * Get logs by resource. + * + * @throws Exception + */ + public function getByResource(string $resource, array $queries = []): array + { + $limit = 25; + $offset = 0; + + foreach ($queries as $query) { + if (is_object($query) && method_exists($query, 'getMethod')) { + if ($query->getMethod() === 'limit') { + $limit = $query->getValue(); + } elseif ($query->getMethod() === 'offset') { + $offset = $query->getValue(); + } + } + } + + $sql = " + SELECT id, userId, event, resource, userAgent, ip, location, time, data + FROM {$this->database}.{$this->table} + WHERE resource = :resource + ORDER BY time DESC + LIMIT :limit OFFSET :offset + FORMAT TabSeparated + "; + + $result = $this->query($sql, [ + 'resource' => $resource, + 'limit' => $limit, + 'offset' => $offset, + ]); + + return $this->parseResults($result); + } + + /** + * Count logs by resource. + * + * @throws Exception + */ + public function countByResource(string $resource, array $queries = []): int + { + $sql = " + SELECT count() as count + FROM {$this->database}.{$this->table} + WHERE resource = :resource + FORMAT TabSeparated + "; + + $result = $this->query($sql, ['resource' => $resource]); + + return (int) trim($result); + } + + /** + * Get logs by user and events. + * + * @throws Exception + */ + public function getByUserAndEvents(string $userId, array $events, array $queries = []): array + { + $limit = 25; + $offset = 0; + + foreach ($queries as $query) { + if (is_object($query) && method_exists($query, 'getMethod')) { + if ($query->getMethod() === 'limit') { + $limit = $query->getValue(); + } elseif ($query->getMethod() === 'offset') { + $offset = $query->getValue(); + } + } + } + + $eventsList = implode("', '", array_map('addslashes', $events)); + + $sql = " + SELECT id, userId, event, resource, userAgent, ip, location, time, data + FROM {$this->database}.{$this->table} + WHERE userId = :userId AND event IN ('{$eventsList}') + ORDER BY time DESC + LIMIT :limit OFFSET :offset + FORMAT TabSeparated + "; + + $result = $this->query($sql, [ + 'userId' => $userId, + 'limit' => $limit, + 'offset' => $offset, + ]); + + return $this->parseResults($result); + } + + /** + * Count logs by user and events. + * + * @throws Exception + */ + public function countByUserAndEvents(string $userId, array $events, array $queries = []): int + { + $eventsList = implode("', '", array_map('addslashes', $events)); + + $sql = " + SELECT count() as count + FROM {$this->database}.{$this->table} + WHERE userId = :userId AND event IN ('{$eventsList}') + FORMAT TabSeparated + "; + + $result = $this->query($sql, ['userId' => $userId]); + + return (int) trim($result); + } + + /** + * Get logs by resource and events. + * + * @throws Exception + */ + public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array + { + $limit = 25; + $offset = 0; + + foreach ($queries as $query) { + if (is_object($query) && method_exists($query, 'getMethod')) { + if ($query->getMethod() === 'limit') { + $limit = $query->getValue(); + } elseif ($query->getMethod() === 'offset') { + $offset = $query->getValue(); + } + } + } + + $eventsList = implode("', '", array_map('addslashes', $events)); + + $sql = " + SELECT id, userId, event, resource, userAgent, ip, location, time, data + FROM {$this->database}.{$this->table} + WHERE resource = :resource AND event IN ('{$eventsList}') + ORDER BY time DESC + LIMIT :limit OFFSET :offset + FORMAT TabSeparated + "; + + $result = $this->query($sql, [ + 'resource' => $resource, + 'limit' => $limit, + 'offset' => $offset, + ]); + + return $this->parseResults($result); + } + + /** + * Count logs by resource and events. + * + * @throws Exception + */ + public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int + { + $eventsList = implode("', '", array_map('addslashes', $events)); + + $sql = " + SELECT count() as count + FROM {$this->database}.{$this->table} + WHERE resource = :resource AND event IN ('{$eventsList}') + FORMAT TabSeparated + "; + + $result = $this->query($sql, ['resource' => $resource]); + + return (int) trim($result); + } + + /** + * Delete logs older than the specified datetime. + * + * ClickHouse uses a different approach for deletions - we use ALTER TABLE DELETE. + * + * @throws Exception + */ + public function cleanup(string $datetime): bool + { + $sql = " + ALTER TABLE {$this->database}.{$this->table} + DELETE WHERE time < :datetime + "; + + $this->query($sql, ['datetime' => $datetime]); + + return true; + } +} diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php new file mode 100644 index 0000000..e70cc5a --- /dev/null +++ b/src/Audit/Adapter/Database.php @@ -0,0 +1,317 @@ +db = $db; + } + + /** + * Get adapter name. + */ + public function getName(): string + { + return 'Database'; + } + + /** + * Setup database structure. + * + * @return void + * @throws Exception|\Exception + */ + public function setup(): void + { + if (! $this->db->exists($this->db->getDatabase())) { + throw new Exception('You need to create the database before running Audit setup'); + } + + $attributes = $this->getAttributeDocuments(); + $indexes = $this->getIndexDocuments(); + + try { + $this->db->createCollection( + $this->getCollectionName(), + $attributes, + $indexes + ); + } catch (DuplicateException) { + // Collection already exists + } + } + + /** + * Create an audit log entry. + * + * @param array $log + * @return Document + * @throws AuthorizationException|\Exception + */ + public function create(array $log): Document + { + return $this->db->getAuthorization()->skip(function () use ($log) { + return $this->db->createDocument($this->getCollectionName(), new Document([ + '$permissions' => [], + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'data' => $log['data'] ?? [], + 'time' => DateTime::now(), + ])); + }); + } + + /** + * Create multiple audit log entries in batch. + * + * @param array $logs + * @return array + * @throws AuthorizationException|\Exception + */ + public function createBatch(array $logs): array + { + $created = []; + $this->db->getAuthorization()->skip(function () use ($logs, &$created) { + foreach ($logs as $log) { + $created[] = $this->db->createDocument($this->getCollectionName(), new Document([ + '$permissions' => [], + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'data' => $log['data'] ?? [], + 'time' => $log['timestamp'], + ])); + } + }); + + return $created; + } + + /** + * Get logs by user ID. + * + * @param string $userId + * @param array $queries + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByUser(string $userId, array $queries = []): array + { + return $this->db->getAuthorization()->skip(function () use ($userId, $queries) { + $queries[] = Query::equal('userId', [$userId]); + $queries[] = Query::orderDesc(); + + return $this->db->find( + collection: $this->getCollectionName(), + queries: $queries, + ); + }); + } + + /** + * Count logs by user ID. + * + * @param string $userId + * @param array $queries + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByUser(string $userId, array $queries = []): int + { + return $this->db->getAuthorization()->skip(function () use ($userId, $queries) { + return $this->db->count( + collection: $this->getCollectionName(), + queries: [ + Query::equal('userId', [$userId]), + ...$queries, + ] + ); + }); + } + + /** + * Get logs by resource. + * + * @param string $resource + * @param array $queries + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByResource(string $resource, array $queries = []): array + { + return $this->db->getAuthorization()->skip(function () use ($resource, $queries) { + $queries[] = Query::equal('resource', [$resource]); + $queries[] = Query::orderDesc(); + + return $this->db->find( + collection: $this->getCollectionName(), + queries: $queries, + ); + }); + } + + /** + * Count logs by resource. + * + * @param string $resource + * @param array $queries + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByResource(string $resource, array $queries = []): int + { + return $this->db->getAuthorization()->skip(function () use ($resource, $queries) { + return $this->db->count( + collection: $this->getCollectionName(), + queries: [ + Query::equal('resource', [$resource]), + ...$queries, + ] + ); + }); + } + + /** + * Get logs by user and events. + * + * @param string $userId + * @param array $events + * @param array $queries + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByUserAndEvents(string $userId, array $events, array $queries = []): array + { + return $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { + $queries[] = Query::equal('userId', [$userId]); + $queries[] = Query::equal('event', $events); + $queries[] = Query::orderDesc(); + + return $this->db->find( + collection: $this->getCollectionName(), + queries: $queries, + ); + }); + } + + /** + * Count logs by user and events. + * + * @param string $userId + * @param array $events + * @param array $queries + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByUserAndEvents(string $userId, array $events, array $queries = []): int + { + return $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { + return $this->db->count( + collection: $this->getCollectionName(), + queries: [ + Query::equal('userId', [$userId]), + Query::equal('event', $events), + ...$queries, + ] + ); + }); + } + + /** + * Get logs by resource and events. + * + * @param string $resource + * @param array $events + * @param array $queries + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array + { + return $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { + $queries[] = Query::equal('resource', [$resource]); + $queries[] = Query::equal('event', $events); + $queries[] = Query::orderDesc(); + + return $this->db->find( + collection: $this->getCollectionName(), + queries: $queries, + ); + }); + } + + /** + * Count logs by resource and events. + * + * @param string $resource + * @param array $events + * @param array $queries + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int + { + return $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { + return $this->db->count( + collection: $this->getCollectionName(), + queries: [ + Query::equal('resource', [$resource]), + Query::equal('event', $events), + ...$queries, + ] + ); + }); + } + + /** + * Delete logs older than the specified datetime. + * + * @param string $datetime + * @return bool + * @throws AuthorizationException|\Exception + */ + public function cleanup(string $datetime): bool + { + $this->db->getAuthorization()->skip(function () use ($datetime) { + do { + $documents = $this->db->find( + collection: $this->getCollectionName(), + queries: [ + Query::lessThan('time', $datetime), + ] + ); + + foreach ($documents as $document) { + $this->db->deleteDocument($this->getCollectionName(), $document->getId()); + } + } while (! empty($documents)); + }); + + return true; + } +} diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php new file mode 100644 index 0000000..69b93b9 --- /dev/null +++ b/src/Audit/Adapter/SQL.php @@ -0,0 +1,220 @@ +> + */ + protected function getAttributes(): array + { + return [ + [ + '$id' => 'userId', + 'type' => Database::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'event', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'resource', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'userAgent', + 'type' => Database::VAR_STRING, + 'size' => 65534, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'ip', + 'type' => Database::VAR_STRING, + 'size' => 45, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'location', + 'type' => Database::VAR_STRING, + 'size' => 45, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'time', + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => 'data', + 'type' => Database::VAR_STRING, + 'size' => 16777216, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['json'], + ], + ]; + } + + /** + * Get attribute documents for audit logs. + * + * @return array + */ + protected function getAttributeDocuments(): array + { + return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes()); + } + + /** + * Get index definitions for audit logs. + * + * @return array> + */ + protected function getIndexes(): array + { + return [ + [ + '$id' => 'idx_event', + 'type' => 'key', + 'attributes' => ['event'], + ], + [ + '$id' => 'idx_userId_event', + 'type' => 'key', + 'attributes' => ['userId', 'event'], + ], + [ + '$id' => 'idx_resource_event', + 'type' => 'key', + 'attributes' => ['resource', 'event'], + ], + [ + '$id' => 'idx_time_desc', + 'type' => 'key', + 'attributes' => ['time'], + ], + ]; + } + + /** + * Get index documents for audit logs. + * + * @return array + */ + protected function getIndexDocuments(): array + { + return array_map(static fn (array $index) => new Document($index), $this->getIndexes()); + } + + /** + * Get a single attribute by ID. + * + * @param string $id + * @return array|null + */ + protected function getAttribute(string $id) + { + foreach ($this->getAttributes() as $attribute) { + if ($attribute['$id'] === $id) { + return $attribute; + } + } + + return null; + } + + /** + * Get SQL column definition for a given attribute ID. + * + * @param string $id + * @return string + */ + protected function getColumnDefinition(string $id): string + { + $attribute = $this->getAttribute($id); + + if (! $attribute) { + throw new \Exception("Attribute {$id} not found"); + } + + $type = match ($id) { + 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String', + 'time' => 'DateTime64(3)', + default => 'String', + }; + + $nullable = ! $attribute['required'] ? 'Nullable(' . $type . ')' : $type; + + return "{$id} {$nullable}"; + } + + /** + * Get all SQL column definitions. + * + * @return array + */ + protected function getAllColumnDefinitions(): array + { + $definitions = []; + foreach ($this->getAttributes() as $attribute) { + $definitions[] = $this->getColumnDefinition($attribute['$id']); + } + + return $definitions; + } +} diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index daf4c2e..4afc06f 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -3,489 +3,257 @@ namespace Utopia\Audit; use Utopia\Database\Database; -use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Exception\Timeout; -use Utopia\Database\Query; -use Utopia\Exception; +/** + * Audit Log Manager + * + * This class manages audit logs using pluggable adapters. + * The default adapter is the Database adapter which stores logs in utopia-php/database. + * Custom adapters can be created by extending the Adapter abstract class. + */ class Audit { - public const COLLECTION = 'audit'; + private Adapter $adapter; - public const ATTRIBUTES = [ - [ - '$id' => 'userId', - 'type' => Database::VAR_STRING, - 'size' => Database::LENGTH_KEY, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'event', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'resource', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'userAgent', - 'type' => Database::VAR_STRING, - 'size' => 65534, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'ip', - 'type' => Database::VAR_STRING, - 'size' => 45, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'location', - 'type' => Database::VAR_STRING, - 'size' => 45, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], [ - '$id' => 'time', - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'array' => false, - 'filters' => ['datetime'], - ], [ - '$id' => 'data', - 'type' => Database::VAR_STRING, - 'size' => 16777216, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => ['json'], - ], - ]; + /** + * Create a new Audit instance with the default Database adapter. + * + * @param Database $db The database instance + * @return self + */ + public static function withDatabase(Database $db): self + { + return new self(new Adapter\Database($db)); + } - public const INDEXES = [ - [ - '$id' => 'index2', - 'type' => Database::INDEX_KEY, - 'attributes' => ['event'], - 'lengths' => [], - 'orders' => [], - ], [ - '$id' => 'index4', - 'type' => Database::INDEX_KEY, - 'attributes' => ['userId', 'event'], - 'lengths' => [], - 'orders' => [], - ], [ - '$id' => 'index5', - 'type' => Database::INDEX_KEY, - 'attributes' => ['resource', 'event'], - 'lengths' => [], - 'orders' => [], - ], [ - '$id' => 'index-time', - 'type' => Database::INDEX_KEY, - 'attributes' => ['time'], - 'lengths' => [], - 'orders' => [Database::ORDER_DESC], - ], - ]; + /** + * Create a new Audit instance with a custom adapter. + * + * @param Adapter $adapter The adapter to use + * @return self + */ + public static function withAdapter(Adapter $adapter): self + { + return new self($adapter); + } - private Database $db; + /** + * Constructor. + * + * @param Adapter $adapter The adapter to use for storing audit logs + */ + public function __construct(Adapter $adapter) + { + $this->adapter = $adapter; + } - public function __construct(Database $db) + /** + * Get the current adapter. + * + * @return Adapter + */ + public function getAdapter(): Adapter { - $this->db = $db; + return $this->adapter; } /** - * Setup database structure. + * Setup the audit log storage. * * @return void - * - * @throws DuplicateException * @throws \Exception */ public function setup(): void { - if (! $this->db->exists($this->db->getDatabase())) { - throw new Exception('You need to create the database before running Audit setup'); - } - - $attributes = \array_map(function ($attribute) { - return new Document($attribute); - }, self::ATTRIBUTES); - - $indexes = \array_map(function ($index) { - return new Document($index); - }, self::INDEXES); - - try { - $this->db->createCollection( - Audit::COLLECTION, - $attributes, - $indexes - ); - } catch (DuplicateException) { - // Collection already exists - } + $this->adapter->setup(); } /** * Add event log. * - * @param string|null $userId - * @param string $event - * @param string $resource - * @param string $userAgent - * @param string $ip - * @param string $location - * @param array $data - * @return bool + * @param string|null $userId + * @param string $event + * @param string $resource + * @param string $userAgent + * @param string $ip + * @param string $location + * @param array $data + * @return Document * - * @throws AuthorizationException - * @throws StructureException * @throws \Exception - * @throws \Throwable */ - public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): bool + public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Document { - $this->db->getAuthorization()->skip(function () use ($userId, $event, $resource, $userAgent, $ip, $location, $data) { - $this->db->createDocument(Audit::COLLECTION, new Document([ - '$permissions' => [], - 'userId' => $userId, - 'event' => $event, - 'resource' => $resource, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'location' => $location, - 'data' => $data, - 'time' => DateTime::now(), - ])); - }); - - return true; + return $this->adapter->create([ + 'userId' => $userId, + 'event' => $event, + 'resource' => $resource, + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => $data, + ]); } - /** * Add multiple event logs in batch. * - * @param array}> $events - * @return bool + * @param array}> $events + * @return array * - * @throws AuthorizationException - * @throws StructureException * @throws \Exception - * @throws \Throwable */ - public function logBatch(array $events): bool + public function logBatch(array $events): array { - $this->db->getAuthorization()->skip(function () use ($events) { - $documents = \array_map(function ($event) { - return new Document([ - '$permissions' => [], - 'userId' => $event['userId'], - 'event' => $event['event'], - 'resource' => $event['resource'], - 'userAgent' => $event['userAgent'], - 'ip' => $event['ip'], - 'location' => $event['location'], - 'data' => $event['data'] ?? [], - 'time' => $event['timestamp'], - ]); - }, $events); - - $this->db->createDocuments(Audit::COLLECTION, $documents); - }); - - return true; + return $this->adapter->createBatch($events); } /** * Get all logs by user ID. * * @param string $userId - * @param array $queries + * @param array $queries * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByUser( string $userId, array $queries = [] ): array { - /** @var array $result */ - $result = $this->db->getAuthorization()->skip(function () use ($queries, $userId) { - $queries[] = Query::equal('userId', [$userId]); - $queries[] = Query::orderDesc(); - - return $this->db->find( - collection: Audit::COLLECTION, - queries: $queries, - ); - }); - - return $result; + return $this->adapter->getByUser($userId, $queries); } /** * Count logs by user ID. * * @param string $userId - * @param array $queries + * @param array $queries * @return int - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByUser( string $userId, array $queries = [] ): int { - /** @var int $count */ - $count = $this->db->getAuthorization()->skip(function () use ($queries, $userId) { - return $this->db->count( - collection: Audit::COLLECTION, - queries: [ - Query::equal('userId', [$userId]), - ...$queries, - ] - ); - }); - - return $count; + return $this->adapter->countByUser($userId, $queries); } /** * Get all logs by resource. * * @param string $resource - * @param array $queries + * @param array $queries * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByResource( string $resource, array $queries = [], ): array { - /** @var array $result */ - $result = $this->db->getAuthorization()->skip(function () use ($queries, $resource) { - $queries[] = Query::equal('resource', [$resource]); - $queries[] = Query::orderDesc(); - - return $this->db->find( - collection: Audit::COLLECTION, - queries: $queries, - ); - }); - - return $result; + return $this->adapter->getByResource($resource, $queries); } /** * Count logs by resource. * * @param string $resource - * @param array $queries + * @param array $queries * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByResource( string $resource, array $queries = [] ): int { - /** @var int $count */ - $count = $this->db->getAuthorization()->skip(function () use ($resource, $queries) { - return $this->db->count( - collection: Audit::COLLECTION, - queries: [ - Query::equal('resource', [$resource]), - ...$queries, - ] - ); - }); - - return $count; + return $this->adapter->countByResource($resource, $queries); } /** * Get logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByUserAndEvents( string $userId, array $events, array $queries = [], ): array { - /** @var array $result */ - $result = $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { - $queries[] = Query::equal('userId', [$userId]); - $queries[] = Query::equal('event', $events); - $queries[] = Query::orderDesc(); - - return $this->db->find( - collection: Audit::COLLECTION, - queries: $queries, - ); - }); - - return $result; + return $this->adapter->getByUserAndEvents($userId, $events, $queries); } /** * Count logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByUserAndEvents( string $userId, array $events, array $queries = [], ): int { - /** @var int $count */ - $count = $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { - return $this->db->count( - collection: Audit::COLLECTION, - queries: [ - Query::equal('userId', [$userId]), - Query::equal('event', $events), - ...$queries, - ] - ); - }); - - return $count; + return $this->adapter->countByUserAndEvents($userId, $events, $queries); } /** * Get logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByResourceAndEvents( string $resource, array $events, array $queries = [], ): array { - /** @var array $result */ - $result = $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { - $queries[] = Query::equal('resource', [$resource]); - $queries[] = Query::equal('event', $events); - $queries[] = Query::orderDesc(); - - return $this->db->find( - collection: Audit::COLLECTION, - queries: $queries, - ); - }); - - return $result; + return $this->adapter->getByResourceAndEvents($resource, $events, $queries); } /** * Count logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByResourceAndEvents( string $resource, array $events, array $queries = [], ): int { - /** @var int $count */ - $count = $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { - return $this->db->count( - collection: Audit::COLLECTION, - queries: [ - Query::equal('resource', [$resource]), - Query::equal('event', $events), - ...$queries, - ] - ); - }); - - return $count; + return $this->adapter->countByResourceAndEvents($resource, $events, $queries); } /** - * Delete all logs older than `$timestamp` seconds + * Delete all logs older than `$datetime` seconds * - * @param string $datetime + * @param string $datetime * @return bool * - * @throws AuthorizationException * @throws \Exception */ public function cleanup(string $datetime): bool { - $this->db->getAuthorization()->skip(function () use ($datetime) { - do { - $documents = $this->db->find( - collection: Audit::COLLECTION, - queries: [ - Query::lessThan('time', $datetime), - ] - ); - - foreach ($documents as $document) { - $this->db->deleteDocument(Audit::COLLECTION, $document->getId()); - } - } while (! empty($documents)); - }); - - return true; + return $this->adapter->cleanup($datetime); } - } diff --git a/tests/Audit/AuditTest.php b/tests/Audit/AuditTest.php index 9c78783..a11817f 100644 --- a/tests/Audit/AuditTest.php +++ b/tests/Audit/AuditTest.php @@ -29,7 +29,7 @@ public function setUp(): void $database->setDatabase('utopiaTests'); $database->setNamespace('namespace'); - $this->audit = new Audit($database); + $this->audit = Audit::withDatabase($database); if (! $database->exists('utopiaTests')) { $database->create(); $this->audit->setup(); @@ -51,10 +51,10 @@ public function createLogs(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertTrue($this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); - $this->assertTrue($this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertTrue($this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertTrue($this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); } public function testGetLogsByUser(): void @@ -211,7 +211,9 @@ public function testLogByBatch(): void ]; // Test batch insertion - $this->assertTrue($this->audit->logBatch($batchEvents)); + $result = $this->audit->logBatch($batchEvents); + $this->assertIsArray($result); + $this->assertEquals(4, count($result)); // Verify the number of logs inserted $logs = $this->audit->getLogsByUser($userId); @@ -272,11 +274,11 @@ public function testCleanup(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertEquals($this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data), true); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); sleep(5); - $this->assertEquals($this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data), true); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); sleep(5); - $this->assertEquals($this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data), true); + $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); sleep(5); // DELETE logs older than 11 seconds and check that status is true From 74af397179474d7eabe4b93ec77114d42589e4c1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:21:17 +0000 Subject: [PATCH 02/50] improvement with tenants --- README.md | 50 +++++ src/Audit/Adapter/ClickHouse.php | 342 ++++++++++++++++++++++++------- 2 files changed, 318 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 31b37ab..4ae7a66 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,56 @@ The Database adapter uses [utopia-php/database](https://github.com/utopia-php/da - MongoDB - And all other databases supported by utopia-php/database +### ClickHouse Adapter + +The ClickHouse adapter uses [ClickHouse](https://clickhouse.com/) for high-performance analytical queries on massive amounts of log data. It communicates with ClickHouse via HTTP interface using cURL. + +**Features:** +- Optimized for analytical queries and aggregations +- Handles billions of log entries efficiently +- Column-oriented storage for fast queries +- Automatic partitioning by month +- Bloom filter indexes for fast lookups + +**Usage:** + +```php +setup(); // Creates database and table + +// Use as normal +$document = $audit->log( + userId: 'user-123', + event: 'document.create', + resource: 'database/document/1', + userAgent: 'Mozilla/5.0...', + ip: '127.0.0.1', + location: 'US', + data: ['key' => 'value'] +); +``` + +**Performance Benefits:** +- Ideal for high-volume logging (millions of events per day) +- Fast aggregation queries (counts, analytics) +- Efficient storage with compression +- Automatic data partitioning and retention policies + ### Creating Custom Adapters To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and implement all required methods: diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 6019628..cf876ba 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -5,7 +5,6 @@ use Exception; use Utopia\Database\Document; use Utopia\Fetch\Client; -use Utopia\Fetch\FetchException; /** * ClickHouse Adapter for Audit @@ -31,6 +30,12 @@ class ClickHouse extends SQL private string $password; + protected string $namespace = ''; + + protected ?int $tenant = null; + + protected bool $sharedTables = false; + /** * @param string $host ClickHouse host * @param string $database ClickHouse database name @@ -63,6 +68,92 @@ public function getName(): string return 'ClickHouse'; } + /** + * Set the namespace for multi-project support. + * Namespace is used as a prefix for table names. + * + * @param string $namespace + * @return self + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Get the namespace. + * + * @return string + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * Set the tenant ID for multi-tenant support. + * Tenant is used to isolate audit logs by tenant. + * + * @param int|null $tenant + * @return self + */ + public function setTenant(?int $tenant): self + { + $this->tenant = $tenant; + return $this; + } + + /** + * Get the tenant ID. + * + * @return int|null + */ + public function getTenant(): ?int + { + return $this->tenant; + } + + /** + * Set whether tables are shared across tenants. + * When enabled, a tenant column is added to the table for data isolation. + * + * @param bool $sharedTables + * @return self + */ + public function setSharedTables(bool $sharedTables): self + { + $this->sharedTables = $sharedTables; + return $this; + } + + /** + * Get whether tables are shared across tenants. + * + * @return bool + */ + public function isSharedTables(): bool + { + return $this->sharedTables; + } + + /** + * Get the table name with namespace prefix. + * Namespace is used to isolate tables for different projects/applications. + * + * @return string + */ + private function getTableName(): string + { + $tableName = $this->table; + + if (!empty($this->namespace)) { + $tableName = $this->namespace . '_' . $tableName; + } + + return $tableName; + } + /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * @@ -87,19 +178,17 @@ private function query(string $sql, array $params = []): string } // Build headers with authentication - $headers = [ - 'X-ClickHouse-User' => $this->username, - 'X-ClickHouse-Key' => $this->password, - 'X-ClickHouse-Database' => $this->database, - ]; + $client = new Client(); + $client->addHeader('X-ClickHouse-User', $this->username); + $client->addHeader('X-ClickHouse-Key', $this->password); + $client->addHeader('X-ClickHouse-Database', $this->database); + $client->setTimeout(30); try { - $response = Client::fetch( + $response = $client->fetch( url: $url, method: Client::METHOD_POST, - headers: $headers, - body: ['query' => $sql], - timeout: 30 + body: ['query' => $sql] ); if ($response->getStatusCode() !== 200) { @@ -107,7 +196,7 @@ private function query(string $sql, array $params = []): string } return $response->getBody() ?: ''; - } catch (FetchException $e) { + } catch (Exception $e) { throw new Exception("ClickHouse connection error: {$e->getMessage()}"); } } @@ -127,10 +216,15 @@ public function setup(): void $this->query($createDbSql); // Build column definitions from base adapter schema - $columns = array_merge( - ['id String'], - $this->getAllColumnDefinitions() - ); + $columns = [ + 'id String', + ...$this->getAllColumnDefinitions(), + ]; + + // Add tenant column only if tables are shared across tenants + if ($this->sharedTables) { + $columns[] = 'tenant Nullable(UInt64)'; // Supports 11-digit MySQL auto-increment IDs + } // Build indexes from base adapter schema $indexes = []; @@ -141,9 +235,11 @@ public function setup(): void $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; } + $tableName = $this->getTableName(); + // Create table with MergeTree engine for optimal performance $createTableSql = " - CREATE TABLE IF NOT EXISTS {$this->database}.{$this->table} ( + CREATE TABLE IF NOT EXISTS {$this->database}.{$tableName} ( " . implode(",\n ", $columns) . ", " . implode(",\n ", $indexes) . " ) @@ -166,21 +262,11 @@ public function create(array $log): Document $id = uniqid('audit_', true); $time = date('Y-m-d H:i:s.v'); - $insertSql = " - INSERT INTO {$this->database}.{$this->table} - (id, userId, event, resource, userAgent, ip, location, time, data) - VALUES ( - :id, - :userId, - :event, - :resource, - :userAgent, - :ip, - :location, - :time, - :data - ) - "; + $tableName = $this->getTableName(); + + // Build column list and values based on sharedTables setting + $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; + $placeholders = [':id', ':userId', ':event', ':resource', ':userAgent', ':ip', ':location', ':time', ':data']; $params = [ 'id' => $id, @@ -194,9 +280,23 @@ public function create(array $log): Document 'data' => json_encode($log['data'] ?? []), ]; + if ($this->sharedTables) { + $columns[] = 'tenant'; + $placeholders[] = ':tenant'; + $params['tenant'] = $this->tenant; + } + + $insertSql = " + INSERT INTO {$this->database}.{$tableName} + (" . implode(', ', $columns) . ") + VALUES ( + " . implode(", ", $placeholders) . " + ) + "; + $this->query($insertSql, $params); - return new Document([ + $result = [ '$id' => $id, 'userId' => $log['userId'] ?? null, 'event' => $log['event'], @@ -206,7 +306,13 @@ public function create(array $log): Document 'location' => $log['location'] ?? null, 'time' => $time, 'data' => $log['data'] ?? [], - ]); + ]; + + if ($this->sharedTables) { + $result['tenant'] = $this->tenant; + } + + return new Document($result); } /** @@ -230,23 +336,48 @@ public function createBatch(array $logs): array ? "'" . addslashes($log['location']) . "'" : 'NULL'; - $values[] = sprintf( - "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", - $id, - $userId, - addslashes($log['event']), - addslashes($log['resource']), - addslashes($log['userAgent']), - addslashes($log['ip']), - $location, - $log['timestamp'], - addslashes(json_encode($log['data'] ?? [])) - ); + if ($this->sharedTables) { + $tenant = $this->tenant !== null ? (int) $this->tenant : 'NULL'; + $values[] = sprintf( + "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s', %s)", + $id, + $userId, + addslashes($log['event']), + addslashes($log['resource']), + addslashes($log['userAgent']), + addslashes($log['ip']), + $location, + $log['timestamp'], + addslashes(json_encode($log['data'] ?? [])), + $tenant + ); + } else { + $values[] = sprintf( + "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", + $id, + $userId, + addslashes($log['event']), + addslashes($log['resource']), + addslashes($log['userAgent']), + addslashes($log['ip']), + $location, + $log['timestamp'], + addslashes(json_encode($log['data'] ?? [])) + ); + } + } + + $tableName = $this->getTableName(); + + // Build column list based on sharedTables setting + $columns = 'id, userId, event, resource, userAgent, ip, location, time, data'; + if ($this->sharedTables) { + $columns .= ', tenant'; } $insertSql = " - INSERT INTO {$this->database}.{$this->table} - (id, userId, event, resource, userAgent, ip, location, time, data) + INSERT INTO {$this->database}.{$tableName} + ({$columns}) VALUES " . implode(', ', $values); $this->query($insertSql); @@ -254,7 +385,7 @@ public function createBatch(array $logs): array // Return documents $documents = []; foreach ($logs as $log) { - $documents[] = new Document([ + $result = [ '$id' => uniqid('audit_', true), 'userId' => $log['userId'] ?? null, 'event' => $log['event'], @@ -264,7 +395,13 @@ public function createBatch(array $logs): array 'location' => $log['location'] ?? null, 'time' => $log['timestamp'], 'data' => $log['data'] ?? [], - ]); + ]; + + if ($this->sharedTables) { + $result['tenant'] = $this->tenant; + } + + $documents[] = new Document($result); } return $documents; @@ -288,7 +425,7 @@ private function parseResults(string $result): array } $columns = explode("\t", $line); - if (count($columns) < 9) { + if (count($columns) < 10) { continue; } @@ -299,7 +436,7 @@ private function parseResults(string $result): array $data = []; } - $documents[] = new Document([ + $document = [ '$id' => $columns[0], 'userId' => $columns[1] === '\\N' ? null : $columns[1], 'event' => $columns[2], @@ -309,12 +446,46 @@ private function parseResults(string $result): array 'location' => $columns[6] === '\\N' ? null : $columns[6], 'time' => $columns[7], 'data' => $data, - ]); + ]; + + // Add tenant only if sharedTables is enabled + if ($this->sharedTables && isset($columns[9])) { + $document['tenant'] = $columns[9] === '\\N' ? null : (int) $columns[9]; + } + + $documents[] = new Document($document); } return $documents; } + /** + * Get the SELECT column list for queries. + * Returns 9 columns if not using shared tables, 10 if using shared tables. + * + * @return string + */ + private function getSelectColumns(): string + { + if ($this->sharedTables) { + return 'id, userId, event, resource, userAgent, ip, location, time, data, tenant'; + } + return 'id, userId, event, resource, userAgent, ip, location, time, data'; + } + + /** + * Build tenant filter clause based on current tenant context. + * + * @return string + */ + private function getTenantFilter(): string + { + if (!$this->sharedTables || $this->tenant === null) { + return ''; + } + + return " AND tenant = {$this->tenant}"; + } /** * Get logs by user ID. * @@ -336,10 +507,13 @@ public function getByUser(string $userId, array $queries = []): array } } + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $sql = " - SELECT id, userId, event, resource, userAgent, ip, location, time, data - FROM {$this->database}.{$this->table} - WHERE userId = :userId + SELECT " . $this->getSelectColumns() . " + FROM {$this->database}.{$tableName} + WHERE userId = :userId{$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset FORMAT TabSeparated @@ -361,10 +535,13 @@ public function getByUser(string $userId, array $queries = []): array */ public function countByUser(string $userId, array $queries = []): int { + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $sql = " SELECT count() as count - FROM {$this->database}.{$this->table} - WHERE userId = :userId + FROM {$this->database}.{$tableName} + WHERE userId = :userId{$tenantFilter} FORMAT TabSeparated "; @@ -393,10 +570,13 @@ public function getByResource(string $resource, array $queries = []): array } } + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $sql = " - SELECT id, userId, event, resource, userAgent, ip, location, time, data - FROM {$this->database}.{$this->table} - WHERE resource = :resource + SELECT " . $this->getSelectColumns() . " + FROM {$this->database}.{$tableName} + WHERE resource = :resource{$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset FORMAT TabSeparated @@ -418,10 +598,13 @@ public function getByResource(string $resource, array $queries = []): array */ public function countByResource(string $resource, array $queries = []): int { + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $sql = " SELECT count() as count - FROM {$this->database}.{$this->table} - WHERE resource = :resource + FROM {$this->database}.{$tableName} + WHERE resource = :resource{$tenantFilter} FORMAT TabSeparated "; @@ -451,11 +634,13 @@ public function getByUserAndEvents(string $userId, array $events, array $queries } $eventsList = implode("', '", array_map('addslashes', $events)); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); $sql = " - SELECT id, userId, event, resource, userAgent, ip, location, time, data - FROM {$this->database}.{$this->table} - WHERE userId = :userId AND event IN ('{$eventsList}') + SELECT " . $this->getSelectColumns() . " + FROM {$this->database}.{$tableName} + WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset FORMAT TabSeparated @@ -478,11 +663,13 @@ public function getByUserAndEvents(string $userId, array $events, array $queries public function countByUserAndEvents(string $userId, array $events, array $queries = []): int { $eventsList = implode("', '", array_map('addslashes', $events)); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); $sql = " SELECT count() as count - FROM {$this->database}.{$this->table} - WHERE userId = :userId AND event IN ('{$eventsList}') + FROM {$this->database}.{$tableName} + WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} FORMAT TabSeparated "; @@ -512,11 +699,13 @@ public function getByResourceAndEvents(string $resource, array $events, array $q } $eventsList = implode("', '", array_map('addslashes', $events)); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); $sql = " - SELECT id, userId, event, resource, userAgent, ip, location, time, data - FROM {$this->database}.{$this->table} - WHERE resource = :resource AND event IN ('{$eventsList}') + SELECT " . $this->getSelectColumns() . " + FROM {$this->database}.{$tableName} + WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset FORMAT TabSeparated @@ -539,11 +728,13 @@ public function getByResourceAndEvents(string $resource, array $events, array $q public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int { $eventsList = implode("', '", array_map('addslashes', $events)); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); $sql = " SELECT count() as count - FROM {$this->database}.{$this->table} - WHERE resource = :resource AND event IN ('{$eventsList}') + FROM {$this->database}.{$tableName} + WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} FORMAT TabSeparated "; @@ -561,9 +752,12 @@ public function countByResourceAndEvents(string $resource, array $events, array */ public function cleanup(string $datetime): bool { + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $sql = " - ALTER TABLE {$this->database}.{$this->table} - DELETE WHERE time < :datetime + ALTER TABLE {$this->database}.{$tableName} + DELETE WHERE time < :datetime{$tenantFilter} "; $this->query($sql, ['datetime' => $datetime]); From 52303ecd3a859a8ea0323b1a60f87a248e2df794 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:30:08 +0000 Subject: [PATCH 03/50] Refactor and fix test --- src/Audit/Audit.php | 22 --------- tests/Audit/{AuditTest.php => AuditBase.php} | 47 ++++++++++---------- tests/Audit/AuditClickHouseTest.php | 33 ++++++++++++++ tests/Audit/AuditDatabaseTest.php | 43 ++++++++++++++++++ 4 files changed, 99 insertions(+), 46 deletions(-) rename tests/Audit/{AuditTest.php => AuditBase.php} (93%) create mode 100644 tests/Audit/AuditClickHouseTest.php create mode 100644 tests/Audit/AuditDatabaseTest.php diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 4afc06f..4058036 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -16,28 +16,6 @@ class Audit { private Adapter $adapter; - /** - * Create a new Audit instance with the default Database adapter. - * - * @param Database $db The database instance - * @return self - */ - public static function withDatabase(Database $db): self - { - return new self(new Adapter\Database($db)); - } - - /** - * Create a new Audit instance with a custom adapter. - * - * @param Adapter $adapter The adapter to use - * @return self - */ - public static function withAdapter(Adapter $adapter): self - { - return new self($adapter); - } - /** * Constructor. * diff --git a/tests/Audit/AuditTest.php b/tests/Audit/AuditBase.php similarity index 93% rename from tests/Audit/AuditTest.php rename to tests/Audit/AuditBase.php index a11817f..542a001 100644 --- a/tests/Audit/AuditTest.php +++ b/tests/Audit/AuditBase.php @@ -2,42 +2,41 @@ namespace Utopia\Tests; -use PDO; -use PHPUnit\Framework\TestCase; use Utopia\Audit\Audit; -use Utopia\Cache\Adapter\None as NoCache; -use Utopia\Cache\Cache; -use Utopia\Database\Adapter\MariaDB; -use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Query; -class AuditTest extends TestCase +/** + * Audit Test Trait + * + * This trait contains all the common test methods that should work + * with any adapter (Database, ClickHouse, etc). + * + * Classes using this trait should implement initializeAudit() to initialize + * the appropriate adapter and set $this->audit. + */ +trait AuditBase { protected Audit $audit; + /** + * Classes using this trait must implement this to initialize the audit instance + * with their specific adapter configuration + */ + abstract protected function initializeAudit(): void; + + /** + * Classes should override if they need custom setup + */ public function setUp(): void { - $dbHost = 'mariadb'; - $dbPort = '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPdoAttributes()); - $cache = new Cache(new NoCache()); - $database = new Database(new MariaDB($pdo), $cache); - $database->setDatabase('utopiaTests'); - $database->setNamespace('namespace'); - - $this->audit = Audit::withDatabase($database); - if (! $database->exists('utopiaTests')) { - $database->create(); - $this->audit->setup(); - } - + $this->initializeAudit(); $this->createLogs(); } + /** + * Classes should override if they need custom teardown + */ public function tearDown(): void { $this->audit->cleanup(DateTime::now()); diff --git a/tests/Audit/AuditClickHouseTest.php b/tests/Audit/AuditClickHouseTest.php new file mode 100644 index 0000000..be488f9 --- /dev/null +++ b/tests/Audit/AuditClickHouseTest.php @@ -0,0 +1,33 @@ +audit = new Audit($clickHouse); + $this->audit->setup(); + } +} diff --git a/tests/Audit/AuditDatabaseTest.php b/tests/Audit/AuditDatabaseTest.php new file mode 100644 index 0000000..3958af6 --- /dev/null +++ b/tests/Audit/AuditDatabaseTest.php @@ -0,0 +1,43 @@ +setDatabase('utopiaTests'); + $database->setNamespace('namespace'); + + $adapter = new Adapter\Database($database); + $this->audit = new Audit($adapter); + if (! $database->exists('utopiaTests')) { + $database->create(); + $this->audit->setup(); + } + } +} From 338fbea2a34f76bac03be74473abe198b33e31e9 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:41:40 +0000 Subject: [PATCH 04/50] Fix codeql --- src/Audit/Adapter/ClickHouse.php | 93 ++++++++++++++++++-------------- src/Audit/Adapter/Database.php | 39 +++++++------- src/Audit/Adapter/SQL.php | 26 +++++++-- 3 files changed, 92 insertions(+), 66 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index cf876ba..aaff09d 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -157,7 +157,8 @@ private function getTableName(): string /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * - * @throws Exception|FetchException + * @param array $params + * @throws Exception */ private function query(string $sql, array $params = []): string { @@ -166,15 +167,23 @@ private function query(string $sql, array $params = []): string // Replace parameters in query foreach ($params as $key => $value) { if (is_string($value)) { - $value = "'" . addslashes($value) . "'"; + $strValue = "'" . addslashes($value) . "'"; } elseif (is_null($value)) { - $value = 'NULL'; + $strValue = 'NULL'; } elseif (is_bool($value)) { - $value = $value ? '1' : '0'; + $strValue = $value ? '1' : '0'; } elseif (is_array($value)) { - $value = "'" . addslashes(json_encode($value)) . "'"; + $encoded = json_encode($value); + if (is_string($encoded)) { + $strValue = "'" . addslashes($encoded) . "'"; + } else { + $strValue = 'NULL'; + } + } else { + /** @var scalar $value */ + $strValue = "'" . addslashes((string) $value) . "'"; } - $sql = str_replace(":{$key}", (string) $value, $sql); + $sql = str_replace(":{$key}", $strValue, $sql); } // Build headers with authentication @@ -192,10 +201,13 @@ private function query(string $sql, array $params = []): string ); if ($response->getStatusCode() !== 200) { - throw new Exception("ClickHouse query failed (HTTP {$response->getStatusCode()}): {$response->getBody()}"); + $body = $response->getBody(); + $bodyStr = is_string($body) ? $body : ''; + throw new Exception("ClickHouse query failed (HTTP {$response->getStatusCode()}): {$bodyStr}"); } - return $response->getBody() ?: ''; + $body = $response->getBody(); + return is_string($body) ? $body : ''; } catch (Exception $e) { throw new Exception("ClickHouse connection error: {$e->getMessage()}"); } @@ -229,7 +241,9 @@ public function setup(): void // Build indexes from base adapter schema $indexes = []; foreach ($this->getIndexes() as $index) { + /** @var string $indexName */ $indexName = $index['$id']; + /** @var array $attributes */ $attributes = $index['attributes']; $attributeList = implode(', ', $attributes); $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; @@ -329,11 +343,13 @@ public function createBatch(array $logs): array $values = []; foreach ($logs as $log) { $id = uniqid('audit_', true); - $userId = isset($log['userId']) && $log['userId'] !== null - ? "'" . addslashes($log['userId']) . "'" + $userIdVal = $log['userId'] ?? null; + $userId = ($userIdVal !== null) + ? "'" . addslashes((string) $userIdVal) . "'" : 'NULL'; - $location = isset($log['location']) && $log['location'] !== null - ? "'" . addslashes($log['location']) . "'" + $locationVal = $log['location'] ?? null; + $location = ($locationVal !== null) + ? "'" . addslashes((string) $locationVal) . "'" : 'NULL'; if ($this->sharedTables) { @@ -342,13 +358,13 @@ public function createBatch(array $logs): array "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s', %s)", $id, $userId, - addslashes($log['event']), - addslashes($log['resource']), - addslashes($log['userAgent']), - addslashes($log['ip']), + addslashes((string) $log['event']), + addslashes((string) $log['resource']), + addslashes((string) $log['userAgent']), + addslashes((string) $log['ip']), $location, $log['timestamp'], - addslashes(json_encode($log['data'] ?? [])), + addslashes((string) json_encode($log['data'] ?? [])), $tenant ); } else { @@ -356,13 +372,13 @@ public function createBatch(array $logs): array "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", $id, $userId, - addslashes($log['event']), - addslashes($log['resource']), - addslashes($log['userAgent']), - addslashes($log['ip']), + addslashes((string) $log['event']), + addslashes((string) $log['resource']), + addslashes((string) $log['userAgent']), + addslashes((string) $log['ip']), $location, $log['timestamp'], - addslashes(json_encode($log['data'] ?? [])) + addslashes((string) json_encode($log['data'] ?? [])) ); } } @@ -409,6 +425,8 @@ public function createBatch(array $logs): array /** * Parse ClickHouse query result into Documents. + * + * @return array */ private function parseResults(string $result): array { @@ -429,12 +447,7 @@ private function parseResults(string $result): array continue; } - $data = []; - try { - $data = json_decode($columns[8], true) ?? []; - } catch (\Exception $e) { - $data = []; - } + $data = json_decode($columns[8], true) ?? []; $document = [ '$id' => $columns[0], @@ -498,11 +511,11 @@ public function getByUser(string $userId, array $queries = []): array // Parse simple limit/offset from queries (simplified version) foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod')) { + if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { if ($query->getMethod() === 'limit') { - $limit = $query->getValue(); + $limit = (int) $query->getValue(); } elseif ($query->getMethod() === 'offset') { - $offset = $query->getValue(); + $offset = (int) $query->getValue(); } } } @@ -561,11 +574,11 @@ public function getByResource(string $resource, array $queries = []): array $offset = 0; foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod')) { + if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { if ($query->getMethod() === 'limit') { - $limit = $query->getValue(); + $limit = (int) $query->getValue(); } elseif ($query->getMethod() === 'offset') { - $offset = $query->getValue(); + $offset = (int) $query->getValue(); } } } @@ -624,11 +637,11 @@ public function getByUserAndEvents(string $userId, array $events, array $queries $offset = 0; foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod')) { + if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { if ($query->getMethod() === 'limit') { - $limit = $query->getValue(); + $limit = (int) $query->getValue(); } elseif ($query->getMethod() === 'offset') { - $offset = $query->getValue(); + $offset = (int) $query->getValue(); } } } @@ -689,11 +702,11 @@ public function getByResourceAndEvents(string $resource, array $events, array $q $offset = 0; foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod')) { + if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { if ($query->getMethod() === 'limit') { - $limit = $query->getValue(); + $limit = (int) $query->getValue(); } elseif ($query->getMethod() === 'offset') { - $offset = $query->getValue(); + $offset = (int) $query->getValue(); } } } diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index e70cc5a..5068347 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -62,7 +62,7 @@ public function setup(): void /** * Create an audit log entry. * - * @param array $log + * @param array $log * @return Document * @throws AuthorizationException|\Exception */ @@ -86,7 +86,7 @@ public function create(array $log): Document /** * Create multiple audit log entries in batch. * - * @param array $logs + * @param array> $logs * @return array * @throws AuthorizationException|\Exception */ @@ -113,12 +113,11 @@ public function createBatch(array $logs): array } /** - * Get logs by user ID. + * Get audit logs by user ID. * - * @param string $userId - * @param array $queries + * @param array $queries * @return array - * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + * @throws AuthorizationException|\Exception */ public function getByUser(string $userId, array $queries = []): array { @@ -134,12 +133,10 @@ public function getByUser(string $userId, array $queries = []): array } /** - * Count logs by user ID. + * Count audit logs by user ID. * - * @param string $userId - * @param array $queries - * @return int - * @throws \Utopia\Database\Exception + * @param array $queries + * @throws AuthorizationException|\Exception */ public function countByUser(string $userId, array $queries = []): int { @@ -158,7 +155,7 @@ public function countByUser(string $userId, array $queries = []): int * Get logs by resource. * * @param string $resource - * @param array $queries + * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ @@ -179,7 +176,7 @@ public function getByResource(string $resource, array $queries = []): array * Count logs by resource. * * @param string $resource - * @param array $queries + * @param array $queries * @return int * @throws \Utopia\Database\Exception */ @@ -200,8 +197,8 @@ public function countByResource(string $resource, array $queries = []): int * Get logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ @@ -223,8 +220,8 @@ public function getByUserAndEvents(string $userId, array $events, array $queries * Count logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return int * @throws \Utopia\Database\Exception */ @@ -246,8 +243,8 @@ public function countByUserAndEvents(string $userId, array $events, array $queri * Get logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ @@ -269,8 +266,8 @@ public function getByResourceAndEvents(string $resource, array $events, array $q * Count logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries + * @param array $events + * @param array $queries * @return int * @throws \Utopia\Database\Exception */ diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php index 69b93b9..79dd8e4 100644 --- a/src/Audit/Adapter/SQL.php +++ b/src/Audit/Adapter/SQL.php @@ -29,7 +29,16 @@ protected function getCollectionName(): string /** * Get attribute definitions for audit logs. * - * @return array> + * Each attribute is an array with the following string keys: + * - $id: string (attribute identifier) + * - type: string + * - size: int + * - required: bool + * - signed: bool + * - array: bool + * - filters: array + * + * @return array> */ protected function getAttributes(): array { @@ -117,13 +126,18 @@ protected function getAttributes(): array */ protected function getAttributeDocuments(): array { - return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes()); + return array_map(static fn(array $attribute) => new Document($attribute), $this->getAttributes()); } /** * Get index definitions for audit logs. * - * @return array> + * Each index is an array with the following string keys: + * - $id: string (index identifier) + * - type: string + * - attributes: array + * + * @return array> */ protected function getIndexes(): array { @@ -158,7 +172,7 @@ protected function getIndexes(): array */ protected function getIndexDocuments(): array { - return array_map(static fn (array $index) => new Document($index), $this->getIndexes()); + return array_map(static fn(array $index) => new Document($index), $this->getIndexes()); } /** @@ -212,7 +226,9 @@ protected function getAllColumnDefinitions(): array { $definitions = []; foreach ($this->getAttributes() as $attribute) { - $definitions[] = $this->getColumnDefinition($attribute['$id']); + /** @var string $id */ + $id = $attribute['$id']; + $definitions[] = $this->getColumnDefinition($id); } return $definitions; From 241d940f1d71f2e14eb779876f8fdf6b7a0e178a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:43:12 +0000 Subject: [PATCH 05/50] remove unused --- tests/Audit/AuditClickHouseTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Audit/AuditClickHouseTest.php b/tests/Audit/AuditClickHouseTest.php index be488f9..bee99dd 100644 --- a/tests/Audit/AuditClickHouseTest.php +++ b/tests/Audit/AuditClickHouseTest.php @@ -5,7 +5,6 @@ use PHPUnit\Framework\TestCase; use Utopia\Audit\Adapter\ClickHouse; use Utopia\Audit\Audit; -use Utopia\Audit\Adapter; /** * ClickHouse Adapter Tests From 250dc30fe49dd68da4bf826ea622e34692cdc121 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:44:52 +0000 Subject: [PATCH 06/50] update docker compose --- docker-compose.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 84dc821..15af9ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,23 @@ services: ports: - "9306:3306" + clickhouse: + image: clickhouse/clickhouse-server:latest + environment: + - CLICKHOUSE_DB=default + - CLICKHOUSE_USER=default + - CLICKHOUSE_PASSWORD= + networks: + - abuse + ports: + - "8123:8123" + - "9000:9000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8123/ping"] + interval: 5s + timeout: 3s + retries: 10 + tests: build: context: . @@ -17,7 +34,10 @@ services: networks: - abuse depends_on: - - mariadb + mariadb: + condition: service_started + clickhouse: + condition: service_healthy volumes: - ./phpunit.xml:/code/phpunit.xml - ./src:/code/src From 22ab1fdfcbfa510f0f863789d5cc576f2349d6b2 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 15:45:54 +0000 Subject: [PATCH 07/50] fix format --- src/Audit/Adapter/SQL.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php index 79dd8e4..f9076ee 100644 --- a/src/Audit/Adapter/SQL.php +++ b/src/Audit/Adapter/SQL.php @@ -126,7 +126,7 @@ protected function getAttributes(): array */ protected function getAttributeDocuments(): array { - return array_map(static fn(array $attribute) => new Document($attribute), $this->getAttributes()); + return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes()); } /** @@ -172,7 +172,7 @@ protected function getIndexes(): array */ protected function getIndexDocuments(): array { - return array_map(static fn(array $index) => new Document($index), $this->getIndexes()); + return array_map(static fn (array $index) => new Document($index), $this->getIndexes()); } /** From a98410825e1e933288691863aa17536b1d87cb40 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 23:17:08 +0000 Subject: [PATCH 08/50] Fix docker compose clickhouse healthcheck --- docker-compose.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 15af9ad..e96ad1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ -version: '3' +version: "3" services: mariadb: image: mariadb:10.11 - environment: + environment: - MYSQL_ROOT_PASSWORD=password networks: - abuse @@ -11,21 +11,23 @@ services: - "9306:3306" clickhouse: - image: clickhouse/clickhouse-server:latest + image: clickhouse/clickhouse-server:25.11-alpine environment: - CLICKHOUSE_DB=default - CLICKHOUSE_USER=default - - CLICKHOUSE_PASSWORD= + - CLICKHOUSE_PASSWORD=clickhouse + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 networks: - abuse ports: - "8123:8123" - "9000:9000" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8123/ping"] + test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "-q", "SELECT 1"] interval: 5s timeout: 3s retries: 10 + start_period: 15s tests: build: @@ -44,4 +46,4 @@ services: - ./tests:/code/tests networks: - abuse: \ No newline at end of file + abuse: From 1c86da339bfd978c9d08b093802e2e28ce6334b2 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 23:37:13 +0000 Subject: [PATCH 09/50] Test and fixes clickhouse adapter --- docker-compose.yml | 2 - src/Audit/Adapter/ClickHouse.php | 68 ++++++++++++++++++++++++----- tests/Audit/AuditClickHouseTest.php | 2 +- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e96ad1e..9a22f59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: mariadb: image: mariadb:10.11 diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index aaff09d..c81b742 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -154,6 +154,23 @@ private function getTableName(): string return $tableName; } + /** + * Format timestamp for ClickHouse DateTime64. + * Removes timezone information and ensures proper format. + * + * @param string $timestamp + * @return string + */ + private function formatTimestamp(string $timestamp): string + { + // Remove timezone suffix (e.g., +00:00, Z) if present + // ClickHouse expects format: 2025-12-07 23:19:29.056 + $timestamp = preg_replace('/([+-]\d{2}:\d{2}|Z)$/', '', $timestamp); + // Replace T with space if present + $timestamp = str_replace('T', ' ', $timestamp); + return $timestamp ?? ''; + } + /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * @@ -166,7 +183,10 @@ private function query(string $sql, array $params = []): string // Replace parameters in query foreach ($params as $key => $value) { - if (is_string($value)) { + if (is_int($value) || is_float($value)) { + // Numeric values should not be quoted + $strValue = (string) $value; + } elseif (is_string($value)) { $strValue = "'" . addslashes($value) . "'"; } elseif (is_null($value)) { $strValue = 'NULL'; @@ -228,11 +248,23 @@ public function setup(): void $this->query($createDbSql); // Build column definitions from base adapter schema + // Override time column to be NOT NULL since it's used in partition key $columns = [ 'id String', - ...$this->getAllColumnDefinitions(), ]; + foreach ($this->getAttributes() as $attribute) { + /** @var string $id */ + $id = $attribute['$id']; + + // Special handling for time column - must be NOT NULL for partition key + if ($id === 'time') { + $columns[] = 'time DateTime64(3)'; + } else { + $columns[] = $this->getColumnDefinition($id); + } + } + // Add tenant column only if tables are shared across tenants if ($this->sharedTables) { $columns[] = 'tenant Nullable(UInt64)'; // Supports 11-digit MySQL auto-increment IDs @@ -274,7 +306,9 @@ public function setup(): void public function create(array $log): Document { $id = uniqid('audit_', true); - $time = date('Y-m-d H:i:s.v'); + // Format: 2025-12-07 23:19:29.056 + $microtime = microtime(true); + $time = date('Y-m-d H:i:s', (int) $microtime) . '.' . sprintf('%03d', ($microtime - floor($microtime)) * 1000); $tableName = $this->getTableName(); @@ -352,6 +386,8 @@ public function createBatch(array $logs): array ? "'" . addslashes((string) $locationVal) . "'" : 'NULL'; + $formattedTimestamp = $this->formatTimestamp($log['timestamp']); + if ($this->sharedTables) { $tenant = $this->tenant !== null ? (int) $this->tenant : 'NULL'; $values[] = sprintf( @@ -363,7 +399,7 @@ public function createBatch(array $logs): array addslashes((string) $log['userAgent']), addslashes((string) $log['ip']), $location, - $log['timestamp'], + $formattedTimestamp, addslashes((string) json_encode($log['data'] ?? [])), $tenant ); @@ -377,7 +413,7 @@ public function createBatch(array $logs): array addslashes((string) $log['userAgent']), addslashes((string) $log['ip']), $location, - $log['timestamp'], + $formattedTimestamp, addslashes((string) json_encode($log['data'] ?? [])) ); } @@ -443,12 +479,22 @@ private function parseResults(string $result): array } $columns = explode("\t", $line); - if (count($columns) < 10) { + // Expect 9 columns without sharedTables, 10 with sharedTables + $expectedColumns = $this->sharedTables ? 10 : 9; + if (count($columns) < $expectedColumns) { continue; } $data = json_decode($columns[8], true) ?? []; + // Convert ClickHouse timestamp format back to ISO 8601 + // ClickHouse: 2025-12-07 23:33:54.493 + // ISO 8601: 2025-12-07T23:33:54.493+00:00 + $time = $columns[7]; + if (strpos($time, 'T') === false) { + $time = str_replace(' ', 'T', $time) . '+00:00'; + } + $document = [ '$id' => $columns[0], 'userId' => $columns[1] === '\\N' ? null : $columns[1], @@ -457,7 +503,7 @@ private function parseResults(string $result): array 'userAgent' => $columns[4], 'ip' => $columns[5], 'location' => $columns[6] === '\\N' ? null : $columns[6], - 'time' => $columns[7], + 'time' => $time, 'data' => $data, ]; @@ -759,7 +805,7 @@ public function countByResourceAndEvents(string $resource, array $events, array /** * Delete logs older than the specified datetime. * - * ClickHouse uses a different approach for deletions - we use ALTER TABLE DELETE. + * ClickHouse uses ALTER TABLE DELETE with synchronous mutations. * * @throws Exception */ @@ -768,9 +814,11 @@ public function cleanup(string $datetime): bool $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) + // Falls back to ALTER TABLE DELETE with mutations_sync for older versions $sql = " - ALTER TABLE {$this->database}.{$tableName} - DELETE WHERE time < :datetime{$tenantFilter} + DELETE FROM {$this->database}.{$tableName} + WHERE time < :datetime{$tenantFilter} "; $this->query($sql, ['datetime' => $datetime]); diff --git a/tests/Audit/AuditClickHouseTest.php b/tests/Audit/AuditClickHouseTest.php index bee99dd..dc544d1 100644 --- a/tests/Audit/AuditClickHouseTest.php +++ b/tests/Audit/AuditClickHouseTest.php @@ -21,7 +21,7 @@ protected function initializeAudit(): void host: 'clickhouse', database: 'default', username: 'default', - password: '', + password: 'clickhouse', port: 8123, table: 'audit_logs' ); From a658197f74c35df2c44309a969d768e1edadda1d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 7 Dec 2025 23:55:28 +0000 Subject: [PATCH 10/50] improve adapter --- src/Audit/Adapter/ClickHouse.php | 39 ++++++++++++++++++++++------- tests/Audit/AuditClickHouseTest.php | 6 ++--- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c81b742..7da20af 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -18,18 +18,23 @@ class ClickHouse extends SQL private const DEFAULT_TABLE = 'audits'; + private const DEFAULT_DATABASE = 'default'; + private string $host; private int $port; - private string $database; + private string $database = self::DEFAULT_DATABASE; - private string $table; + private string $table = self::DEFAULT_TABLE; private string $username; private string $password; + /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */ + private bool $secure = false; + protected string $namespace = ''; protected ?int $tenant = null; @@ -38,26 +43,23 @@ class ClickHouse extends SQL /** * @param string $host ClickHouse host - * @param string $database ClickHouse database name * @param string $username ClickHouse username (default: 'default') * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) - * @param string $table Table name for audit logs (default: 'audits') + * @param bool $secure Whether to use HTTPS (default: false) */ public function __construct( string $host, - string $database, string $username = 'default', string $password = '', int $port = self::DEFAULT_PORT, - string $table = self::DEFAULT_TABLE + bool $secure = false ) { $this->host = $host; $this->port = $port; - $this->database = $database; - $this->table = $table; $this->username = $username; $this->password = $password; + $this->secure = $secure; } /** @@ -81,6 +83,24 @@ public function setNamespace(string $namespace): self return $this; } + /** + * Set the database name for subsequent operations. + */ + public function setDatabase(string $database): self + { + $this->database = $database; + return $this; + } + + /** + * Enable or disable HTTPS for ClickHouse HTTP interface. + */ + public function setSecure(bool $secure): self + { + $this->secure = $secure; + return $this; + } + /** * Get the namespace. * @@ -179,7 +199,8 @@ private function formatTimestamp(string $timestamp): string */ private function query(string $sql, array $params = []): string { - $url = "http://{$this->host}:{$this->port}/"; + $scheme = $this->secure ? 'https' : 'http'; + $url = "{$scheme}://{$this->host}:{$this->port}/"; // Replace parameters in query foreach ($params as $key => $value) { diff --git a/tests/Audit/AuditClickHouseTest.php b/tests/Audit/AuditClickHouseTest.php index dc544d1..25fcdd0 100644 --- a/tests/Audit/AuditClickHouseTest.php +++ b/tests/Audit/AuditClickHouseTest.php @@ -19,13 +19,13 @@ protected function initializeAudit(): void { $clickHouse = new ClickHouse( host: 'clickhouse', - database: 'default', username: 'default', password: 'clickhouse', - port: 8123, - table: 'audit_logs' + port: 8123 ); + $clickHouse->setDatabase('default'); + $this->audit = new Audit($clickHouse); $this->audit->setup(); } From c711f3359c0fdc183a345482720a0fc265b65b2d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 00:09:26 +0000 Subject: [PATCH 11/50] Refactor --- composer.json | 2 +- .../ClickHouseTest.php} | 7 +++---- .../{AuditDatabaseTest.php => Adapter/DatabaseTest.php} | 9 ++++----- tests/Audit/AuditBase.php | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) rename tests/Audit/{AuditClickHouseTest.php => Adapter/ClickHouseTest.php} (80%) rename tests/Audit/{AuditDatabaseTest.php => Adapter/DatabaseTest.php} (87%) diff --git a/composer.json b/composer.json index 6a06346..98feb51 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ }, "autoload-dev": { "psr-4": { - "Utopia\\Tests\\": "tests/Audit" + "Utopia\\Tests\\": "tests" } }, "require": { diff --git a/tests/Audit/AuditClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php similarity index 80% rename from tests/Audit/AuditClickHouseTest.php rename to tests/Audit/Adapter/ClickHouseTest.php index 25fcdd0..58f1b20 100644 --- a/tests/Audit/AuditClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -1,17 +1,16 @@ Date: Mon, 8 Dec 2025 00:11:27 +0000 Subject: [PATCH 12/50] codeql fix --- src/Audit/Adapter/ClickHouse.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 7da20af..f6cbbf7 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -185,10 +185,16 @@ private function formatTimestamp(string $timestamp): string { // Remove timezone suffix (e.g., +00:00, Z) if present // ClickHouse expects format: 2025-12-07 23:19:29.056 - $timestamp = preg_replace('/([+-]\d{2}:\d{2}|Z)$/', '', $timestamp); + $normalized = preg_replace('/([+-]\d{2}:\d{2}|Z)$/', '', $timestamp); + + if (!is_string($normalized)) { + return ''; + } + // Replace T with space if present - $timestamp = str_replace('T', ' ', $timestamp); - return $timestamp ?? ''; + $normalized = str_replace('T', ' ', $normalized); + + return $normalized; } /** From a19378ed25784f44ae78e462eb8d551416a4d346 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 12:14:44 +0000 Subject: [PATCH 13/50] Fix suggestions and security issues --- src/Audit/Adapter/ClickHouse.php | 186 ++++++++++++++++++++++++++----- 1 file changed, 156 insertions(+), 30 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index f6cbbf7..5102e55 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -35,6 +35,8 @@ class ClickHouse extends SQL /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */ private bool $secure = false; + private Client $client; + protected string $namespace = ''; protected ?int $tenant = null; @@ -47,6 +49,7 @@ class ClickHouse extends SQL * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) * @param bool $secure Whether to use HTTPS (default: false) + * @throws Exception If validation fails */ public function __construct( string $host, @@ -55,11 +58,20 @@ public function __construct( int $port = self::DEFAULT_PORT, bool $secure = false ) { + $this->validateHost($host); + $this->validatePort($port); + $this->host = $host; $this->port = $port; $this->username = $username; $this->password = $password; $this->secure = $secure; + + // Initialize the HTTP client for connection reuse + $this->client = new Client(); + $this->client->addHeader('X-ClickHouse-User', $this->username); + $this->client->addHeader('X-ClickHouse-Key', $this->password); + $this->client->setTimeout(30); } /** @@ -70,24 +82,135 @@ public function getName(): string return 'ClickHouse'; } + /** + * Validate host parameter. + * + * @param string $host + * @throws Exception + */ + private function validateHost(string $host): void + { + if (empty($host)) { + throw new Exception('ClickHouse host cannot be empty'); + } + + // Check if it's a valid hostname or IP address + // Allow: alphanumeric, dots, hyphens, underscores for hostnames + // Allow: numeric and dots for IPv4 addresses + // Allow: colons for IPv6 addresses + if (!preg_match('/^[a-zA-Z0-9._\-:]+$/', $host)) { + throw new Exception('ClickHouse host contains invalid characters'); + } + + // Prevent localhost references that might bypass security + if (filter_var($host, FILTER_VALIDATE_IP) === false && !preg_match('/^[a-zA-Z0-9._\-]+$/', $host)) { + throw new Exception('ClickHouse host must be a valid hostname or IP address'); + } + } + + /** + * Validate port parameter. + * + * @param int $port + * @throws Exception + */ + private function validatePort(int $port): void + { + if ($port < 1 || $port > 65535) { + throw new Exception('ClickHouse port must be between 1 and 65535'); + } + } + + /** + * Validate identifier (database, table, namespace). + * ClickHouse identifiers follow SQL standard rules. + * + * @param string $identifier + * @param string $type Name of the identifier type for error messages + * @throws Exception + */ + private function validateIdentifier(string $identifier, string $type = 'Identifier'): void + { + if (empty($identifier)) { + throw new Exception("{$type} cannot be empty"); + } + + if (strlen($identifier) > 255) { + throw new Exception("{$type} cannot exceed 255 characters"); + } + + // ClickHouse identifiers: alphanumeric, underscores, cannot start with number + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) { + throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores"); + } + + // Check against SQL keywords (common ones) + $keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'DATABASE']; + if (in_array(strtoupper($identifier), $keywords, true)) { + throw new Exception("{$type} cannot be a reserved SQL keyword"); + } + } + + /** + * Escape an identifier (database name, table name, column name) for safe use in SQL. + * Uses backticks as per SQL standard for identifier quoting. + * + * @param string $identifier + * @return string + */ + private function escapeIdentifier(string $identifier): string + { + // Backtick escaping: replace any backticks in the identifier with double backticks + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + /** + * Escape a string value for safe use in ClickHouse SQL queries. + * ClickHouse uses SQL standard escaping: single quotes are escaped by doubling them. + * This is critical for preventing SQL injection attacks. + * + * @param string $value + * @return string The escaped value without surrounding quotes + */ + private function escapeString(string $value): string + { + // ClickHouse SQL standard: escape single quotes by doubling them + // Also escape backslashes to prevent any potential issues + return str_replace( + ["\\", "'"], + ["\\\\", "''"], + $value + ); + } + + /** * Set the namespace for multi-project support. * Namespace is used as a prefix for table names. * * @param string $namespace * @return self + * @throws Exception */ public function setNamespace(string $namespace): self { + if (!empty($namespace)) { + $this->validateIdentifier($namespace, 'Namespace'); + } $this->namespace = $namespace; return $this; } /** * Set the database name for subsequent operations. + * + * @param string $database + * @return self + * @throws Exception */ public function setDatabase(string $database): self { + $this->validateIdentifier($database, 'Database'); $this->database = $database; return $this; } @@ -200,6 +323,9 @@ private function formatTimestamp(string $timestamp): string /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * + * Reuses the HTTP client from the constructor to enable connection pooling + * and improve performance with frequent queries. + * * @param array $params * @throws Exception */ @@ -214,7 +340,7 @@ private function query(string $sql, array $params = []): string // Numeric values should not be quoted $strValue = (string) $value; } elseif (is_string($value)) { - $strValue = "'" . addslashes($value) . "'"; + $strValue = "'" . $this->escapeString($value) . "'"; } elseif (is_null($value)) { $strValue = 'NULL'; } elseif (is_bool($value)) { @@ -222,26 +348,22 @@ private function query(string $sql, array $params = []): string } elseif (is_array($value)) { $encoded = json_encode($value); if (is_string($encoded)) { - $strValue = "'" . addslashes($encoded) . "'"; + $strValue = "'" . $this->escapeString($encoded) . "'"; } else { $strValue = 'NULL'; } } else { /** @var scalar $value */ - $strValue = "'" . addslashes((string) $value) . "'"; + $strValue = "'" . $this->escapeString((string) $value) . "'"; } $sql = str_replace(":{$key}", $strValue, $sql); } - // Build headers with authentication - $client = new Client(); - $client->addHeader('X-ClickHouse-User', $this->username); - $client->addHeader('X-ClickHouse-Key', $this->password); - $client->addHeader('X-ClickHouse-Database', $this->database); - $client->setTimeout(30); + // Update the database header for each query (in case setDatabase was called) + $this->client->addHeader('X-ClickHouse-Database', $this->database); try { - $response = $client->fetch( + $response = $this->client->fetch( url: $url, method: Client::METHOD_POST, body: ['query' => $sql] @@ -271,7 +393,8 @@ private function query(string $sql, array $params = []): string public function setup(): void { // Create database if not exists - $createDbSql = "CREATE DATABASE IF NOT EXISTS {$this->database}"; + $escapedDatabase = $this->escapeIdentifier($this->database); + $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"; $this->query($createDbSql); // Build column definitions from base adapter schema @@ -309,10 +432,11 @@ public function setup(): void } $tableName = $this->getTableName(); + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); // Create table with MergeTree engine for optimal performance $createTableSql = " - CREATE TABLE IF NOT EXISTS {$this->database}.{$tableName} ( + CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( " . implode(",\n ", $columns) . ", " . implode(",\n ", $indexes) . " ) @@ -361,8 +485,9 @@ public function create(array $log): Document $params['tenant'] = $this->tenant; } + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $insertSql = " - INSERT INTO {$this->database}.{$tableName} + INSERT INTO {$escapedDatabaseAndTable} (" . implode(', ', $columns) . ") VALUES ( " . implode(", ", $placeholders) . " @@ -406,11 +531,11 @@ public function createBatch(array $logs): array $id = uniqid('audit_', true); $userIdVal = $log['userId'] ?? null; $userId = ($userIdVal !== null) - ? "'" . addslashes((string) $userIdVal) . "'" + ? "'" . $this->escapeString((string) $userIdVal) . "'" : 'NULL'; $locationVal = $log['location'] ?? null; $location = ($locationVal !== null) - ? "'" . addslashes((string) $locationVal) . "'" + ? "'" . $this->escapeString((string) $locationVal) . "'" : 'NULL'; $formattedTimestamp = $this->formatTimestamp($log['timestamp']); @@ -421,13 +546,13 @@ public function createBatch(array $logs): array "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s', %s)", $id, $userId, - addslashes((string) $log['event']), - addslashes((string) $log['resource']), - addslashes((string) $log['userAgent']), - addslashes((string) $log['ip']), + $this->escapeString((string) $log['event']), + $this->escapeString((string) $log['resource']), + $this->escapeString((string) $log['userAgent']), + $this->escapeString((string) $log['ip']), $location, $formattedTimestamp, - addslashes((string) json_encode($log['data'] ?? [])), + $this->escapeString((string) json_encode($log['data'] ?? [])), $tenant ); } else { @@ -435,13 +560,13 @@ public function createBatch(array $logs): array "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", $id, $userId, - addslashes((string) $log['event']), - addslashes((string) $log['resource']), - addslashes((string) $log['userAgent']), - addslashes((string) $log['ip']), + $this->escapeString((string) $log['event']), + $this->escapeString((string) $log['resource']), + $this->escapeString((string) $log['userAgent']), + $this->escapeString((string) $log['ip']), $location, $formattedTimestamp, - addslashes((string) json_encode($log['data'] ?? [])) + $this->escapeString((string) json_encode($log['data'] ?? [])) ); } } @@ -454,8 +579,9 @@ public function createBatch(array $logs): array $columns .= ', tenant'; } + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $insertSql = " - INSERT INTO {$this->database}.{$tableName} + INSERT INTO {$escapedDatabaseAndTable} ({$columns}) VALUES " . implode(', ', $values); @@ -719,7 +845,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries } } - $eventsList = implode("', '", array_map('addslashes', $events)); + $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -748,7 +874,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries */ public function countByUserAndEvents(string $userId, array $events, array $queries = []): int { - $eventsList = implode("', '", array_map('addslashes', $events)); + $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -784,7 +910,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q } } - $eventsList = implode("', '", array_map('addslashes', $events)); + $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -813,7 +939,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q */ public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int { - $eventsList = implode("', '", array_map('addslashes', $events)); + $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); From 91ba1881299e715facd523e46add99e7f21a9459 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 12:17:47 +0000 Subject: [PATCH 14/50] remove id prefix --- src/Audit/Adapter/ClickHouse.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 5102e55..71154b2 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -456,7 +456,7 @@ public function setup(): void */ public function create(array $log): Document { - $id = uniqid('audit_', true); + $id = uniqid('', true); // Format: 2025-12-07 23:19:29.056 $microtime = microtime(true); $time = date('Y-m-d H:i:s', (int) $microtime) . '.' . sprintf('%03d', ($microtime - floor($microtime)) * 1000); @@ -528,7 +528,7 @@ public function createBatch(array $logs): array $values = []; foreach ($logs as $log) { - $id = uniqid('audit_', true); + $id = uniqid('', true); $userIdVal = $log['userId'] ?? null; $userId = ($userIdVal !== null) ? "'" . $this->escapeString((string) $userIdVal) . "'" @@ -591,7 +591,7 @@ public function createBatch(array $logs): array $documents = []; foreach ($logs as $log) { $result = [ - '$id' => uniqid('audit_', true), + '$id' => uniqid('', true), 'userId' => $log['userId'] ?? null, 'event' => $log['event'], 'resource' => $log['resource'], From db744cf1bc453ee996970346d2bc8066b8239297 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 12:23:03 +0000 Subject: [PATCH 15/50] Further fixes --- src/Audit/Adapter/ClickHouse.php | 86 +++++++++++++++----------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 71154b2..c94d0ce 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -372,13 +372,19 @@ private function query(string $sql, array $params = []): string if ($response->getStatusCode() !== 200) { $body = $response->getBody(); $bodyStr = is_string($body) ? $body : ''; - throw new Exception("ClickHouse query failed (HTTP {$response->getStatusCode()}): {$bodyStr}"); + throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$bodyStr}"); } $body = $response->getBody(); return is_string($body) ? $body : ''; } catch (Exception $e) { - throw new Exception("ClickHouse connection error: {$e->getMessage()}"); + // Preserve the original exception context for better debugging + // Re-throw with additional context while maintaining the original exception chain + throw new Exception( + "ClickHouse query execution failed: {$e->getMessage()}", + 0, + $e + ); } } @@ -527,8 +533,10 @@ public function createBatch(array $logs): array } $values = []; + $ids = []; foreach ($logs as $log) { $id = uniqid('', true); + $ids[] = $id; $userIdVal = $log['userId'] ?? null; $userId = ($userIdVal !== null) ? "'" . $this->escapeString((string) $userIdVal) . "'" @@ -587,11 +595,11 @@ public function createBatch(array $logs): array $this->query($insertSql); - // Return documents + // Return documents using the same IDs that were inserted $documents = []; - foreach ($logs as $log) { + foreach ($logs as $index => $log) { $result = [ - '$id' => uniqid('', true), + '$id' => $ids[$index], 'userId' => $log['userId'] ?? null, 'event' => $log['event'], 'resource' => $log['resource'], @@ -698,17 +706,18 @@ private function getTenantFilter(): string return " AND tenant = {$this->tenant}"; } + /** - * Get logs by user ID. + * Parse limit and offset from query parameters. * - * @throws Exception + * @param array $queries + * @return array */ - public function getByUser(string $userId, array $queries = []): array + private function parseQueryParams(array $queries): array { $limit = 25; $offset = 0; - // Parse simple limit/offset from queries (simplified version) foreach ($queries as $query) { if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { if ($query->getMethod() === 'limit') { @@ -719,6 +728,20 @@ public function getByUser(string $userId, array $queries = []): array } } + return ['limit' => $limit, 'offset' => $offset]; + } + + /** + * Get logs by user ID. + * + * @throws Exception + */ + public function getByUser(string $userId, array $queries = []): array + { + $params = $this->parseQueryParams($queries); + $limit = $params['limit']; + $offset = $params['offset']; + $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -769,18 +792,9 @@ public function countByUser(string $userId, array $queries = []): int */ public function getByResource(string $resource, array $queries = []): array { - $limit = 25; - $offset = 0; - - foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { - if ($query->getMethod() === 'limit') { - $limit = (int) $query->getValue(); - } elseif ($query->getMethod() === 'offset') { - $offset = (int) $query->getValue(); - } - } - } + $params = $this->parseQueryParams($queries); + $limit = $params['limit']; + $offset = $params['offset']; $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -832,18 +846,9 @@ public function countByResource(string $resource, array $queries = []): int */ public function getByUserAndEvents(string $userId, array $events, array $queries = []): array { - $limit = 25; - $offset = 0; - - foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { - if ($query->getMethod() === 'limit') { - $limit = (int) $query->getValue(); - } elseif ($query->getMethod() === 'offset') { - $offset = (int) $query->getValue(); - } - } - } + $params = $this->parseQueryParams($queries); + $limit = $params['limit']; + $offset = $params['offset']; $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); @@ -897,18 +902,9 @@ public function countByUserAndEvents(string $userId, array $events, array $queri */ public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array { - $limit = 25; - $offset = 0; - - foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { - if ($query->getMethod() === 'limit') { - $limit = (int) $query->getValue(); - } elseif ($query->getMethod() === 'offset') { - $offset = (int) $query->getValue(); - } - } - } + $params = $this->parseQueryParams($queries); + $limit = $params['limit']; + $offset = $params['offset']; $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); $tableName = $this->getTableName(); From 7d1e588027fcbc1d9ea9dcde63310e5ff7a0f430 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 12:24:31 +0000 Subject: [PATCH 16/50] Format --- src/Audit/Adapter/ClickHouse.php | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c94d0ce..0ecde49 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -731,6 +731,18 @@ private function parseQueryParams(array $queries): array return ['limit' => $limit, 'offset' => $offset]; } + /** + * Build a formatted SQL IN list from an array of events. + * Properly escapes each event for safe SQL inclusion. + * + * @param array $events + * @return string Formatted as 'event1', 'event2', 'event3' + */ + private function buildEventsList(array $events): string + { + return implode("', '", array_map(fn ($e) => $this->escapeString($e), $events)); + } + /** * Get logs by user ID. * @@ -850,7 +862,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries $limit = $params['limit']; $offset = $params['offset']; - $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); + $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -879,7 +891,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries */ public function countByUserAndEvents(string $userId, array $events, array $queries = []): int { - $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); + $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -906,7 +918,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q $limit = $params['limit']; $offset = $params['offset']; - $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); + $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -935,7 +947,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q */ public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int { - $eventsList = implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); + $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); From 5fd42bf76ce4cb4d35c46f305aac4ee254b1c5f7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 8 Dec 2025 12:29:05 +0000 Subject: [PATCH 17/50] More suggestion fixes --- src/Audit/Adapter/ClickHouse.php | 27 +++++++++++++++++++++++++ src/Audit/Adapter/Database.php | 34 ++++++++++++++++++++++++++++++++ src/Audit/Adapter/SQL.php | 25 +++++------------------ 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0ecde49..6d7ed43 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -743,6 +743,33 @@ private function buildEventsList(array $events): string return implode("', '", array_map(fn ($e) => $this->escapeString($e), $events)); } + /** + * Get ClickHouse-specific SQL column definition for a given attribute ID. + * + * @param string $id Attribute identifier + * @return string ClickHouse column definition with appropriate types and nullability + * @throws Exception + */ + protected function getColumnDefinition(string $id): string + { + $attribute = $this->getAttribute($id); + + if (!$attribute) { + throw new Exception("Attribute {$id} not found"); + } + + // ClickHouse-specific type mapping + $type = match ($id) { + 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String', + 'time' => 'DateTime64(3)', + default => 'String', + }; + + $nullable = !$attribute['required'] ? 'Nullable(' . $type . ')' : $type; + + return "{$id} {$nullable}"; + } + /** * Get logs by user ID. * diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 5068347..6f42038 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -311,4 +311,38 @@ public function cleanup(string $datetime): bool return true; } + + /** + * Get database-agnostic column definition for a given attribute ID. + * + * For the Database adapter, this method is not actively used since the adapter + * delegates to utopia-php/database's native Document/Collection API which handles + * type mapping internally. However, this implementation is required to satisfy + * the abstract method declaration in the base SQL adapter. + * + * @param string $id Attribute identifier + * @return string Database-agnostic column description + * @throws Exception + */ + protected function getColumnDefinition(string $id): string + { + $attribute = $this->getAttribute($id); + + if (!$attribute) { + throw new Exception("Attribute {$id} not found"); + } + + // For the Database adapter, we use Utopia's VAR_* type constants internally + // This method provides a description for reference purposes + /** @var string $type */ + $type = $attribute['type']; + /** @var int $size */ + $size = $attribute['size'] ?? 0; + + if ($size > 0) { + return "{$id}: {$type}({$size})"; + } + + return "{$id}: {$type}"; + } } diff --git a/src/Audit/Adapter/SQL.php b/src/Audit/Adapter/SQL.php index f9076ee..3b196f2 100644 --- a/src/Audit/Adapter/SQL.php +++ b/src/Audit/Adapter/SQL.php @@ -194,31 +194,16 @@ protected function getAttribute(string $id) /** * Get SQL column definition for a given attribute ID. + * This method is database-specific and must be implemented by each concrete adapter. * - * @param string $id - * @return string + * @param string $id Attribute identifier + * @return string Database-specific column definition */ - protected function getColumnDefinition(string $id): string - { - $attribute = $this->getAttribute($id); - - if (! $attribute) { - throw new \Exception("Attribute {$id} not found"); - } - - $type = match ($id) { - 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String', - 'time' => 'DateTime64(3)', - default => 'String', - }; - - $nullable = ! $attribute['required'] ? 'Nullable(' . $type . ')' : $type; - - return "{$id} {$nullable}"; - } + abstract protected function getColumnDefinition(string $id): string; /** * Get all SQL column definitions. + * Uses the concrete adapter's implementation of getColumnDefinition. * * @return array */ From 59225a1602e58e809d406b634155f26b9ba3d5bd Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 01:04:16 +0000 Subject: [PATCH 18/50] wrong folder --- .../workflows/codeql-analysis.yml | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/workflows/codeql-analysis.yml diff --git a/.github/ISSUE_TEMPLATE/workflows/codeql-analysis.yml b/.github/ISSUE_TEMPLATE/workflows/codeql-analysis.yml deleted file mode 100644 index 7567b1c..0000000 --- a/.github/ISSUE_TEMPLATE/workflows/codeql-analysis.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "CodeQL" - -on: [pull_request] -jobs: - lint: - name: CodeQL - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - - run: git checkout HEAD^2 - - - name: Run CodeQL - run: | - docker run --rm -v $PWD:/app composer sh -c \ - "composer install --profile --ignore-platform-reqs && composer check" \ No newline at end of file From 957a7a9e97048160ecb1fdfb6e2073a7ccb46c04 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 01:10:47 +0000 Subject: [PATCH 19/50] use docker compose wait --- .github/workflows/tests.yml | 3 +-- docker-compose.yml | 14 +++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31158be..25988e6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,8 +17,7 @@ jobs: - name: Build run: | docker compose build - docker compose up -d - sleep 10 + docker compose up -d --wait - name: Run Tests run: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9a22f59..990aa88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,12 @@ services: - abuse ports: - "9306:3306" + healthcheck: + test: ["CMD", "sh", "-c", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s clickhouse: image: clickhouse/clickhouse-server:25.11-alpine @@ -35,13 +41,19 @@ services: - abuse depends_on: mariadb: - condition: service_started + condition: service_healthy clickhouse: condition: service_healthy volumes: - ./phpunit.xml:/code/phpunit.xml - ./src:/code/src - ./tests:/code/tests + healthcheck: + test: ["CMD", "php", "--version"] + interval: 5s + timeout: 3s + retries: 3 + start_period: 5s networks: abuse: From 78088252a26f9c987fbb73002d2b0ba45734ba87 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 01:45:59 +0000 Subject: [PATCH 20/50] updated return type --- src/Audit/Adapter.php | 16 ++- src/Audit/Adapter/ClickHouse.php | 14 +-- src/Audit/Adapter/Database.php | 58 +++++++-- src/Audit/Audit.php | 15 ++- src/Audit/Log.php | 209 +++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 37 deletions(-) create mode 100644 src/Audit/Log.php diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index fb568bd..7354250 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -2,8 +2,6 @@ namespace Utopia\Audit; -use Utopia\Database\Document; - /** * Abstract Adapter class for Audit implementations * @@ -38,11 +36,11 @@ abstract public function setup(): void; * location?: string, * data?: array * } $log - * @return Document The created document + * @return Log The created log entry * * @throws \Exception */ - abstract public function create(array $log): Document; + abstract public function create(array $log): Log; /** * Create multiple audit log entries in batch. @@ -57,7 +55,7 @@ abstract public function create(array $log): Document; * timestamp: string, * data?: array * }> $logs - * @return array + * @return array * * @throws \Exception */ @@ -68,7 +66,7 @@ abstract public function createBatch(array $logs): array; * * @param string $userId * @param array $queries Additional query parameters - * @return array + * @return array * * @throws \Exception */ @@ -90,7 +88,7 @@ abstract public function countByUser(string $userId, array $queries = []): int; * * @param string $resource * @param array $queries Additional query parameters - * @return array + * @return array * * @throws \Exception */ @@ -113,7 +111,7 @@ abstract public function countByResource(string $resource, array $queries = []): * @param string $userId * @param array $events * @param array $queries Additional query parameters - * @return array + * @return array * * @throws \Exception */ @@ -137,7 +135,7 @@ abstract public function countByUserAndEvents(string $userId, array $events, arr * @param string $resource * @param array $events * @param array $queries Additional query parameters - * @return array + * @return array * * @throws \Exception */ diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 6d7ed43..31eb038 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -3,7 +3,7 @@ namespace Utopia\Audit\Adapter; use Exception; -use Utopia\Database\Document; +use Utopia\Audit\Log; use Utopia\Fetch\Client; /** @@ -460,7 +460,7 @@ public function setup(): void * * @throws Exception */ - public function create(array $log): Document + public function create(array $log): Log { $id = uniqid('', true); // Format: 2025-12-07 23:19:29.056 @@ -518,7 +518,7 @@ public function create(array $log): Document $result['tenant'] = $this->tenant; } - return new Document($result); + return new Log($result); } /** @@ -614,16 +614,16 @@ public function createBatch(array $logs): array $result['tenant'] = $this->tenant; } - $documents[] = new Document($result); + $documents[] = new Log($result); } return $documents; } /** - * Parse ClickHouse query result into Documents. + * Parse ClickHouse query result into Log objects. * - * @return array + * @return array */ private function parseResults(string $result): array { @@ -673,7 +673,7 @@ private function parseResults(string $result): array $document['tenant'] = $columns[9] === '\\N' ? null : (int) $columns[9]; } - $documents[] = new Document($document); + $documents[] = new Log($document); } return $documents; diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 6f42038..ba7dad7 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -2,6 +2,7 @@ namespace Utopia\Audit\Adapter; +use Utopia\Audit\Log; use Utopia\Database\Database as DatabaseClient; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -63,12 +64,12 @@ public function setup(): void * Create an audit log entry. * * @param array $log - * @return Document + * @return Log * @throws AuthorizationException|\Exception */ - public function create(array $log): Document + public function create(array $log): Log { - return $this->db->getAuthorization()->skip(function () use ($log) { + $document = $this->db->getAuthorization()->skip(function () use ($log) { return $this->db->createDocument($this->getCollectionName(), new Document([ '$permissions' => [], 'userId' => $log['userId'] ?? null, @@ -81,13 +82,15 @@ public function create(array $log): Document 'time' => DateTime::now(), ])); }); + + return $this->documentToLog($document); } /** * Create multiple audit log entries in batch. * * @param array> $logs - * @return array + * @return array * @throws AuthorizationException|\Exception */ public function createBatch(array $logs): array @@ -109,19 +112,19 @@ public function createBatch(array $logs): array } }); - return $created; + return array_map(fn ($doc) => $this->documentToLog($doc), $created); } /** * Get audit logs by user ID. * * @param array $queries - * @return array + * @return array * @throws AuthorizationException|\Exception */ public function getByUser(string $userId, array $queries = []): array { - return $this->db->getAuthorization()->skip(function () use ($userId, $queries) { + $documents = $this->db->getAuthorization()->skip(function () use ($userId, $queries) { $queries[] = Query::equal('userId', [$userId]); $queries[] = Query::orderDesc(); @@ -130,6 +133,8 @@ public function getByUser(string $userId, array $queries = []): array queries: $queries, ); }); + + return array_map(fn ($doc) => $this->documentToLog($doc), $documents); } /** @@ -156,12 +161,12 @@ public function countByUser(string $userId, array $queries = []): int * * @param string $resource * @param array $queries - * @return array + * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResource(string $resource, array $queries = []): array { - return $this->db->getAuthorization()->skip(function () use ($resource, $queries) { + $documents = $this->db->getAuthorization()->skip(function () use ($resource, $queries) { $queries[] = Query::equal('resource', [$resource]); $queries[] = Query::orderDesc(); @@ -170,6 +175,8 @@ public function getByResource(string $resource, array $queries = []): array queries: $queries, ); }); + + return array_map(fn ($doc) => $this->documentToLog($doc), $documents); } /** @@ -199,12 +206,12 @@ public function countByResource(string $resource, array $queries = []): int * @param string $userId * @param array $events * @param array $queries - * @return array + * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByUserAndEvents(string $userId, array $events, array $queries = []): array { - return $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { + $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { $queries[] = Query::equal('userId', [$userId]); $queries[] = Query::equal('event', $events); $queries[] = Query::orderDesc(); @@ -214,6 +221,8 @@ public function getByUserAndEvents(string $userId, array $events, array $queries queries: $queries, ); }); + + return array_map(fn ($doc) => $this->documentToLog($doc), $documents); } /** @@ -245,12 +254,12 @@ public function countByUserAndEvents(string $userId, array $events, array $queri * @param string $resource * @param array $events * @param array $queries - * @return array + * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array { - return $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { + $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { $queries[] = Query::equal('resource', [$resource]); $queries[] = Query::equal('event', $events); $queries[] = Query::orderDesc(); @@ -260,6 +269,8 @@ public function getByResourceAndEvents(string $resource, array $events, array $q queries: $queries, ); }); + + return array_map(fn ($doc) => $this->documentToLog($doc), $documents); } /** @@ -312,6 +323,27 @@ public function cleanup(string $datetime): bool return true; } + /** + * Convert a Document to a Log object. + * + * @param Document $document + * @return Log + */ + private function documentToLog(Document $document): Log + { + return new Log([ + '$id' => $document->getId(), + 'userId' => $document->getAttribute('userId'), + 'event' => $document->getAttribute('event'), + 'resource' => $document->getAttribute('resource'), + 'userAgent' => $document->getAttribute('userAgent'), + 'ip' => $document->getAttribute('ip'), + 'location' => $document->getAttribute('location'), + 'time' => $document->getAttribute('time'), + 'data' => $document->getAttribute('data', []), + ]); + } + /** * Get database-agnostic column definition for a given attribute ID. * diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 4058036..98736cc 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -3,7 +3,6 @@ namespace Utopia\Audit; use Utopia\Database\Database; -use Utopia\Database\Document; /** * Audit Log Manager @@ -57,11 +56,11 @@ public function setup(): void * @param string $ip * @param string $location * @param array $data - * @return Document + * @return Log * * @throws \Exception */ - public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Document + public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Log { return $this->adapter->create([ 'userId' => $userId, @@ -78,7 +77,7 @@ public function log(?string $userId, string $event, string $resource, string $us * Add multiple event logs in batch. * * @param array}> $events - * @return array + * @return array * * @throws \Exception */ @@ -92,7 +91,7 @@ public function logBatch(array $events): array * * @param string $userId * @param array $queries - * @return array + * @return array * * @throws \Exception */ @@ -123,7 +122,7 @@ public function countLogsByUser( * * @param string $resource * @param array $queries - * @return array + * @return array * * @throws \Exception */ @@ -156,7 +155,7 @@ public function countLogsByResource( * @param string $userId * @param array $events * @param array $queries - * @return array + * @return array * * @throws \Exception */ @@ -192,7 +191,7 @@ public function countLogsByUserAndEvents( * @param string $resource * @param array $events * @param array $queries - * @return array + * @return array * * @throws \Exception */ diff --git a/src/Audit/Log.php b/src/Audit/Log.php new file mode 100644 index 0000000..ffbf197 --- /dev/null +++ b/src/Audit/Log.php @@ -0,0 +1,209 @@ + + */ +class Log extends ArrayObject +{ + /** + * Construct a new audit log object. + * + * @param array $input + */ + public function __construct(array $input = []) + { + parent::__construct($input); + } + + /** + * Get the log ID. + * + * @return string + */ + public function getId(): string + { + $id = $this->getAttribute('$id', ''); + return is_string($id) ? $id : ''; + } + + /** + * Get the user ID associated with this log entry. + * + * @return string|null + */ + public function getUserId(): ?string + { + $userId = $this->getAttribute('userId'); + return is_string($userId) ? $userId : null; + } + + /** + * Get the event name. + * + * @return string + */ + public function getEvent(): string + { + $event = $this->getAttribute('event', ''); + return is_string($event) ? $event : ''; + } + + /** + * Get the resource identifier. + * + * @return string + */ + public function getResource(): string + { + $resource = $this->getAttribute('resource', ''); + return is_string($resource) ? $resource : ''; + } + + /** + * Get the user agent string. + * + * @return string + */ + public function getUserAgent(): string + { + $userAgent = $this->getAttribute('userAgent', ''); + return is_string($userAgent) ? $userAgent : ''; + } + + /** + * Get the IP address. + * + * @return string + */ + public function getIp(): string + { + $ip = $this->getAttribute('ip', ''); + return is_string($ip) ? $ip : ''; + } + + /** + * Get the location information. + * + * @return string|null + */ + public function getLocation(): ?string + { + $location = $this->getAttribute('location'); + return is_string($location) ? $location : null; + } + + /** + * Get the timestamp. + * + * @return string + */ + public function getTime(): string + { + $time = $this->getAttribute('time', ''); + return is_string($time) ? $time : ''; + } + + /** + * Get the additional data. + * + * @return array + */ + public function getData(): array + { + $data = $this->getAttribute('data', []); + return is_array($data) ? $data : []; + } + + /** + * Get the tenant ID (for multi-tenant setups). + * + * @return int|null + */ + public function getTenant(): ?int + { + $tenant = $this->getAttribute('tenant'); + + if ($tenant === null) { + return null; + } + + if (is_int($tenant)) { + return $tenant; + } + + if (is_numeric($tenant)) { + return (int) $tenant; + } + + return null; + } + + /** + * Get an attribute by key. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getAttribute(string $key, mixed $default = null): mixed + { + return $this->offsetExists($key) ? $this->offsetGet($key) : $default; + } + + /** + * Set an attribute. + * + * @param string $key + * @param mixed $value + * @return self + */ + public function setAttribute(string $key, mixed $value): self + { + $this->offsetSet($key, $value); + return $this; + } + + /** + * Remove an attribute. + * + * @param string $key + * @return self + */ + public function removeAttribute(string $key): self + { + if ($this->offsetExists($key)) { + $this->offsetUnset($key); + } + return $this; + } + + /** + * Check if an attribute exists. + * + * @param string $key + * @return bool + */ + public function isSet(string $key): bool + { + return $this->offsetExists($key); + } + + /** + * Get all attributes as an array. + * + * @return array + */ + public function getArrayCopy(): array + { + return parent::getArrayCopy(); + } +} From 03951d0fa4e9cd036616d6aadf396e15d278bd96 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 03:23:42 +0000 Subject: [PATCH 21/50] Fix test --- tests/Audit/AuditBase.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 68156de..c9b3ac7 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -3,6 +3,7 @@ namespace Utopia\Tests\Audit; use Utopia\Audit\Audit; +use Utopia\Audit\Log; use Utopia\Database\DateTime; use Utopia\Database\Query; @@ -50,10 +51,10 @@ public function createLogs(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); } public function testGetLogsByUser(): void @@ -273,11 +274,11 @@ public function testCleanup(): void $location = 'US'; $data = ['key1' => 'value1', 'key2' => 'value2']; - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); sleep(5); - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); sleep(5); - $this->assertInstanceOf('Utopia\\Database\\Document', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); sleep(5); // DELETE logs older than 11 seconds and check that status is true From e806fa07ede487583f9e40dbafc95ecaffd11dfd Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 10:57:32 +0000 Subject: [PATCH 22/50] use batch delete --- src/Audit/Adapter/Database.php | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index ba7dad7..31d5b7d 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -307,16 +307,9 @@ public function cleanup(string $datetime): bool { $this->db->getAuthorization()->skip(function () use ($datetime) { do { - $documents = $this->db->find( - collection: $this->getCollectionName(), - queries: [ - Query::lessThan('time', $datetime), - ] - ); - - foreach ($documents as $document) { - $this->db->deleteDocument($this->getCollectionName(), $document->getId()); - } + $this->db->deleteDocuments($this->getCollectionName(), [ + Query::lessThan('time', $datetime), + ]); } while (! empty($documents)); }); From 97c1e0e8e293a79d991479dc122011f20d80ff75 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:01:53 +0000 Subject: [PATCH 23/50] simplify conversion --- src/Audit/Adapter/Database.php | 58 +++++----------------------------- 1 file changed, 8 insertions(+), 50 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 31d5b7d..5f18acc 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -4,7 +4,6 @@ use Utopia\Audit\Log; use Utopia\Database\Database as DatabaseClient; -use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -70,20 +69,10 @@ public function setup(): void public function create(array $log): Log { $document = $this->db->getAuthorization()->skip(function () use ($log) { - return $this->db->createDocument($this->getCollectionName(), new Document([ - '$permissions' => [], - 'userId' => $log['userId'] ?? null, - 'event' => $log['event'], - 'resource' => $log['resource'], - 'userAgent' => $log['userAgent'], - 'ip' => $log['ip'], - 'location' => $log['location'] ?? null, - 'data' => $log['data'] ?? [], - 'time' => DateTime::now(), - ])); + return $this->db->createDocument($this->getCollectionName(), new Document($log)); }); - return $this->documentToLog($document); + return new Log($document->getArrayCopy()); } /** @@ -98,21 +87,11 @@ public function createBatch(array $logs): array $created = []; $this->db->getAuthorization()->skip(function () use ($logs, &$created) { foreach ($logs as $log) { - $created[] = $this->db->createDocument($this->getCollectionName(), new Document([ - '$permissions' => [], - 'userId' => $log['userId'] ?? null, - 'event' => $log['event'], - 'resource' => $log['resource'], - 'userAgent' => $log['userAgent'], - 'ip' => $log['ip'], - 'location' => $log['location'] ?? null, - 'data' => $log['data'] ?? [], - 'time' => $log['timestamp'], - ])); + $created[] = $this->db->createDocument($this->getCollectionName(), new Document($log)); } }); - return array_map(fn ($doc) => $this->documentToLog($doc), $created); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -134,7 +113,7 @@ public function getByUser(string $userId, array $queries = []): array ); }); - return array_map(fn ($doc) => $this->documentToLog($doc), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -176,7 +155,7 @@ public function getByResource(string $resource, array $queries = []): array ); }); - return array_map(fn ($doc) => $this->documentToLog($doc), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -222,7 +201,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries ); }); - return array_map(fn ($doc) => $this->documentToLog($doc), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -270,7 +249,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q ); }); - return array_map(fn ($doc) => $this->documentToLog($doc), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -316,27 +295,6 @@ public function cleanup(string $datetime): bool return true; } - /** - * Convert a Document to a Log object. - * - * @param Document $document - * @return Log - */ - private function documentToLog(Document $document): Log - { - return new Log([ - '$id' => $document->getId(), - 'userId' => $document->getAttribute('userId'), - 'event' => $document->getAttribute('event'), - 'resource' => $document->getAttribute('resource'), - 'userAgent' => $document->getAttribute('userAgent'), - 'ip' => $document->getAttribute('ip'), - 'location' => $document->getAttribute('location'), - 'time' => $document->getAttribute('time'), - 'data' => $document->getAttribute('data', []), - ]); - } - /** * Get database-agnostic column definition for a given attribute ID. * From 8e1211f8f17ba3d4c0a89164dd2697cb14520efa Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:08:21 +0000 Subject: [PATCH 24/50] Fix comments and cleanup --- README.md | 3 ++- src/Audit/Adapter/Database.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ae7a66..1d0d2b2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ use Utopia\Cache\Cache; use Utopia\Cache\Adapter\None as NoCache; use Utopia\Database\Adapter\MySQL; use Utopia\Database\Database; +use Utopia\Audit\Adapter\Database as DatabaseAdapter; $dbHost = '127.0.0.1'; $dbUser = 'travis'; @@ -61,7 +62,7 @@ $database = new Database(new MySQL($pdo), $cache); $database->setNamespace('namespace'); // Create audit instance with Database adapter -$audit = Audit::withDatabase($database); +$audit = new Audit(new DatabaseAdapter($database)); $audit->setup(); ``` diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 5f18acc..0dec6eb 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -286,10 +286,10 @@ public function cleanup(string $datetime): bool { $this->db->getAuthorization()->skip(function () use ($datetime) { do { - $this->db->deleteDocuments($this->getCollectionName(), [ + $removed = $this->db->deleteDocuments($this->getCollectionName(), [ Query::lessThan('time', $datetime), ]); - } while (! empty($documents)); + } while ($removed > 0); }); return true; From fd6b2ddb4c71043c77ff41a311df3ff76aadc886 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:11:03 +0000 Subject: [PATCH 25/50] use validator --- composer.json | 3 ++- composer.lock | 2 +- src/Audit/Adapter/ClickHouse.php | 19 ++++--------------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/composer.json b/composer.json index 98feb51..4d00dad 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "require": { "php": ">=8.0", "utopia-php/database": "4.*", - "utopia-php/fetch": "^0.4.2" + "utopia-php/fetch": "^0.4.2", + "utopia-php/validators": "^0.1.0" }, "require-dev": { "phpunit/phpunit": "9.*", diff --git a/composer.lock b/composer.lock index ed1f019..3076c39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "19cc937889f0e8fcc9f196c2d50be7e0", + "content-hash": "7e7770e1778658a1376fdd3c1ffd73c3", "packages": [ { "name": "brick/math", diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 31eb038..3051952 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -5,6 +5,7 @@ use Exception; use Utopia\Audit\Log; use Utopia\Fetch\Client; +use Utopia\Validator\Hostname; /** * ClickHouse Adapter for Audit @@ -90,21 +91,9 @@ public function getName(): string */ private function validateHost(string $host): void { - if (empty($host)) { - throw new Exception('ClickHouse host cannot be empty'); - } - - // Check if it's a valid hostname or IP address - // Allow: alphanumeric, dots, hyphens, underscores for hostnames - // Allow: numeric and dots for IPv4 addresses - // Allow: colons for IPv6 addresses - if (!preg_match('/^[a-zA-Z0-9._\-:]+$/', $host)) { - throw new Exception('ClickHouse host contains invalid characters'); - } - - // Prevent localhost references that might bypass security - if (filter_var($host, FILTER_VALIDATE_IP) === false && !preg_match('/^[a-zA-Z0-9._\-]+$/', $host)) { - throw new Exception('ClickHouse host must be a valid hostname or IP address'); + $validator = new Hostname(); + if (!$validator->isValid($host)) { + throw new Exception('ClickHouse host is not a valid hostname or IP address'); } } From 5714e29fb432baebc38c2683409e7d6de72a3925 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:24:42 +0000 Subject: [PATCH 26/50] update to use time attribute --- tests/Audit/AuditBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index c9b3ac7..5956353 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -176,7 +176,7 @@ public function testLogByBatch(): void 'ip' => $ip, 'location' => $location, 'data' => ['key' => 'value1'], - 'timestamp' => $timestamp1 + 'time' => $timestamp1 ], [ 'userId' => $userId, @@ -186,7 +186,7 @@ public function testLogByBatch(): void 'ip' => $ip, 'location' => $location, 'data' => ['key' => 'value2'], - 'timestamp' => $timestamp2 + 'time' => $timestamp2 ], [ 'userId' => $userId, @@ -196,7 +196,7 @@ public function testLogByBatch(): void 'ip' => $ip, 'location' => $location, 'data' => ['key' => 'value3'], - 'timestamp' => $timestamp3 + 'time' => $timestamp3 ], [ 'userId' => null, @@ -206,7 +206,7 @@ public function testLogByBatch(): void 'ip' => $ip, 'location' => $location, 'data' => ['key' => 'value4'], - 'timestamp' => $timestamp3 + 'time' => $timestamp3 ] ]; From 7ac9f3b118fd7f540eeb1e2d4798cf3ee0c65fe0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:30:39 +0000 Subject: [PATCH 27/50] fix type --- src/Audit/Audit.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 98736cc..6dcdf12 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -76,7 +76,7 @@ public function log(?string $userId, string $event, string $resource, string $us /** * Add multiple event logs in batch. * - * @param array}> $events + * @param array}> $events * @return array * * @throws \Exception From 8cd0cec2d1789a95f284908e9bacc7d247c50615 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:32:47 +0000 Subject: [PATCH 28/50] fix params --- src/Audit/Adapter.php | 2 +- src/Audit/Adapter/ClickHouse.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index 7354250..aa377ff 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -52,7 +52,7 @@ abstract public function create(array $log): Log; * userAgent: string, * ip: string, * location?: string, - * timestamp: string, + * time: string, * data?: array * }> $logs * @return array diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 3051952..ed31bd9 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -535,7 +535,7 @@ public function createBatch(array $logs): array ? "'" . $this->escapeString((string) $locationVal) . "'" : 'NULL'; - $formattedTimestamp = $this->formatTimestamp($log['timestamp']); + $formattedTimestamp = $this->formatTimestamp($log['time']); if ($this->sharedTables) { $tenant = $this->tenant !== null ? (int) $this->tenant : 'NULL'; @@ -595,7 +595,7 @@ public function createBatch(array $logs): array 'userAgent' => $log['userAgent'], 'ip' => $log['ip'], 'location' => $log['location'] ?? null, - 'time' => $log['timestamp'], + 'time' => $log['time'], 'data' => $log['data'] ?? [], ]; From de10e8e1e2ee560954f74a78f8d8c9a81b03fe81 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:54:28 +0000 Subject: [PATCH 29/50] Fix create --- src/Audit/Adapter/Database.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 0dec6eb..0bcfff3 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -4,6 +4,7 @@ use Utopia\Audit\Log; use Utopia\Database\Database as DatabaseClient; +use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -68,6 +69,7 @@ public function setup(): void */ public function create(array $log): Log { + $log['time'] = $log['time'] ?? DateTime::now(); $document = $this->db->getAuthorization()->skip(function () use ($log) { return $this->db->createDocument($this->getCollectionName(), new Document($log)); }); @@ -91,7 +93,7 @@ public function createBatch(array $logs): array } }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -113,7 +115,7 @@ public function getByUser(string $userId, array $queries = []): array ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -155,7 +157,7 @@ public function getByResource(string $resource, array $queries = []): array ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -201,7 +203,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -249,7 +251,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** From 7251c3e4d4f60d897e99478bdcb2c573799d9c3c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:57:07 +0000 Subject: [PATCH 30/50] remove example file --- example.php | 144 ---------------------------------------------------- 1 file changed, 144 deletions(-) delete mode 100644 example.php diff --git a/example.php b/example.php deleted file mode 100644 index 2615693..0000000 --- a/example.php +++ /dev/null @@ -1,144 +0,0 @@ - 3, - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => true, - PDO::ATTR_STRINGIFY_FETCHES => true, - ]); - - // Create cache instance - $cache = new Cache(new NoCache()); - - // Create database instance - $database = new Database(new MySQL($pdo), $cache); - $database->setDatabase('auditExample'); - $database->setNamespace('example'); - - // Create database if it doesn't exist - if (!$database->exists('auditExample')) { - $database->create(); - } - - // Method 1: Create Audit instance using the Database adapter (recommended) - $audit = Audit::withDatabase($database); - - // Setup the audit collection (creates tables/collections and indexes) - $audit->setup(); - - echo "✓ Audit instance created and setup completed\n\n"; - - // Example 1: Log a single event - echo "Example 1: Logging a single event\n"; - $document = $audit->log( - userId: 'user-123', - event: 'document.create', - resource: 'database/collection/document-456', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - ip: '192.168.1.100', - location: 'US', - data: [ - 'documentId' => 'document-456', - 'collectionId' => 'collection-789', - 'action' => 'created new document' - ] - ); - echo "✓ Created log with ID: {$document->getId()}\n\n"; - - // Example 2: Log multiple events in batch - echo "Example 2: Batch logging multiple events\n"; - $batchEvents = [ - [ - 'userId' => 'user-123', - 'event' => 'document.update', - 'resource' => 'database/collection/document-456', - 'userAgent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', - 'ip' => '192.168.1.100', - 'location' => 'US', - 'data' => ['field' => 'title', 'oldValue' => 'Old Title', 'newValue' => 'New Title'], - 'timestamp' => DateTime::now() - ], - [ - 'userId' => 'user-456', - 'event' => 'user.login', - 'resource' => 'auth/session/session-789', - 'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', - 'ip' => '192.168.1.101', - 'location' => 'UK', - 'data' => ['sessionId' => 'session-789', 'method' => 'email'], - 'timestamp' => DateTime::now() - ], - ]; - - $documents = $audit->logBatch($batchEvents); - echo "✓ Created " . count($documents) . " logs in batch\n\n"; - - // Example 3: Retrieve logs by user - echo "Example 3: Retrieving logs by user\n"; - $userLogs = $audit->getLogsByUser('user-123'); - echo "✓ Found " . count($userLogs) . " logs for user-123\n"; - foreach ($userLogs as $log) { - echo " - Event: {$log->getAttribute('event')}, Resource: {$log->getAttribute('resource')}\n"; - } - echo "\n"; - - // Example 4: Retrieve logs by user and specific events - echo "Example 4: Retrieving logs by user and events\n"; - $eventLogs = $audit->getLogsByUserAndEvents('user-123', ['document.create', 'document.update']); - echo "✓ Found " . count($eventLogs) . " document events for user-123\n\n"; - - // Example 5: Retrieve logs by resource - echo "Example 5: Retrieving logs by resource\n"; - $resourceLogs = $audit->getLogsByResource('database/collection/document-456'); - echo "✓ Found " . count($resourceLogs) . " logs for resource\n\n"; - - // Example 6: Count logs - echo "Example 6: Counting logs\n"; - $userLogsCount = $audit->countLogsByUser('user-123'); - echo "✓ Total logs for user-123: {$userLogsCount}\n\n"; - - // Example 7: Using with custom adapter (alternative method) - echo "Example 7: Using custom adapter\n"; - $customAdapter = new \Utopia\Audit\Adapter\Database($database); - $auditWithAdapter = Audit::withAdapter($customAdapter); - echo "✓ Audit created with custom adapter: {$customAdapter->getName()}\n\n"; - - // Example 8: Cleanup old logs - echo "Example 8: Cleanup old logs (commented out to preserve examples)\n"; - // $oldDate = DateTime::addSeconds(new \DateTime(), -3600); // Logs older than 1 hour - // $audit->cleanup($oldDate); - echo "✓ Cleanup example available (currently commented out)\n\n"; - - echo "All examples completed successfully!\n"; -} catch (\Exception $e) { - echo "Error: " . $e->getMessage() . "\n"; - echo "Trace: " . $e->getTraceAsString() . "\n"; - exit(1); -} From 0e1920a4e165f62f4e583a7f705931c3a0a4d03d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 11:59:59 +0000 Subject: [PATCH 31/50] update readme --- README.md | 20 ++++++-------------- src/Audit/Adapter/Database.php | 10 +++++----- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1d0d2b2..744a85d 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,7 @@ use Utopia\Database\Database; // Using the Database adapter directly $adapter = new DatabaseAdapter($database); -$audit = Audit::withAdapter($adapter); - -// Or using the static factory method -$audit = Audit::withDatabase($database); +$audit = new Audit($adapter); ``` ### Basic Operations @@ -150,7 +147,7 @@ $events = [ 'ip' => '127.0.0.1', 'location' => 'US', 'data' => ['key' => 'value'], - 'timestamp' => DateTime::now() + 'time' => DateTime::now() ], [ 'userId' => 'user-2', @@ -160,7 +157,7 @@ $events = [ 'ip' => '192.168.1.1', 'location' => 'UK', 'data' => ['key' => 'value'], - 'timestamp' => DateTime::now() + 'time' => DateTime::now() ] ]; @@ -175,15 +172,10 @@ Utopia Audit uses an adapter pattern to support different storage backends. Curr The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a database. -**Supported Databases:** -- MySQL/MariaDB -- PostgreSQL -- MongoDB -- And all other databases supported by utopia-php/database ### ClickHouse Adapter -The ClickHouse adapter uses [ClickHouse](https://clickhouse.com/) for high-performance analytical queries on massive amounts of log data. It communicates with ClickHouse via HTTP interface using cURL. +The ClickHouse adapter uses [ClickHouse](https://clickhouse.com/) for high-performance analytical queries on massive amounts of log data. It communicates with ClickHouse via HTTP interface using Utopia Fetch. **Features:** - Optimized for analytical queries and aggregations @@ -210,7 +202,7 @@ $adapter = new ClickHouse( table: 'audit_logs' ); -$audit = Audit::withAdapter($adapter); +$audit = new Audit($adapter); $audit->setup(); // Creates database and table // Use as normal @@ -278,7 +270,7 @@ Then use your custom adapter: ```php $adapter = new CustomAdapter(); -$audit = Audit::withAdapter($adapter); +$audit = new Audit($adapter); ``` ## System Requirements diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 0bcfff3..3be84fb 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -93,7 +93,7 @@ public function createBatch(array $logs): array } }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -115,7 +115,7 @@ public function getByUser(string $userId, array $queries = []): array ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -157,7 +157,7 @@ public function getByResource(string $resource, array $queries = []): array ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -203,7 +203,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -251,7 +251,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** From db3d035fa04db6fe760fb0f562cb82f074264d2d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 12:02:16 +0000 Subject: [PATCH 32/50] fix healthcheck --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 990aa88..8614fe1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: - "8123:8123" - "9000:9000" healthcheck: - test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "-q", "SELECT 1"] + test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "--query=SELECT 1", "--format=Null"] interval: 5s timeout: 3s retries: 10 From 257c1fc4f48aa32d2a398242c96a9ba97acac165 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 9 Dec 2025 12:05:54 +0000 Subject: [PATCH 33/50] fix escaping --- src/Audit/Adapter/ClickHouse.php | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index ed31bd9..e9b09a6 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -772,10 +772,11 @@ public function getByUser(string $userId, array $queries = []): array $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT " . $this->getSelectColumns() . " - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE userId = :userId{$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset @@ -800,10 +801,11 @@ public function countByUser(string $userId, array $queries = []): int { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT count() as count - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE userId = :userId{$tenantFilter} FORMAT TabSeparated "; @@ -826,10 +828,11 @@ public function getByResource(string $resource, array $queries = []): array $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT " . $this->getSelectColumns() . " - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE resource = :resource{$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset @@ -854,10 +857,11 @@ public function countByResource(string $resource, array $queries = []): int { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT count() as count - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE resource = :resource{$tenantFilter} FORMAT TabSeparated "; @@ -881,10 +885,11 @@ public function getByUserAndEvents(string $userId, array $events, array $queries $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT " . $this->getSelectColumns() . " - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset @@ -910,10 +915,11 @@ public function countByUserAndEvents(string $userId, array $events, array $queri $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT count() as count - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} FORMAT TabSeparated "; @@ -937,10 +943,11 @@ public function getByResourceAndEvents(string $resource, array $events, array $q $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT " . $this->getSelectColumns() . " - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} ORDER BY time DESC LIMIT :limit OFFSET :offset @@ -966,10 +973,11 @@ public function countByResourceAndEvents(string $resource, array $events, array $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " SELECT count() as count - FROM {$this->database}.{$tableName} + FROM {$escapedTable} WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} FORMAT TabSeparated "; @@ -990,11 +998,12 @@ public function cleanup(string $datetime): bool { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) // Falls back to ALTER TABLE DELETE with mutations_sync for older versions $sql = " - DELETE FROM {$this->database}.{$tableName} + DELETE FROM {$escapedTable} WHERE time < :datetime{$tenantFilter} "; From 9ed6d87e55df5b7db9270cef27f3c1da427c7b7c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 01:51:04 +0000 Subject: [PATCH 34/50] Feat: add specialized methods with additional query params --- src/Audit/Adapter.php | 72 ++++++++--- src/Audit/Adapter/ClickHouse.php | 210 ++++++++++++++++++++----------- src/Audit/Adapter/Database.php | 189 ++++++++++++++++++++-------- src/Audit/Audit.php | 60 +++++---- tests/Audit/AuditBase.php | 22 ++-- 5 files changed, 377 insertions(+), 176 deletions(-) diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index aa377ff..1725b37 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -65,93 +65,133 @@ abstract public function createBatch(array $logs): array; * Get logs by user ID. * * @param string $userId - * @param array $queries Additional query parameters * @return array * * @throws \Exception */ - abstract public function getByUser(string $userId, array $queries = []): array; + abstract public function getByUser( + string $userId, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; /** * Count logs by user ID. * * @param string $userId - * @param array $queries Additional query parameters * @return int * * @throws \Exception */ - abstract public function countByUser(string $userId, array $queries = []): int; + abstract public function countByUser( + string $userId, + ?string $after = null, + ?string $before = null, + ): int; /** * Get logs by resource. * * @param string $resource - * @param array $queries Additional query parameters * @return array * * @throws \Exception */ - abstract public function getByResource(string $resource, array $queries = []): array; + abstract public function getByResource( + string $resource, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; /** * Count logs by resource. * * @param string $resource - * @param array $queries Additional query parameters * @return int * * @throws \Exception */ - abstract public function countByResource(string $resource, array $queries = []): int; + abstract public function countByResource( + string $resource, + ?string $after = null, + ?string $before = null, + ): int; /** * Get logs by user and events. * * @param string $userId * @param array $events - * @param array $queries Additional query parameters * @return array * * @throws \Exception */ - abstract public function getByUserAndEvents(string $userId, array $events, array $queries = []): array; + abstract public function getByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; /** * Count logs by user and events. * * @param string $userId * @param array $events - * @param array $queries Additional query parameters * @return int * * @throws \Exception */ - abstract public function countByUserAndEvents(string $userId, array $events, array $queries = []): int; + abstract public function countByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + ): int; /** * Get logs by resource and events. * * @param string $resource * @param array $events - * @param array $queries Additional query parameters * @return array * * @throws \Exception */ - abstract public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array; + abstract public function getByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; /** * Count logs by resource and events. * * @param string $resource * @param array $events - * @param array $queries Additional query parameters * @return int * * @throws \Exception */ - abstract public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int; + abstract public function countByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + ): int; /** * Delete logs older than the specified datetime. diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index e9b09a6..0572dc9 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -697,27 +697,43 @@ private function getTenantFilter(): string } /** - * Parse limit and offset from query parameters. + * Build time WHERE clause and parameters. * - * @param array $queries - * @return array + * @param string|null $after + * @param string|null $before + * @return array{clause: string, params: array} */ - private function parseQueryParams(array $queries): array + private function buildTimeClause(?string $after, ?string $before): array { - $limit = 25; - $offset = 0; - - foreach ($queries as $query) { - if (is_object($query) && method_exists($query, 'getMethod') && method_exists($query, 'getValue')) { - if ($query->getMethod() === 'limit') { - $limit = (int) $query->getValue(); - } elseif ($query->getMethod() === 'offset') { - $offset = (int) $query->getValue(); - } - } + $params = []; + $conditions = []; + + if ($after !== null && $before !== null) { + $conditions[] = 'time BETWEEN :after AND :before'; + $params['after'] = $after; + $params['before'] = $before; + + return ['clause' => ' AND ' . $conditions[0], 'params' => $params]; + } + + if ($after !== null) { + $conditions[] = 'time > :after'; + $params['after'] = $after; + } + + if ($before !== null) { + $conditions[] = 'time < :before'; + $params['before'] = $before; } - return ['limit' => $limit, 'offset' => $offset]; + if ($conditions === []) { + return ['clause' => '', 'params' => []]; + } + + return [ + 'clause' => ' AND ' . implode(' AND ', $conditions), + 'params' => $params, + ]; } /** @@ -729,7 +745,7 @@ private function parseQueryParams(array $queries): array */ private function buildEventsList(array $events): string { - return implode("', '", array_map(fn ($e) => $this->escapeString($e), $events)); + return implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); } /** @@ -764,11 +780,16 @@ protected function getColumnDefinition(string $id): string * * @throws Exception */ - public function getByUser(string $userId, array $queries = []): array - { - $params = $this->parseQueryParams($queries); - $limit = $params['limit']; - $offset = $params['offset']; + public function getByUser( + string $userId, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -777,17 +798,17 @@ public function getByUser(string $userId, array $queries = []): array $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE userId = :userId{$tenantFilter} - ORDER BY time DESC + WHERE userId = :userId{$tenantFilter}{$time['clause']} + ORDER BY time {$order} LIMIT :limit OFFSET :offset FORMAT TabSeparated "; - $result = $this->query($sql, [ + $result = $this->query($sql, array_merge([ 'userId' => $userId, 'limit' => $limit, 'offset' => $offset, - ]); + ], $time['params'])); return $this->parseResults($result); } @@ -797,8 +818,13 @@ public function getByUser(string $userId, array $queries = []): array * * @throws Exception */ - public function countByUser(string $userId, array $queries = []): int - { + public function countByUser( + string $userId, + ?string $after = null, + ?string $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -806,11 +832,13 @@ public function countByUser(string $userId, array $queries = []): int $sql = " SELECT count() as count FROM {$escapedTable} - WHERE userId = :userId{$tenantFilter} + WHERE userId = :userId{$tenantFilter}{$time['clause']} FORMAT TabSeparated "; - $result = $this->query($sql, ['userId' => $userId]); + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + ], $time['params'])); return (int) trim($result); } @@ -820,11 +848,16 @@ public function countByUser(string $userId, array $queries = []): int * * @throws Exception */ - public function getByResource(string $resource, array $queries = []): array - { - $params = $this->parseQueryParams($queries); - $limit = $params['limit']; - $offset = $params['offset']; + public function getByResource( + string $resource, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -833,17 +866,17 @@ public function getByResource(string $resource, array $queries = []): array $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE resource = :resource{$tenantFilter} - ORDER BY time DESC + WHERE resource = :resource{$tenantFilter}{$time['clause']} + ORDER BY time {$order} LIMIT :limit OFFSET :offset FORMAT TabSeparated "; - $result = $this->query($sql, [ + $result = $this->query($sql, array_merge([ 'resource' => $resource, 'limit' => $limit, 'offset' => $offset, - ]); + ], $time['params'])); return $this->parseResults($result); } @@ -853,8 +886,13 @@ public function getByResource(string $resource, array $queries = []): array * * @throws Exception */ - public function countByResource(string $resource, array $queries = []): int - { + public function countByResource( + string $resource, + ?string $after = null, + ?string $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -862,11 +900,13 @@ public function countByResource(string $resource, array $queries = []): int $sql = " SELECT count() as count FROM {$escapedTable} - WHERE resource = :resource{$tenantFilter} + WHERE resource = :resource{$tenantFilter}{$time['clause']} FORMAT TabSeparated "; - $result = $this->query($sql, ['resource' => $resource]); + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + ], $time['params'])); return (int) trim($result); } @@ -876,12 +916,17 @@ public function countByResource(string $resource, array $queries = []): int * * @throws Exception */ - public function getByUserAndEvents(string $userId, array $events, array $queries = []): array - { - $params = $this->parseQueryParams($queries); - $limit = $params['limit']; - $offset = $params['offset']; - + public function getByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -890,17 +935,17 @@ public function getByUserAndEvents(string $userId, array $events, array $queries $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} - ORDER BY time DESC + WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + ORDER BY time {$order} LIMIT :limit OFFSET :offset FORMAT TabSeparated "; - $result = $this->query($sql, [ + $result = $this->query($sql, array_merge([ 'userId' => $userId, 'limit' => $limit, 'offset' => $offset, - ]); + ], $time['params'])); return $this->parseResults($result); } @@ -910,21 +955,28 @@ public function getByUserAndEvents(string $userId, array $events, array $queries * * @throws Exception */ - public function countByUserAndEvents(string $userId, array $events, array $queries = []): int - { + public function countByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " - SELECT count() as count + SELECT count() FROM {$escapedTable} - WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter} + WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} FORMAT TabSeparated "; - $result = $this->query($sql, ['userId' => $userId]); + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + ], $time['params'])); return (int) trim($result); } @@ -934,12 +986,17 @@ public function countByUserAndEvents(string $userId, array $events, array $queri * * @throws Exception */ - public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array - { - $params = $this->parseQueryParams($queries); - $limit = $params['limit']; - $offset = $params['offset']; - + public function getByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); @@ -948,17 +1005,17 @@ public function getByResourceAndEvents(string $resource, array $events, array $q $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} - ORDER BY time DESC + WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + ORDER BY time {$order} LIMIT :limit OFFSET :offset FORMAT TabSeparated "; - $result = $this->query($sql, [ + $result = $this->query($sql, array_merge([ 'resource' => $resource, 'limit' => $limit, 'offset' => $offset, - ]); + ], $time['params'])); return $this->parseResults($result); } @@ -968,21 +1025,28 @@ public function getByResourceAndEvents(string $resource, array $events, array $q * * @throws Exception */ - public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int - { + public function countByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); $eventsList = $this->buildEventsList($events); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " - SELECT count() as count + SELECT count() FROM {$escapedTable} - WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter} + WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} FORMAT TabSeparated "; - $result = $this->query($sql, ['resource' => $resource]); + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + ], $time['params'])); return (int) trim($result); } diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 3be84fb..9aee53e 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -93,21 +93,59 @@ public function createBatch(array $logs): array } }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); + } + + /** + * Build time-related query conditions. + * + * @param string|null $after + * @param string|null $before + * @return array + */ + private function buildTimeQueries(?string $after, ?string $before): array + { + $queries = []; + + if ($after !== null && $before !== null) { + $queries[] = Query::between('time', $after, $before); + return $queries; + } + + if ($after !== null) { + $queries[] = Query::greaterThan('time', $after); + } + + if ($before !== null) { + $queries[] = Query::lessThan('time', $before); + } + + return $queries; } /** * Get audit logs by user ID. * - * @param array $queries * @return array * @throws AuthorizationException|\Exception */ - public function getByUser(string $userId, array $queries = []): array - { - $documents = $this->db->getAuthorization()->skip(function () use ($userId, $queries) { - $queries[] = Query::equal('userId', [$userId]); - $queries[] = Query::orderDesc(); + public function getByUser( + string $userId, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $timeQueries = $this->buildTimeQueries($after, $before); + $documents = $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries, $limit, $offset, $ascending) { + $queries = [ + Query::equal('userId', [$userId]), + ...$timeQueries, + $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), + Query::limit($limit), + Query::offset($offset), + ]; return $this->db->find( collection: $this->getCollectionName(), @@ -115,23 +153,26 @@ public function getByUser(string $userId, array $queries = []): array ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** * Count audit logs by user ID. * - * @param array $queries * @throws AuthorizationException|\Exception */ - public function countByUser(string $userId, array $queries = []): int - { - return $this->db->getAuthorization()->skip(function () use ($userId, $queries) { + public function countByUser( + string $userId, + ?string $after = null, + ?string $before = null, + ): int { + $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries) { return $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('userId', [$userId]), - ...$queries, + ...$timeQueries, ] ); }); @@ -141,15 +182,26 @@ public function countByUser(string $userId, array $queries = []): int * Get logs by resource. * * @param string $resource - * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ - public function getByResource(string $resource, array $queries = []): array - { - $documents = $this->db->getAuthorization()->skip(function () use ($resource, $queries) { - $queries[] = Query::equal('resource', [$resource]); - $queries[] = Query::orderDesc(); + public function getByResource( + string $resource, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $timeQueries = $this->buildTimeQueries($after, $before); + $documents = $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries, $limit, $offset, $ascending) { + $queries = [ + Query::equal('resource', [$resource]), + ...$timeQueries, + $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), + Query::limit($limit), + Query::offset($offset), + ]; return $this->db->find( collection: $this->getCollectionName(), @@ -157,25 +209,28 @@ public function getByResource(string $resource, array $queries = []): array ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** * Count logs by resource. * * @param string $resource - * @param array $queries * @return int * @throws \Utopia\Database\Exception */ - public function countByResource(string $resource, array $queries = []): int - { - return $this->db->getAuthorization()->skip(function () use ($resource, $queries) { + public function countByResource( + string $resource, + ?string $after = null, + ?string $before = null, + ): int { + $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries) { return $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('resource', [$resource]), - ...$queries, + ...$timeQueries, ] ); }); @@ -186,16 +241,28 @@ public function countByResource(string $resource, array $queries = []): int * * @param string $userId * @param array $events - * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ - public function getByUserAndEvents(string $userId, array $events, array $queries = []): array - { - $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { - $queries[] = Query::equal('userId', [$userId]); - $queries[] = Query::equal('event', $events); - $queries[] = Query::orderDesc(); + public function getByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $timeQueries = $this->buildTimeQueries($after, $before); + $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries, $limit, $offset, $ascending) { + $queries = [ + Query::equal('userId', [$userId]), + Query::equal('event', $events), + ...$timeQueries, + $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), + Query::limit($limit), + Query::offset($offset), + ]; return $this->db->find( collection: $this->getCollectionName(), @@ -203,7 +270,7 @@ public function getByUserAndEvents(string $userId, array $events, array $queries ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -211,19 +278,23 @@ public function getByUserAndEvents(string $userId, array $events, array $queries * * @param string $userId * @param array $events - * @param array $queries * @return int * @throws \Utopia\Database\Exception */ - public function countByUserAndEvents(string $userId, array $events, array $queries = []): int - { - return $this->db->getAuthorization()->skip(function () use ($userId, $events, $queries) { + public function countByUserAndEvents( + string $userId, + array $events, + ?string $after = null, + ?string $before = null, + ): int { + $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries) { return $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('userId', [$userId]), Query::equal('event', $events), - ...$queries, + ...$timeQueries, ] ); }); @@ -234,16 +305,28 @@ public function countByUserAndEvents(string $userId, array $events, array $queri * * @param string $resource * @param array $events - * @param array $queries * @return array * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query */ - public function getByResourceAndEvents(string $resource, array $events, array $queries = []): array - { - $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { - $queries[] = Query::equal('resource', [$resource]); - $queries[] = Query::equal('event', $events); - $queries[] = Query::orderDesc(); + public function getByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $timeQueries = $this->buildTimeQueries($after, $before); + $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries, $limit, $offset, $ascending) { + $queries = [ + Query::equal('resource', [$resource]), + Query::equal('event', $events), + ...$timeQueries, + $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), + Query::limit($limit), + Query::offset($offset), + ]; return $this->db->find( collection: $this->getCollectionName(), @@ -251,7 +334,7 @@ public function getByResourceAndEvents(string $resource, array $events, array $q ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -259,19 +342,23 @@ public function getByResourceAndEvents(string $resource, array $events, array $q * * @param string $resource * @param array $events - * @param array $queries * @return int * @throws \Utopia\Database\Exception */ - public function countByResourceAndEvents(string $resource, array $events, array $queries = []): int - { - return $this->db->getAuthorization()->skip(function () use ($resource, $events, $queries) { + public function countByResourceAndEvents( + string $resource, + array $events, + ?string $after = null, + ?string $before = null, + ): int { + $timeQueries = $this->buildTimeQueries($after, $before); + return $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries) { return $this->db->count( collection: $this->getCollectionName(), queries: [ Query::equal('resource', [$resource]), Query::equal('event', $events), - ...$queries, + ...$timeQueries, ] ); }); diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 6dcdf12..e179283 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -90,63 +90,69 @@ public function logBatch(array $events): array * Get all logs by user ID. * * @param string $userId - * @param array $queries * @return array * * @throws \Exception */ public function getLogsByUser( string $userId, - array $queries = [] + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): array { - return $this->adapter->getByUser($userId, $queries); + return $this->adapter->getByUser($userId, $after, $before, $limit, $offset, $ascending); } /** * Count logs by user ID. * * @param string $userId - * @param array $queries * @return int * @throws \Exception */ public function countLogsByUser( string $userId, - array $queries = [] + ?string $after = null, + ?string $before = null, ): int { - return $this->adapter->countByUser($userId, $queries); + return $this->adapter->countByUser($userId, $after, $before); } /** * Get all logs by resource. * * @param string $resource - * @param array $queries * @return array * * @throws \Exception */ public function getLogsByResource( string $resource, - array $queries = [], + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): array { - return $this->adapter->getByResource($resource, $queries); + return $this->adapter->getByResource($resource, $after, $before, $limit, $offset, $ascending); } /** * Count logs by resource. * * @param string $resource - * @param array $queries * @return int * * @throws \Exception */ public function countLogsByResource( string $resource, - array $queries = [] + ?string $after = null, + ?string $before = null, ): int { - return $this->adapter->countByResource($resource, $queries); + return $this->adapter->countByResource($resource, $after, $before); } /** @@ -154,7 +160,6 @@ public function countLogsByResource( * * @param string $userId * @param array $events - * @param array $queries * @return array * * @throws \Exception @@ -162,9 +167,13 @@ public function countLogsByResource( public function getLogsByUserAndEvents( string $userId, array $events, - array $queries = [], + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): array { - return $this->adapter->getByUserAndEvents($userId, $events, $queries); + return $this->adapter->getByUserAndEvents($userId, $events, $after, $before, $limit, $offset, $ascending); } /** @@ -172,7 +181,6 @@ public function getLogsByUserAndEvents( * * @param string $userId * @param array $events - * @param array $queries * @return int * * @throws \Exception @@ -180,9 +188,10 @@ public function getLogsByUserAndEvents( public function countLogsByUserAndEvents( string $userId, array $events, - array $queries = [], + ?string $after = null, + ?string $before = null, ): int { - return $this->adapter->countByUserAndEvents($userId, $events, $queries); + return $this->adapter->countByUserAndEvents($userId, $events, $after, $before); } /** @@ -190,7 +199,6 @@ public function countLogsByUserAndEvents( * * @param string $resource * @param array $events - * @param array $queries * @return array * * @throws \Exception @@ -198,9 +206,13 @@ public function countLogsByUserAndEvents( public function getLogsByResourceAndEvents( string $resource, array $events, - array $queries = [], + ?string $after = null, + ?string $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): array { - return $this->adapter->getByResourceAndEvents($resource, $events, $queries); + return $this->adapter->getByResourceAndEvents($resource, $events, $after, $before, $limit, $offset, $ascending); } /** @@ -208,7 +220,6 @@ public function getLogsByResourceAndEvents( * * @param string $resource * @param array $events - * @param array $queries * @return int * * @throws \Exception @@ -216,9 +227,10 @@ public function getLogsByResourceAndEvents( public function countLogsByResourceAndEvents( string $resource, array $events, - array $queries = [], + ?string $after = null, + ?string $before = null, ): int { - return $this->adapter->countByResourceAndEvents($resource, $events, $queries); + return $this->adapter->countByResourceAndEvents($resource, $events, $after, $before); } /** diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 5956353..befa1ab 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -5,7 +5,6 @@ use Utopia\Audit\Audit; use Utopia\Audit\Log; use Utopia\Database\DateTime; -use Utopia\Database\Query; /** * Audit Test Trait @@ -65,11 +64,11 @@ public function testGetLogsByUser(): void $logsCount = $this->audit->countLogsByUser('userId'); $this->assertEquals(3, $logsCount); - $logs1 = $this->audit->getLogsByUser('userId', [Query::limit(1), Query::offset(1)]); + $logs1 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); $this->assertEquals(1, \count($logs1)); $this->assertEquals($logs1[0]->getId(), $logs[1]->getId()); - $logs2 = $this->audit->getLogsByUser('userId', [Query::limit(1), Query::offset(1)]); + $logs2 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); $this->assertEquals(1, \count($logs2)); $this->assertEquals($logs2[0]->getId(), $logs[1]->getId()); } @@ -88,12 +87,12 @@ public function testGetLogsByUserAndEvents(): void $this->assertEquals(2, $logsCount1); $this->assertEquals(3, $logsCount2); - $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], [Query::limit(1), Query::offset(1)]); + $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); $this->assertEquals(1, \count($logs3)); $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - $logs4 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], [Query::limit(1), Query::offset(1)]); + $logs4 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); $this->assertEquals(1, \count($logs4)); $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); @@ -113,12 +112,12 @@ public function testGetLogsByResourceAndEvents(): void $this->assertEquals(1, $logsCount1); $this->assertEquals(2, $logsCount2); - $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], [Query::limit(1), Query::offset(1)]); + $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); $this->assertEquals(1, \count($logs3)); $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - $logs4 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], [Query::limit(1), Query::offset(1)]); + $logs4 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); $this->assertEquals(1, \count($logs4)); $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); @@ -138,11 +137,11 @@ public function testGetLogsByResource(): void $this->assertEquals(1, $logsCount1); $this->assertEquals(2, $logsCount2); - $logs3 = $this->audit->getLogsByResource('database/document/2', [Query::limit(1), Query::offset(1)]); + $logs3 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); $this->assertEquals(1, \count($logs3)); $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); - $logs4 = $this->audit->getLogsByResource('database/document/2', [Query::limit(1), Query::offset(1)]); + $logs4 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); $this->assertEquals(1, \count($logs4)); $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); @@ -249,9 +248,8 @@ public function testLogByBatch(): void public function testGetLogsCustomFilters(): void { - $logs = $this->audit->getLogsByUser('userId', queries: [ - Query::greaterThan('time', DateTime::addSeconds(new \DateTime(), -10)) - ]); + $threshold = DateTime::addSeconds(new \DateTime(), -10); + $logs = $this->audit->getLogsByUser('userId', after: $threshold); $this->assertEquals(3, \count($logs)); } From 874de634869a006163b81dff653aec554dcbcdb2 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 02:30:27 +0000 Subject: [PATCH 35/50] Update clickhouse to a safe approach --- src/Audit/Adapter/ClickHouse.php | 310 +++++++++++++++++-------------- src/Audit/Adapter/Database.php | 10 +- 2 files changed, 179 insertions(+), 141 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0572dc9..1937824 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -152,27 +152,6 @@ private function escapeIdentifier(string $identifier): string // Backtick escaping: replace any backticks in the identifier with double backticks return '`' . str_replace('`', '``', $identifier) . '`'; } - - /** - * Escape a string value for safe use in ClickHouse SQL queries. - * ClickHouse uses SQL standard escaping: single quotes are escaped by doubling them. - * This is critical for preventing SQL injection attacks. - * - * @param string $value - * @return string The escaped value without surrounding quotes - */ - private function escapeString(string $value): string - { - // ClickHouse SQL standard: escape single quotes by doubling them - // Also escape backslashes to prevent any potential issues - return str_replace( - ["\\", "'"], - ["\\\\", "''"], - $value - ); - } - - /** * Set the namespace for multi-project support. * Namespace is used as a prefix for table names. @@ -312,10 +291,20 @@ private function formatTimestamp(string $timestamp): string /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * - * Reuses the HTTP client from the constructor to enable connection pooling - * and improve performance with frequent queries. + * Uses ClickHouse query parameters (sent as POST multipart form data) to prevent SQL injection. + * This is ClickHouse's native parameter mechanism - parameters are safely + * transmitted separately from the query structure. + * + * Parameters are referenced in the SQL using the syntax: {paramName:Type}. + * For example: SELECT * WHERE id = {id:String} + * + * ClickHouse handles all parameter escaping and type conversion internally, + * making this approach fully injection-safe without needing manual escaping. * - * @param array $params + * Using POST body avoids URL length limits for batch operations with many parameters. + * Equivalent to: curl -X POST -F 'query=...' -F 'param_key=value' http://host/ + * + * @param array $params Key-value pairs for query parameters * @throws Exception */ private function query(string $sql, array $params = []): string @@ -323,44 +312,25 @@ private function query(string $sql, array $params = []): string $scheme = $this->secure ? 'https' : 'http'; $url = "{$scheme}://{$this->host}:{$this->port}/"; - // Replace parameters in query - foreach ($params as $key => $value) { - if (is_int($value) || is_float($value)) { - // Numeric values should not be quoted - $strValue = (string) $value; - } elseif (is_string($value)) { - $strValue = "'" . $this->escapeString($value) . "'"; - } elseif (is_null($value)) { - $strValue = 'NULL'; - } elseif (is_bool($value)) { - $strValue = $value ? '1' : '0'; - } elseif (is_array($value)) { - $encoded = json_encode($value); - if (is_string($encoded)) { - $strValue = "'" . $this->escapeString($encoded) . "'"; - } else { - $strValue = 'NULL'; - } - } else { - /** @var scalar $value */ - $strValue = "'" . $this->escapeString((string) $value) . "'"; - } - $sql = str_replace(":{$key}", $strValue, $sql); - } - // Update the database header for each query (in case setDatabase was called) $this->client->addHeader('X-ClickHouse-Database', $this->database); + // Build multipart form data body with query and parameters + // The Fetch client will automatically encode arrays as multipart/form-data + $body = ['query' => $sql]; + foreach ($params as $key => $value) { + $body['param_' . $key] = $this->formatParamValue($value); + } + try { $response = $this->client->fetch( url: $url, method: Client::METHOD_POST, - body: ['query' => $sql] + body: $body ); - if ($response->getStatusCode() !== 200) { - $body = $response->getBody(); - $bodyStr = is_string($body) ? $body : ''; + $bodyStr = $response->getBody(); + $bodyStr = is_string($bodyStr) ? $bodyStr : ''; throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$bodyStr}"); } @@ -377,6 +347,46 @@ private function query(string $sql, array $params = []): string } } + /** + * Format a parameter value for safe transmission to ClickHouse. + * + * Converts PHP values to their string representation without SQL quoting. + * ClickHouse's query parameter mechanism handles type conversion and escaping. + * + * @param mixed $value + * @return string + */ + private function formatParamValue(mixed $value): string + { + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value)) { + $encoded = json_encode($value); + return is_string($encoded) ? $encoded : ''; + } + + if (is_string($value)) { + return $value; + } + + // For objects or other types, attempt to convert to string + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return ''; + } + /** * Setup ClickHouse table structure. * @@ -460,7 +470,7 @@ public function create(array $log): Log // Build column list and values based on sharedTables setting $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; - $placeholders = [':id', ':userId', ':event', ':resource', ':userAgent', ':ip', ':location', ':time', ':data']; + $placeholders = ['{id:String}', '{userId:Nullable(String)}', '{event:String}', '{resource:String}', '{userAgent:String}', '{ip:String}', '{location:Nullable(String)}', '{time:String}', '{data:String}']; $params = [ 'id' => $id, @@ -476,7 +486,7 @@ public function create(array $log): Log if ($this->sharedTables) { $columns[] = 'tenant'; - $placeholders[] = ':tenant'; + $placeholders[] = '{tenant:Nullable(UInt64)}'; $params['tenant'] = $this->tenant; } @@ -521,68 +531,74 @@ public function createBatch(array $logs): array return []; } - $values = []; + $tableName = $this->getTableName(); + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + // Build column list based on sharedTables setting + $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; + if ($this->sharedTables) { + $columns[] = 'tenant'; + } + $ids = []; + $paramCounter = 0; + $params = []; + $valueClauses = []; + foreach ($logs as $log) { $id = uniqid('', true); $ids[] = $id; - $userIdVal = $log['userId'] ?? null; - $userId = ($userIdVal !== null) - ? "'" . $this->escapeString((string) $userIdVal) . "'" - : 'NULL'; - $locationVal = $log['location'] ?? null; - $location = ($locationVal !== null) - ? "'" . $this->escapeString((string) $locationVal) . "'" - : 'NULL'; - $formattedTimestamp = $this->formatTimestamp($log['time']); + // Create parameter placeholders for this row + $paramKeys = []; + $paramKeys[] = 'id_' . $paramCounter; + $paramKeys[] = 'userId_' . $paramCounter; + $paramKeys[] = 'event_' . $paramCounter; + $paramKeys[] = 'resource_' . $paramCounter; + $paramKeys[] = 'userAgent_' . $paramCounter; + $paramKeys[] = 'ip_' . $paramCounter; + $paramKeys[] = 'location_' . $paramCounter; + $paramKeys[] = 'time_' . $paramCounter; + $paramKeys[] = 'data_' . $paramCounter; + + // Set parameter values + $params[$paramKeys[0]] = $id; + $params[$paramKeys[1]] = $log['userId'] ?? null; + $params[$paramKeys[2]] = $log['event']; + $params[$paramKeys[3]] = $log['resource']; + $params[$paramKeys[4]] = $log['userAgent']; + $params[$paramKeys[5]] = $log['ip']; + $params[$paramKeys[6]] = $log['location'] ?? null; + $params[$paramKeys[7]] = $this->formatTimestamp($log['time']); + $params[$paramKeys[8]] = json_encode($log['data'] ?? []); if ($this->sharedTables) { - $tenant = $this->tenant !== null ? (int) $this->tenant : 'NULL'; - $values[] = sprintf( - "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s', %s)", - $id, - $userId, - $this->escapeString((string) $log['event']), - $this->escapeString((string) $log['resource']), - $this->escapeString((string) $log['userAgent']), - $this->escapeString((string) $log['ip']), - $location, - $formattedTimestamp, - $this->escapeString((string) json_encode($log['data'] ?? [])), - $tenant - ); - } else { - $values[] = sprintf( - "('%s', %s, '%s', '%s', '%s', '%s', %s, '%s', '%s')", - $id, - $userId, - $this->escapeString((string) $log['event']), - $this->escapeString((string) $log['resource']), - $this->escapeString((string) $log['userAgent']), - $this->escapeString((string) $log['ip']), - $location, - $formattedTimestamp, - $this->escapeString((string) json_encode($log['data'] ?? [])) - ); + $paramKeys[] = 'tenant_' . $paramCounter; + $params[$paramKeys[9]] = $this->tenant; } - } - $tableName = $this->getTableName(); + // Build placeholder string for this row + $placeholders = []; + for ($i = 0; $i < count($paramKeys); $i++) { + if ($i === 1 || $i === 6) { // userId and location are nullable + $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(String)}'; + } elseif ($this->sharedTables && $i === 9) { // tenant is nullable UInt64 + $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(UInt64)}'; + } else { + $placeholders[] = '{' . $paramKeys[$i] . ':String}'; + } + } - // Build column list based on sharedTables setting - $columns = 'id, userId, event, resource, userAgent, ip, location, time, data'; - if ($this->sharedTables) { - $columns .= ', tenant'; + $valueClauses[] = '(' . implode(', ', $placeholders) . ')'; + $paramCounter++; } - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $insertSql = " INSERT INTO {$escapedDatabaseAndTable} - ({$columns}) - VALUES " . implode(', ', $values); + (" . implode(', ', $columns) . ") + VALUES " . implode(', ', $valueClauses); - $this->query($insertSql); + $this->query($insertSql, $params); // Return documents using the same IDs that were inserted $documents = []; @@ -645,21 +661,30 @@ private function parseResults(string $result): array $time = str_replace(' ', 'T', $time) . '+00:00'; } + // Helper function to parse nullable string fields + // ClickHouse TabSeparated format uses \N for NULL, but empty strings are also treated as null for nullable fields + $parseNullableString = static function ($value): ?string { + if ($value === '\\N' || $value === '') { + return null; + } + return $value; + }; + $document = [ '$id' => $columns[0], - 'userId' => $columns[1] === '\\N' ? null : $columns[1], + 'userId' => $parseNullableString($columns[1]), 'event' => $columns[2], 'resource' => $columns[3], 'userAgent' => $columns[4], 'ip' => $columns[5], - 'location' => $columns[6] === '\\N' ? null : $columns[6], + 'location' => $parseNullableString($columns[6]), 'time' => $time, 'data' => $data, ]; // Add tenant only if sharedTables is enabled if ($this->sharedTables && isset($columns[9])) { - $document['tenant'] = $columns[9] === '\\N' ? null : (int) $columns[9]; + $document['tenant'] = $columns[9] === '\\N' || $columns[9] === '' ? null : (int) $columns[9]; } $documents[] = new Log($document); @@ -697,7 +722,7 @@ private function getTenantFilter(): string } /** - * Build time WHERE clause and parameters. + * Build time WHERE clause and parameters with safe parameter placeholders. * * @param string|null $after * @param string|null $before @@ -709,7 +734,7 @@ private function buildTimeClause(?string $after, ?string $before): array $conditions = []; if ($after !== null && $before !== null) { - $conditions[] = 'time BETWEEN :after AND :before'; + $conditions[] = 'time BETWEEN {after:String} AND {before:String}'; $params['after'] = $after; $params['before'] = $before; @@ -717,12 +742,12 @@ private function buildTimeClause(?string $after, ?string $before): array } if ($after !== null) { - $conditions[] = 'time > :after'; + $conditions[] = 'time > {after:String}'; $params['after'] = $after; } if ($before !== null) { - $conditions[] = 'time < :before'; + $conditions[] = 'time < {before:String}'; $params['before'] = $before; } @@ -738,14 +763,27 @@ private function buildTimeClause(?string $after, ?string $before): array /** * Build a formatted SQL IN list from an array of events. - * Properly escapes each event for safe SQL inclusion. + * Events are parameterized for safe SQL inclusion. * * @param array $events - * @return string Formatted as 'event1', 'event2', 'event3' + * @param int $paramOffset Base parameter number for creating unique param names + * @return array{clause: string, params: array} */ - private function buildEventsList(array $events): string + private function buildEventsList(array $events, int $paramOffset = 0): array { - return implode("', '", array_map(fn($e) => $this->escapeString($e), $events)); + $placeholders = []; + $params = []; + + foreach ($events as $index => $event) { + /** @var int $paramNumber */ + $paramNumber = $paramOffset + (int) $index; + $paramName = 'event_' . (string) $paramNumber; + $placeholders[] = '{' . $paramName . ':String}'; + $params[$paramName] = $event; + } + + $clause = implode(', ', $placeholders); + return ['clause' => $clause, 'params' => $params]; } /** @@ -798,9 +836,9 @@ public function getByUser( $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE userId = :userId{$tenantFilter}{$time['clause']} + WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} ORDER BY time {$order} - LIMIT :limit OFFSET :offset + LIMIT {limit:UInt64} OFFSET {offset:UInt64} FORMAT TabSeparated "; @@ -830,9 +868,9 @@ public function countByUser( $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " - SELECT count() as count + SELECT count() FROM {$escapedTable} - WHERE userId = :userId{$tenantFilter}{$time['clause']} + WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} FORMAT TabSeparated "; @@ -866,9 +904,9 @@ public function getByResource( $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE resource = :resource{$tenantFilter}{$time['clause']} + WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} ORDER BY time {$order} - LIMIT :limit OFFSET :offset + LIMIT {limit:UInt64} OFFSET {offset:UInt64} FORMAT TabSeparated "; @@ -898,9 +936,9 @@ public function countByResource( $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); $sql = " - SELECT count() as count + SELECT count() FROM {$escapedTable} - WHERE resource = :resource{$tenantFilter}{$time['clause']} + WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} FORMAT TabSeparated "; @@ -927,7 +965,7 @@ public function getByUserAndEvents( ): array { $time = $this->buildTimeClause($after, $before); $order = $ascending ? 'ASC' : 'DESC'; - $eventsList = $this->buildEventsList($events); + $eventList = $this->buildEventsList($events, 0); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -935,9 +973,9 @@ public function getByUserAndEvents( $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} ORDER BY time {$order} - LIMIT :limit OFFSET :offset + LIMIT {limit:UInt64} OFFSET {offset:UInt64} FORMAT TabSeparated "; @@ -945,7 +983,7 @@ public function getByUserAndEvents( 'userId' => $userId, 'limit' => $limit, 'offset' => $offset, - ], $time['params'])); + ], $eventList['params'], $time['params'])); return $this->parseResults($result); } @@ -962,7 +1000,7 @@ public function countByUserAndEvents( ?string $before = null, ): int { $time = $this->buildTimeClause($after, $before); - $eventsList = $this->buildEventsList($events); + $eventList = $this->buildEventsList($events, 0); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -970,13 +1008,13 @@ public function countByUserAndEvents( $sql = " SELECT count() FROM {$escapedTable} - WHERE userId = :userId AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} FORMAT TabSeparated "; $result = $this->query($sql, array_merge([ 'userId' => $userId, - ], $time['params'])); + ], $eventList['params'], $time['params'])); return (int) trim($result); } @@ -997,7 +1035,7 @@ public function getByResourceAndEvents( ): array { $time = $this->buildTimeClause($after, $before); $order = $ascending ? 'ASC' : 'DESC'; - $eventsList = $this->buildEventsList($events); + $eventList = $this->buildEventsList($events, 0); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -1005,9 +1043,9 @@ public function getByResourceAndEvents( $sql = " SELECT " . $this->getSelectColumns() . " FROM {$escapedTable} - WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} ORDER BY time {$order} - LIMIT :limit OFFSET :offset + LIMIT {limit:UInt64} OFFSET {offset:UInt64} FORMAT TabSeparated "; @@ -1015,7 +1053,7 @@ public function getByResourceAndEvents( 'resource' => $resource, 'limit' => $limit, 'offset' => $offset, - ], $time['params'])); + ], $eventList['params'], $time['params'])); return $this->parseResults($result); } @@ -1032,7 +1070,7 @@ public function countByResourceAndEvents( ?string $before = null, ): int { $time = $this->buildTimeClause($after, $before); - $eventsList = $this->buildEventsList($events); + $eventList = $this->buildEventsList($events, 0); $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); @@ -1040,13 +1078,13 @@ public function countByResourceAndEvents( $sql = " SELECT count() FROM {$escapedTable} - WHERE resource = :resource AND event IN ('{$eventsList}'){$tenantFilter}{$time['clause']} + WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} FORMAT TabSeparated "; $result = $this->query($sql, array_merge([ 'resource' => $resource, - ], $time['params'])); + ], $eventList['params'], $time['params'])); return (int) trim($result); } @@ -1068,7 +1106,7 @@ public function cleanup(string $datetime): bool // Falls back to ALTER TABLE DELETE with mutations_sync for older versions $sql = " DELETE FROM {$escapedTable} - WHERE time < :datetime{$tenantFilter} + WHERE time < {datetime:String}{$tenantFilter} "; $this->query($sql, ['datetime' => $datetime]); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 9aee53e..10d3123 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -93,7 +93,7 @@ public function createBatch(array $logs): array } }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -153,7 +153,7 @@ public function getByUser( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -209,7 +209,7 @@ public function getByResource( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -270,7 +270,7 @@ public function getByUserAndEvents( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -334,7 +334,7 @@ public function getByResourceAndEvents( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** From 2bb91592b14a9b40c760648a7e9352367082c318 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 02:48:47 +0000 Subject: [PATCH 36/50] Clickhouse specific tests --- tests/Audit/Adapter/ClickHouseTest.php | 288 +++++++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 58f1b20..947ec8a 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -2,6 +2,7 @@ namespace Utopia\Tests\Audit\Adapter; +use Exception; use PHPUnit\Framework\TestCase; use Utopia\Audit\Adapter\ClickHouse; use Utopia\Audit\Audit; @@ -9,6 +10,9 @@ /** * ClickHouse Adapter Tests + * + * Tests ClickHouse-specific features and configurations. + * Generic audit functionality tests are in AuditBase trait. */ class ClickHouseTest extends TestCase { @@ -28,4 +32,288 @@ protected function initializeAudit(): void $this->audit = new Audit($clickHouse); $this->audit->setup(); } + + /** + * Test constructor validates host + */ + public function testConstructorValidatesHost(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('ClickHouse host is not a valid hostname or IP address'); + + new ClickHouse( + host: '', + username: 'default', + password: '' + ); + } + + /** + * Test constructor validates port range + */ + public function testConstructorValidatesPortTooLow(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); + + new ClickHouse( + host: 'localhost', + username: 'default', + password: '', + port: 0 + ); + } + + /** + * Test constructor validates port range upper bound + */ + public function testConstructorValidatesPortTooHigh(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); + + new ClickHouse( + host: 'localhost', + username: 'default', + password: '', + port: 65536 + ); + } + + /** + * Test constructor with valid parameters + */ + public function testConstructorWithValidParameters(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'testuser', + password: 'testpass', + port: 8443, + secure: true + ); + + $this->assertInstanceOf(ClickHouse::class, $adapter); + $this->assertEquals('ClickHouse', $adapter->getName()); + } + + /** + * Test getName returns correct adapter name + */ + public function testGetName(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $this->assertEquals('ClickHouse', $adapter->getName()); + } + + /** + * Test setDatabase validates empty identifier + */ + public function testSetDatabaseValidatesEmpty(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Database cannot be empty'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setDatabase(''); + } + + /** + * Test setDatabase validates identifier length + */ + public function testSetDatabaseValidatesLength(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Database cannot exceed 255 characters'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setDatabase(str_repeat('a', 256)); + } + + /** + * Test setDatabase validates identifier format + */ + public function testSetDatabaseValidatesFormat(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Database must start with a letter or underscore'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setDatabase('123invalid'); + } + + /** + * Test setDatabase rejects SQL keywords + */ + public function testSetDatabaseRejectsKeywords(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Database cannot be a reserved SQL keyword'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setDatabase('SELECT'); + } + + /** + * Test setDatabase with valid identifier + */ + public function testSetDatabaseWithValidIdentifier(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $result = $adapter->setDatabase('my_database_123'); + $this->assertInstanceOf(ClickHouse::class, $result); + } + + /** + * Test setNamespace allows empty string + */ + public function testSetNamespaceAllowsEmpty(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $result = $adapter->setNamespace(''); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals('', $adapter->getNamespace()); + } + + /** + * Test setNamespace validates identifier format + */ + public function testSetNamespaceValidatesFormat(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Namespace must start with a letter or underscore'); + + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $adapter->setNamespace('9invalid'); + } + + /** + * Test setNamespace with valid identifier + */ + public function testSetNamespaceWithValidIdentifier(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + $result = $adapter->setNamespace('project_123'); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertEquals('project_123', $adapter->getNamespace()); + } + + /** + * Test setSecure method + */ + public function testSetSecure(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse', + port: 8123, + secure: false + ); + + $result = $adapter->setSecure(true); + $this->assertInstanceOf(ClickHouse::class, $result); + } + + /** + * Test shared tables configuration + */ + public function testSharedTablesConfiguration(): void + { + $adapter = new ClickHouse( + host: 'clickhouse', + username: 'default', + password: 'clickhouse' + ); + + // Test initial state + $this->assertFalse($adapter->isSharedTables()); + $this->assertNull($adapter->getTenant()); + + // Test setting shared tables + $result = $adapter->setSharedTables(true); + $this->assertInstanceOf(ClickHouse::class, $result); + $this->assertTrue($adapter->isSharedTables()); + + // Test setting tenant + $result2 = $adapter->setTenant(12345); + $this->assertInstanceOf(ClickHouse::class, $result2); + $this->assertEquals(12345, $adapter->getTenant()); + + // Test setting tenant to null + $adapter->setTenant(null); + $this->assertNull($adapter->getTenant()); + } + + /** + * Test batch operations with special characters + */ + public function testBatchOperationsWithSpecialCharacters(): void + { + // Test batch with special characters in data + $batchEvents = [ + [ + 'userId' => 'user`with`backticks', + 'event' => 'create', + 'resource' => 'doc/"quotes"', + 'userAgent' => "User'Agent\"With'Quotes", + 'ip' => '192.168.1.1', + 'location' => 'UK', + 'data' => ['special' => "data with 'quotes'"], + 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '' + ] + ]; + + $result = $this->audit->logBatch($batchEvents); + $this->assertEquals(1, count($result)); + + // Verify retrieval + $logs = $this->audit->getLogsByUser('user`with`backticks'); + $this->assertGreaterThan(0, count($logs)); + } } From c21f861bbe715eea5366d6c2e534ada5ffa51d8e Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 02:54:29 +0000 Subject: [PATCH 37/50] additional tests --- tests/Audit/AuditBase.php | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index befa1ab..a7e3b3c 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -254,6 +254,98 @@ public function testGetLogsCustomFilters(): void $this->assertEquals(3, \count($logs)); } + public function testAscendingOrderRetrieval(): void + { + // Test ascending order retrieval + $logsDesc = $this->audit->getLogsByUser('userId', ascending: false); + $logsAsc = $this->audit->getLogsByUser('userId', ascending: true); + + // Both should have same count + $this->assertEquals(\count($logsDesc), \count($logsAsc)); + + // Events should be in opposite order + if (\count($logsDesc) > 1) { + $descEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsDesc); + $ascEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsAsc); + $this->assertEquals($descEvents, array_reverse($ascEvents)); + } + } + + public function testLargeBatchInsert(): void + { + // Create a large batch (50 events) + $batchEvents = []; + $baseTime = DateTime::now(); + for ($i = 0; $i < 50; $i++) { + $batchEvents[] = [ + 'userId' => 'largebatchuser', + 'event' => 'event_' . $i, + 'resource' => 'doc/' . $i, + 'userAgent' => 'Mozilla', + 'ip' => '127.0.0.1', + 'location' => 'US', + 'data' => ['index' => $i], + 'time' => DateTime::formatTz($baseTime) ?? '' + ]; + } + + // Insert batch + $result = $this->audit->logBatch($batchEvents); + $this->assertEquals(50, \count($result)); + + // Verify all were inserted + $count = $this->audit->countLogsByUser('largebatchuser'); + $this->assertEquals(50, $count); + + // Test pagination + $page1 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 0); + $this->assertEquals(10, \count($page1)); + + $page2 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 10); + $this->assertEquals(10, \count($page2)); + } + + public function testTimeRangeFilters(): void + { + // Create logs with different timestamps + $old = DateTime::format(new \DateTime('2024-01-01 10:00:00')); + $recent = DateTime::now(); + + $batchEvents = [ + [ + 'userId' => 'timerangeuser', + 'event' => 'old_event', + 'resource' => 'doc/1', + 'userAgent' => 'Mozilla', + 'ip' => '127.0.0.1', + 'location' => 'US', + 'data' => [], + 'time' => $old + ], + [ + 'userId' => 'timerangeuser', + 'event' => 'recent_event', + 'resource' => 'doc/2', + 'userAgent' => 'Mozilla', + 'ip' => '127.0.0.1', + 'location' => 'US', + 'data' => [], + 'time' => $recent + ] + ]; + + $this->audit->logBatch($batchEvents); + + // Test getting all logs + $all = $this->audit->getLogsByUser('timerangeuser'); + $this->assertGreaterThanOrEqual(2, \count($all)); + + // Test with before filter - should get both since they're both in the past relative to future + $beforeFuture = DateTime::format(new \DateTime('2099-12-31 23:59:59')); + $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture); + $this->assertGreaterThanOrEqual(2, \count($beforeLogs)); + } + public function testCleanup(): void { sleep(3); From 5ca5dd47dfe18af6c237de0af3ae3a55f40949c4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 03:02:53 +0000 Subject: [PATCH 38/50] update datetime --- src/Audit/Adapter.php | 32 +++--- src/Audit/Adapter/ClickHouse.php | 65 ++++++----- src/Audit/Adapter/Database.php | 53 +++++---- src/Audit/Audit.php | 32 +++--- tests/Audit/AuditBase.php | 190 ++++++++++++++++++++++++++++++- 5 files changed, 287 insertions(+), 85 deletions(-) diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index 1725b37..cc89140 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -71,8 +71,8 @@ abstract public function createBatch(array $logs): array; */ abstract public function getByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -88,8 +88,8 @@ abstract public function getByUser( */ abstract public function countByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int; /** @@ -102,8 +102,8 @@ abstract public function countByUser( */ abstract public function getByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -119,8 +119,8 @@ abstract public function getByResource( */ abstract public function countByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int; /** @@ -135,8 +135,8 @@ abstract public function countByResource( abstract public function getByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -154,8 +154,8 @@ abstract public function getByUserAndEvents( abstract public function countByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int; /** @@ -170,8 +170,8 @@ abstract public function countByUserAndEvents( abstract public function getByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -189,8 +189,8 @@ abstract public function getByResourceAndEvents( abstract public function countByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int; /** diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 1937824..7e577cc 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -724,31 +724,44 @@ private function getTenantFilter(): string /** * Build time WHERE clause and parameters with safe parameter placeholders. * - * @param string|null $after - * @param string|null $before + * @param \DateTime|null $after + * @param \DateTime|null $before * @return array{clause: string, params: array} */ - private function buildTimeClause(?string $after, ?string $before): array + private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array { $params = []; $conditions = []; - if ($after !== null && $before !== null) { + $afterStr = null; + $beforeStr = null; + + if ($after !== null) { + /** @var \DateTime $after */ + $afterStr = \Utopia\Database\DateTime::format($after); + } + + if ($before !== null) { + /** @var \DateTime $before */ + $beforeStr = \Utopia\Database\DateTime::format($before); + } + + if ($afterStr !== null && $beforeStr !== null) { $conditions[] = 'time BETWEEN {after:String} AND {before:String}'; - $params['after'] = $after; - $params['before'] = $before; + $params['after'] = $afterStr; + $params['before'] = $beforeStr; return ['clause' => ' AND ' . $conditions[0], 'params' => $params]; } - if ($after !== null) { + if ($afterStr !== null) { $conditions[] = 'time > {after:String}'; - $params['after'] = $after; + $params['after'] = $afterStr; } - if ($before !== null) { + if ($beforeStr !== null) { $conditions[] = 'time < {before:String}'; - $params['before'] = $before; + $params['before'] = $beforeStr; } if ($conditions === []) { @@ -820,8 +833,8 @@ protected function getColumnDefinition(string $id): string */ public function getByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -858,8 +871,8 @@ public function getByUser( */ public function countByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $time = $this->buildTimeClause($after, $before); @@ -888,8 +901,8 @@ public function countByUser( */ public function getByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -926,8 +939,8 @@ public function getByResource( */ public function countByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $time = $this->buildTimeClause($after, $before); @@ -957,8 +970,8 @@ public function countByResource( public function getByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -996,8 +1009,8 @@ public function getByUserAndEvents( public function countByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $time = $this->buildTimeClause($after, $before); $eventList = $this->buildEventsList($events, 0); @@ -1027,8 +1040,8 @@ public function countByUserAndEvents( public function getByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -1066,8 +1079,8 @@ public function getByResourceAndEvents( public function countByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $time = $this->buildTimeClause($after, $before); $eventList = $this->buildEventsList($events, 0); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 10d3123..2ccedd7 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -99,25 +99,28 @@ public function createBatch(array $logs): array /** * Build time-related query conditions. * - * @param string|null $after - * @param string|null $before + * @param \DateTime|null $after + * @param \DateTime|null $before * @return array */ - private function buildTimeQueries(?string $after, ?string $before): array + private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array { $queries = []; - if ($after !== null && $before !== null) { - $queries[] = Query::between('time', $after, $before); + $afterStr = $after ? DateTime::format($after) : null; + $beforeStr = $before ? DateTime::format($before) : null; + + if ($afterStr !== null && $beforeStr !== null) { + $queries[] = Query::between('time', $afterStr, $beforeStr); return $queries; } - if ($after !== null) { - $queries[] = Query::greaterThan('time', $after); + if ($afterStr !== null) { + $queries[] = Query::greaterThan('time', $afterStr); } - if ($before !== null) { - $queries[] = Query::lessThan('time', $before); + if ($beforeStr !== null) { + $queries[] = Query::lessThan('time', $beforeStr); } return $queries; @@ -131,8 +134,8 @@ private function buildTimeQueries(?string $after, ?string $before): array */ public function getByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -163,8 +166,8 @@ public function getByUser( */ public function countByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); return $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries) { @@ -187,8 +190,8 @@ public function countByUser( */ public function getByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -221,8 +224,8 @@ public function getByResource( */ public function countByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); return $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries) { @@ -247,8 +250,8 @@ public function countByResource( public function getByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -284,8 +287,8 @@ public function getByUserAndEvents( public function countByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); return $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries) { @@ -311,8 +314,8 @@ public function countByUserAndEvents( public function getByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -348,8 +351,8 @@ public function getByResourceAndEvents( public function countByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { $timeQueries = $this->buildTimeQueries($after, $before); return $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries) { diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index e179283..419a41e 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -96,8 +96,8 @@ public function logBatch(array $events): array */ public function getLogsByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -114,8 +114,8 @@ public function getLogsByUser( */ public function countLogsByUser( string $userId, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { return $this->adapter->countByUser($userId, $after, $before); } @@ -130,8 +130,8 @@ public function countLogsByUser( */ public function getLogsByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -149,8 +149,8 @@ public function getLogsByResource( */ public function countLogsByResource( string $resource, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { return $this->adapter->countByResource($resource, $after, $before); } @@ -167,8 +167,8 @@ public function countLogsByResource( public function getLogsByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -188,8 +188,8 @@ public function getLogsByUserAndEvents( public function countLogsByUserAndEvents( string $userId, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { return $this->adapter->countByUserAndEvents($userId, $events, $after, $before); } @@ -206,8 +206,8 @@ public function countLogsByUserAndEvents( public function getLogsByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, int $limit = 25, int $offset = 0, bool $ascending = false, @@ -227,8 +227,8 @@ public function getLogsByResourceAndEvents( public function countLogsByResourceAndEvents( string $resource, array $events, - ?string $after = null, - ?string $before = null, + ?\DateTime $after = null, + ?\DateTime $before = null, ): int { return $this->adapter->countByResourceAndEvents($resource, $events, $after, $before); } diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index a7e3b3c..258c92a 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -248,7 +248,8 @@ public function testLogByBatch(): void public function testGetLogsCustomFilters(): void { - $threshold = DateTime::addSeconds(new \DateTime(), -10); + $threshold = new \DateTime(); + $threshold->modify('-10 seconds'); $logs = $this->audit->getLogsByUser('userId', after: $threshold); $this->assertEquals(3, \count($logs)); @@ -341,7 +342,7 @@ public function testTimeRangeFilters(): void $this->assertGreaterThanOrEqual(2, \count($all)); // Test with before filter - should get both since they're both in the past relative to future - $beforeFuture = DateTime::format(new \DateTime('2099-12-31 23:59:59')); + $beforeFuture = new \DateTime('2099-12-31 23:59:59'); $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture); $this->assertGreaterThanOrEqual(2, \count($beforeLogs)); } @@ -379,4 +380,189 @@ public function testCleanup(): void $logs = $this->audit->getLogsByUser('userId'); $this->assertEquals(2, \count($logs)); } + + /** + * Test all additional retrieval parameters: limit, offset, ascending, after, before + */ + public function testRetrievalParameters(): void + { + // Setup: Create logs with specific timestamps for testing + $this->audit->cleanup(DateTime::now()); + + $userId = 'paramtestuser'; + $userAgent = 'Mozilla/5.0'; + $ip = '192.168.1.1'; + $location = 'US'; + + // Create 5 logs with different timestamps + $baseTime = new \DateTime('2024-06-15 12:00:00'); + $batchEvents = []; + for ($i = 0; $i < 5; $i++) { + $offset = $i * 60; + $logTime = new \DateTime('2024-06-15 12:00:00'); + $logTime->modify("+{$offset} seconds"); + $timestamp = DateTime::format($logTime); + $batchEvents[] = [ + 'userId' => $userId, + 'event' => 'event_' . $i, + 'resource' => 'doc/' . $i, + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => ['sequence' => $i], + 'time' => $timestamp + ]; + } + + $this->audit->logBatch($batchEvents); + + // Test 1: limit parameter + $logsLimit2 = $this->audit->getLogsByUser($userId, limit: 2); + $this->assertEquals(2, \count($logsLimit2)); + + $logsLimit3 = $this->audit->getLogsByUser($userId, limit: 3); + $this->assertEquals(3, \count($logsLimit3)); + + // Test 2: offset parameter + $logsOffset0 = $this->audit->getLogsByUser($userId, limit: 10, offset: 0); + $logsOffset2 = $this->audit->getLogsByUser($userId, limit: 10, offset: 2); + $logsOffset4 = $this->audit->getLogsByUser($userId, limit: 10, offset: 4); + + $this->assertEquals(5, \count($logsOffset0)); + $this->assertEquals(3, \count($logsOffset2)); + $this->assertEquals(1, \count($logsOffset4)); + + // Verify offset returns different logs + $this->assertNotEquals($logsOffset0[0]->getId(), $logsOffset2[0]->getId()); + $this->assertNotEquals($logsOffset2[0]->getId(), $logsOffset4[0]->getId()); + + // Test 3: ascending parameter + $logsDesc = $this->audit->getLogsByUser($userId, ascending: false); + $logsAsc = $this->audit->getLogsByUser($userId, ascending: true); + + $this->assertEquals(5, \count($logsDesc)); + $this->assertEquals(5, \count($logsAsc)); + + // Verify order is reversed + if (\count($logsDesc) === \count($logsAsc)) { + for ($i = 0; $i < \count($logsDesc); $i++) { + $this->assertEquals( + $logsDesc[$i]->getId(), + $logsAsc[\count($logsAsc) - 1 - $i]->getId() + ); + } + } + + // Test 4: after parameter (logs after a certain timestamp) + $afterTimeObj = new \DateTime('2024-06-15 12:03:00'); // After 3rd log + $logsAfter = $this->audit->getLogsByUser($userId, after: $afterTimeObj); + // Should get logs at positions 3 and 4 (2 logs) + $this->assertGreaterThanOrEqual(1, \count($logsAfter)); + + // Test 5: before parameter (logs before a certain timestamp) + $beforeTimeObj = new \DateTime('2024-06-15 12:02:00'); // Before 3rd log + $logsBefore = $this->audit->getLogsByUser($userId, before: $beforeTimeObj); + // Should get logs at positions 0, 1, 2 (3 logs) + $this->assertGreaterThanOrEqual(1, \count($logsBefore)); + + // Test 6: Combination of limit + offset + $logsPage1 = $this->audit->getLogsByUser($userId, limit: 2, offset: 0); + $logsPage2 = $this->audit->getLogsByUser($userId, limit: 2, offset: 2); + $logsPage3 = $this->audit->getLogsByUser($userId, limit: 2, offset: 4); + + $this->assertEquals(2, \count($logsPage1)); + $this->assertEquals(2, \count($logsPage2)); + $this->assertEquals(1, \count($logsPage3)); + + // Verify pages don't overlap + $this->assertNotEquals($logsPage1[0]->getId(), $logsPage2[0]->getId()); + $this->assertNotEquals($logsPage2[0]->getId(), $logsPage3[0]->getId()); + + // Test 7: Combination of ascending + limit + $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true); + $this->assertEquals(2, \count($ascLimit2)); + // First log should be oldest in ascending order + $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event')); + + // Test 8: Combination of after + before (time range) + $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log + $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log + $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); + $this->assertGreaterThanOrEqual(1, \count($logsRange)); + + // Test 7: Combination of ascending + limit + $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true); + $this->assertEquals(2, \count($ascLimit2)); + // First log should be oldest in ascending order + $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event')); + + // Test 8: Combination of after + before (time range) + $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log + $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log + $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); + $this->assertGreaterThanOrEqual(1, \count($logsRange)); + + // Test 9: Test with getLogsByResource using parameters + $logsRes = $this->audit->getLogsByResource('doc/0', limit: 1, offset: 0); + $this->assertEquals(1, \count($logsRes)); + + // Test 10: Test with getLogsByUserAndEvents using parameters + $logsEvt = $this->audit->getLogsByUserAndEvents( + $userId, + ['event_1', 'event_2'], + limit: 1, + offset: 0, + ascending: false + ); + $this->assertGreaterThanOrEqual(0, \count($logsEvt)); + + // Test 11: Test count methods with after/before filters + $countAll = $this->audit->countLogsByUser($userId); + $this->assertEquals(5, $countAll); + + $countAfter = $this->audit->countLogsByUser($userId, after: $afterTimeObj); + $this->assertGreaterThanOrEqual(0, $countAfter); + + $countBefore = $this->audit->countLogsByUser($userId, before: $beforeTimeObj); + $this->assertGreaterThanOrEqual(0, $countBefore); + + // Test 12: Test countLogsByResource with filters + $countResAll = $this->audit->countLogsByResource('doc/0'); + $this->assertEquals(1, $countResAll); + + $countResAfter = $this->audit->countLogsByResource('doc/0', after: $afterTimeObj); + $this->assertGreaterThanOrEqual(0, $countResAfter); + + // Test 13: Test countLogsByUserAndEvents with filters + $countEvtAll = $this->audit->countLogsByUserAndEvents($userId, ['event_1', 'event_2']); + $this->assertGreaterThanOrEqual(0, $countEvtAll); + + $countEvtAfter = $this->audit->countLogsByUserAndEvents( + $userId, + ['event_1', 'event_2'], + after: $afterTimeObj + ); + $this->assertGreaterThanOrEqual(0, $countEvtAfter); + + // Test 14: Test countLogsByResourceAndEvents with filters + $countResEvtAll = $this->audit->countLogsByResourceAndEvents('doc/0', ['event_0']); + $this->assertEquals(1, $countResEvtAll); + + $countResEvtAfter = $this->audit->countLogsByResourceAndEvents( + 'doc/0', + ['event_0'], + after: $afterTimeObj + ); + $this->assertGreaterThanOrEqual(0, $countResEvtAfter); + + // Test 15: Test getLogsByResourceAndEvents with all parameters + $logsResEvt = $this->audit->getLogsByResourceAndEvents( + 'doc/1', + ['event_1'], + limit: 1, + offset: 0, + ascending: true + ); + $this->assertGreaterThanOrEqual(0, \count($logsResEvt)); + } } From 93c0fbaa6d762ce7c5a0400c9d3bc63c85c9b8d0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:03:12 +0000 Subject: [PATCH 39/50] Feat: enhance log retrieval methods with optional filtering parameters --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 744a85d..e779425 100644 --- a/README.md +++ b/README.md @@ -102,33 +102,70 @@ $audit->log($userId, $event, $resource, $userAgent, $ip, $location, $data); **Get Logs By User** -Fetch all logs by given user ID +Fetch all logs by given user ID with optional filtering parameters: ```php +// Basic usage +$logs = $audit->getLogsByUser('userId'); + +// With pagination +$logs = $audit->getLogsByUser( + userId: 'userId', + limit: 10, + offset: 0 +); + +// With time filtering using DateTime objects $logs = $audit->getLogsByUser( - 'userId' // User unique ID -); // Returns an array of all logs for specific user + userId: 'userId', + after: new \DateTime('2024-01-01 00:00:00'), + before: new \DateTime('2024-12-31 23:59:59'), + limit: 25, + offset: 0, + ascending: false // false = newest first (default), true = oldest first +); ``` **Get Logs By User and Action** -Fetch all logs by given user ID and a specific event name +Fetch all logs by given user ID and specific event names with optional filtering: ```php -$logs = $audit->getLogsByUserAndEvents( - 'userId', // User unique ID - ['update', 'delete'] // List of selected event to fetch -); // Returns an array of all logs for specific user filtered by given actions +// Basic usage +$logs = $audit->getLogsByUserAndEvents( + userId: 'userId', + events: ['update', 'delete'] +); + +// With time filtering and pagination +$logs = $audit->getLogsByUserAndEvents( + userId: 'userId', + events: ['update', 'delete'], + after: new \DateTime('-7 days'), + before: new \DateTime('now'), + limit: 50, + offset: 0, + ascending: false +); ``` **Get Logs By Resource** -Fetch all logs by a given resource name +Fetch all logs by a given resource name with optional filtering: ```php +// Basic usage +$logs = $audit->getLogsByResource('database/document-1'); + +// With time filtering and pagination $logs = $audit->getLogsByResource( - 'resource-name', // Resource Name -); // Returns an array of all logs for the specific resource + resource: 'database/document-1', + after: new \DateTime('-30 days'), + before: new \DateTime('now'), + limit: 100, + offset: 0, + ascending: true // Get oldest logs first +); ``` **Batch Logging** @@ -164,6 +201,63 @@ $events = [ $documents = $audit->logBatch($events); ``` +**Counting Logs** + +All retrieval methods have corresponding count methods with the same filtering capabilities: + +```php +// Count all logs for a user +$count = $audit->countLogsByUser('userId'); + +// Count logs within a time range +$count = $audit->countLogsByUser( + userId: 'userId', + after: new \DateTime('-7 days'), + before: new \DateTime('now') +); + +// Count logs by resource +$count = $audit->countLogsByResource('database/document-1'); + +// Count logs by user and events +$count = $audit->countLogsByUserAndEvents( + userId: 'userId', + events: ['create', 'update', 'delete'], + after: new \DateTime('-30 days') +); + +// Count logs by resource and events +$count = $audit->countLogsByResourceAndEvents( + resource: 'database/document-1', + events: ['update', 'delete'] +); +``` + +**Advanced Filtering** + +Get logs by resource and specific events: + +```php +$logs = $audit->getLogsByResourceAndEvents( + resource: 'database/document-1', + events: ['create', 'update'], + after: new \DateTime('-24 hours'), + limit: 20, + offset: 0, + ascending: false +); +``` + +### Filtering Parameters + +All retrieval methods support the following optional parameters: + +- **after** (`?\DateTime`): Get logs created after this datetime +- **before** (`?\DateTime`): Get logs created before this datetime +- **limit** (`int`, default: 25): Maximum number of logs to return +- **offset** (`int`, default: 0): Number of logs to skip (for pagination) +- **ascending** (`bool`, default: false): Sort order - false for newest first, true for oldest first + ## Adapters Utopia Audit uses an adapter pattern to support different storage backends. Currently available adapters: From 5be02950b4e80727272174e3a5d88c40b164c247 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:15:32 +0000 Subject: [PATCH 40/50] Dockerfile update --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d9cc4c1..2e94b87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM composer:2.0 as step0 +FROM composer:2.8 AS step0 WORKDIR /src/ @@ -8,7 +8,7 @@ COPY composer.json /src/ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist -FROM php:8.3.3-cli-alpine3.19 as final +FROM php:8.3.3-cli-alpine3.19 AS final LABEL maintainer="team@appwrite.io" From d29458491391713fd8a9c778a89b9131d4a2cc36 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:21:38 +0000 Subject: [PATCH 41/50] remove unused import --- src/Audit/Audit.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 419a41e..425a7cb 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -2,8 +2,6 @@ namespace Utopia\Audit; -use Utopia\Database\Database; - /** * Audit Log Manager * From 8a67e3119755c4ee767c7cdc64c3baa96e54615a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 11 Dec 2025 05:11:33 +0545 Subject: [PATCH 42/50] Update Dockerfile Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2e94b87..7788050 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY composer.json /src/ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist -FROM php:8.3.3-cli-alpine3.19 AS final +FROM php:8.3.3-cli-alpine3.19 AS final LABEL maintainer="team@appwrite.io" From b04f8f84c1199ca06c004d80061fe3a105d1dff2 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:28:18 +0000 Subject: [PATCH 43/50] Fix: use DateTime object in cleanup instead of string --- README.md | 97 +++++++++++++++++++++++++++++--- src/Audit/Adapter.php | 4 +- src/Audit/Adapter/ClickHouse.php | 7 ++- src/Audit/Adapter/Database.php | 9 +-- src/Audit/Audit.php | 6 +- tests/Audit/AuditBase.php | 12 ++-- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e779425..cff35ef 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and namespace MyApp\Audit; use Utopia\Audit\Adapter; -use Utopia\Database\Document; +use Utopia\Audit\Log; class CustomAdapter extends Adapter { @@ -338,25 +338,104 @@ class CustomAdapter extends Adapter public function setup(): void { - // Initialize your storage backend + // Initialize your storage backend (create tables, indexes, etc.) } - public function create(array $log): Document + public function create(array $log): Log { - // Store a single log entry + // Store a single log entry and return a Log object } public function createBatch(array $logs): array { - // Store multiple log entries + // Store multiple log entries and return array of Log objects } - public function getByUser(string $userId, array $queries = []): array - { - // Retrieve logs by user ID + public function getByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + // Retrieve logs by user ID with optional filtering + } + + public function countByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + // Count logs by user ID with optional time filtering + } + + public function getByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + // Retrieve logs by resource with optional filtering + } + + public function countByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + // Count logs by resource with optional time filtering + } + + public function getByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + // Retrieve logs by user ID and specific events with optional filtering } - // Implement other required methods... + public function countByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + // Count logs by user ID and events with optional time filtering + } + + public function getByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + // Retrieve logs by resource and specific events with optional filtering + } + + public function countByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + // Count logs by resource and events with optional time filtering + } + + public function cleanup(\DateTime $datetime): bool + { + // Delete logs older than the specified datetime + // Return true on success, false otherwise + } } ``` diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index cc89140..56757f9 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -196,10 +196,10 @@ abstract public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - * @param string $datetime ISO 8601 datetime string + * @param \DateTime $datetime * @return bool * * @throws \Exception */ - abstract public function cleanup(string $datetime): bool; + abstract public function cleanup(\DateTime $datetime): bool; } diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 7e577cc..fb802f5 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -1109,12 +1109,15 @@ public function countByResourceAndEvents( * * @throws Exception */ - public function cleanup(string $datetime): bool + public function cleanup(\DateTime $datetime): bool { $tableName = $this->getTableName(); $tenantFilter = $this->getTenantFilter(); $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + // Convert DateTime to string format expected by ClickHouse + $datetimeString = $datetime->format('Y-m-d H:i:s'); + // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) // Falls back to ALTER TABLE DELETE with mutations_sync for older versions $sql = " @@ -1122,7 +1125,7 @@ public function cleanup(string $datetime): bool WHERE time < {datetime:String}{$tenantFilter} "; - $this->query($sql, ['datetime' => $datetime]); + $this->query($sql, ['datetime' => $datetimeString]); return true; } diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 2ccedd7..36cc3a6 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -371,15 +371,16 @@ public function countByResourceAndEvents( * Delete logs older than the specified datetime. * * @param string $datetime - * @return bool + /** * @throws AuthorizationException|\Exception */ - public function cleanup(string $datetime): bool + public function cleanup(\DateTime $datetime): bool { - $this->db->getAuthorization()->skip(function () use ($datetime) { + $datetimeString = $datetime->format('Y-m-d\TH:i:s.vP'); + $this->db->getAuthorization()->skip(function () use ($datetimeString) { do { $removed = $this->db->deleteDocuments($this->getCollectionName(), [ - Query::lessThan('time', $datetime), + Query::lessThan('time', $datetimeString), ]); } while ($removed > 0); }); diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 425a7cb..2873edc 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -232,14 +232,14 @@ public function countLogsByResourceAndEvents( } /** - * Delete all logs older than `$datetime` seconds + * Delete all logs older than the specified datetime * - * @param string $datetime + * @param \DateTime $datetime * @return bool * * @throws \Exception */ - public function cleanup(string $datetime): bool + public function cleanup(\DateTime $datetime): bool { return $this->adapter->cleanup($datetime); } diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 258c92a..26d1688 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -39,7 +39,7 @@ public function setUp(): void */ public function tearDown(): void { - $this->audit->cleanup(DateTime::now()); + $this->audit->cleanup(new \DateTime()); } public function createLogs(): void @@ -154,7 +154,7 @@ public function testGetLogsByResource(): void public function testLogByBatch(): void { // First cleanup existing logs - $this->audit->cleanup(DateTime::now()); + $this->audit->cleanup(new \DateTime()); $userId = 'batchUserId'; $userAgent = 'Mozilla/5.0 (Test User Agent)'; @@ -351,7 +351,7 @@ public function testCleanup(): void { sleep(3); // First delete all the logs - $status = $this->audit->cleanup(DateTime::now()); + $status = $this->audit->cleanup(new \DateTime()); $this->assertEquals($status, true); // Check that all logs have been deleted @@ -373,7 +373,9 @@ public function testCleanup(): void sleep(5); // DELETE logs older than 11 seconds and check that status is true - $status = $this->audit->cleanup(DateTime::addSeconds(new \DateTime(), -11)); + $datetime = new \DateTime(); + $datetime->modify('-11 seconds'); + $status = $this->audit->cleanup($datetime); $this->assertEquals($status, true); // Check if 1 log has been deleted @@ -387,7 +389,7 @@ public function testCleanup(): void public function testRetrievalParameters(): void { // Setup: Create logs with specific timestamps for testing - $this->audit->cleanup(DateTime::now()); + $this->audit->cleanup(new \DateTime()); $userId = 'paramtestuser'; $userAgent = 'Mozilla/5.0'; From 5cbab40ddbb44f7e6332f3aad2ec157fea719951 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:28:27 +0000 Subject: [PATCH 44/50] format --- src/Audit/Adapter/Database.php | 10 +++++----- tests/Audit/AuditBase.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 36cc3a6..966d1d2 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -93,7 +93,7 @@ public function createBatch(array $logs): array } }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -156,7 +156,7 @@ public function getByUser( ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -212,7 +212,7 @@ public function getByResource( ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -273,7 +273,7 @@ public function getByUserAndEvents( ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -337,7 +337,7 @@ public function getByResourceAndEvents( ); }); - return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); } /** diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 26d1688..7173d30 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -266,8 +266,8 @@ public function testAscendingOrderRetrieval(): void // Events should be in opposite order if (\count($logsDesc) > 1) { - $descEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsDesc); - $ascEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsAsc); + $descEvents = array_map(fn($log) => $log->getAttribute('event'), $logsDesc); + $ascEvents = array_map(fn($log) => $log->getAttribute('event'), $logsAsc); $this->assertEquals($descEvents, array_reverse($ascEvents)); } } From 711481be01ad4da908dace3fc04c0c1796e8926c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:33:45 +0000 Subject: [PATCH 45/50] fix date format --- src/Audit/Adapter/Database.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 966d1d2..4ae3191 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -93,7 +93,7 @@ public function createBatch(array $logs): array } }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $created); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); } /** @@ -156,7 +156,7 @@ public function getByUser( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -212,7 +212,7 @@ public function getByResource( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -273,7 +273,7 @@ public function getByUserAndEvents( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -337,7 +337,7 @@ public function getByResourceAndEvents( ); }); - return array_map(fn($doc) => new Log($doc->getArrayCopy()), $documents); + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); } /** @@ -370,13 +370,13 @@ public function countByResourceAndEvents( /** * Delete logs older than the specified datetime. * - * @param string $datetime + * @param \DateTime $datetime /** * @throws AuthorizationException|\Exception */ public function cleanup(\DateTime $datetime): bool { - $datetimeString = $datetime->format('Y-m-d\TH:i:s.vP'); + $datetimeString = DateTime::format($datetime); $this->db->getAuthorization()->skip(function () use ($datetimeString) { do { $removed = $this->db->deleteDocuments($this->getCollectionName(), [ From d68d21d2f38c245f87802c0860c2595a9262e3b1 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 10 Dec 2025 23:52:20 +0000 Subject: [PATCH 46/50] Fix date time handling --- src/Audit/Adapter/ClickHouse.php | 36 +++++++------------------------- tests/Audit/AuditBase.php | 12 ++++++----- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index fb802f5..fb60616 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -265,29 +265,6 @@ private function getTableName(): string return $tableName; } - /** - * Format timestamp for ClickHouse DateTime64. - * Removes timezone information and ensures proper format. - * - * @param string $timestamp - * @return string - */ - private function formatTimestamp(string $timestamp): string - { - // Remove timezone suffix (e.g., +00:00, Z) if present - // ClickHouse expects format: 2025-12-07 23:19:29.056 - $normalized = preg_replace('/([+-]\d{2}:\d{2}|Z)$/', '', $timestamp); - - if (!is_string($normalized)) { - return ''; - } - - // Replace T with space if present - $normalized = str_replace('T', ' ', $normalized); - - return $normalized; - } - /** * Execute a ClickHouse query via HTTP interface using Fetch Client. * @@ -462,9 +439,7 @@ public function setup(): void public function create(array $log): Log { $id = uniqid('', true); - // Format: 2025-12-07 23:19:29.056 - $microtime = microtime(true); - $time = date('Y-m-d H:i:s', (int) $microtime) . '.' . sprintf('%03d', ($microtime - floor($microtime)) * 1000); + $time = (new \DateTime())->format('Y-m-d H:i:s.v'); $tableName = $this->getTableName(); @@ -569,7 +544,12 @@ public function createBatch(array $logs): array $params[$paramKeys[4]] = $log['userAgent']; $params[$paramKeys[5]] = $log['ip']; $params[$paramKeys[6]] = $log['location'] ?? null; - $params[$paramKeys[7]] = $this->formatTimestamp($log['time']); + + $time = $log['time'] ?? new \DateTime(); + if (is_string($time)) { + $time = new \DateTime($time); + } + $params[$paramKeys[7]] = $time->format('Y-m-d H:i:s.v'); $params[$paramKeys[8]] = json_encode($log['data'] ?? []); if ($this->sharedTables) { @@ -1116,7 +1096,7 @@ public function cleanup(\DateTime $datetime): bool $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); // Convert DateTime to string format expected by ClickHouse - $datetimeString = $datetime->format('Y-m-d H:i:s'); + $datetimeString = $datetime->format('Y-m-d H:i:s.v'); // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) // Falls back to ALTER TABLE DELETE with mutations_sync for older versions diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 7173d30..9370783 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -3,7 +3,6 @@ namespace Utopia\Tests\Audit; use Utopia\Audit\Audit; -use Utopia\Audit\Log; use Utopia\Database\DateTime; /** @@ -31,6 +30,9 @@ abstract protected function initializeAudit(): void; public function setUp(): void { $this->initializeAudit(); + $cleanup = new \DateTime(); + $cleanup = $cleanup->modify('+10 second'); + $this->audit->cleanup(new \DateTime()); $this->createLogs(); } @@ -39,6 +41,8 @@ public function setUp(): void */ public function tearDown(): void { + $cleanup = new \DateTime(); + $cleanup = $cleanup->modify('+10 second'); $this->audit->cleanup(new \DateTime()); } @@ -266,8 +270,8 @@ public function testAscendingOrderRetrieval(): void // Events should be in opposite order if (\count($logsDesc) > 1) { - $descEvents = array_map(fn($log) => $log->getAttribute('event'), $logsDesc); - $ascEvents = array_map(fn($log) => $log->getAttribute('event'), $logsAsc); + $descEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsDesc); + $ascEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsAsc); $this->assertEquals($descEvents, array_reverse($ascEvents)); } } @@ -349,8 +353,6 @@ public function testTimeRangeFilters(): void public function testCleanup(): void { - sleep(3); - // First delete all the logs $status = $this->audit->cleanup(new \DateTime()); $this->assertEquals($status, true); From d72b523c2f01e33794ffd11a8784baddb0ac3e65 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 11 Dec 2025 00:08:04 +0000 Subject: [PATCH 47/50] fix codeql --- src/Audit/Adapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php index 56757f9..a8fea58 100644 --- a/src/Audit/Adapter.php +++ b/src/Audit/Adapter.php @@ -52,7 +52,7 @@ abstract public function create(array $log): Log; * userAgent: string, * ip: string, * location?: string, - * time: string, + * time: \DateTime|string|null, * data?: array * }> $logs * @return array From 56584d2f299c7cd71267580a40cdd4af261d4811 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 11 Dec 2025 00:12:13 +0000 Subject: [PATCH 48/50] remove duplicate --- tests/Audit/AuditBase.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 9370783..301a7c8 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -494,18 +494,6 @@ public function testRetrievalParameters(): void $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); $this->assertGreaterThanOrEqual(1, \count($logsRange)); - // Test 7: Combination of ascending + limit - $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true); - $this->assertEquals(2, \count($ascLimit2)); - // First log should be oldest in ascending order - $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event')); - - // Test 8: Combination of after + before (time range) - $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log - $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log - $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); - $this->assertGreaterThanOrEqual(1, \count($logsRange)); - // Test 9: Test with getLogsByResource using parameters $logsRes = $this->audit->getLogsByResource('doc/0', limit: 1, offset: 0); $this->assertEquals(1, \count($logsRes)); From e9a9c315b1ec7008b66bf0bb87ea3d0659a4581a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 11 Dec 2025 00:26:21 +0000 Subject: [PATCH 49/50] format time correctly in database adapter --- src/Audit/Adapter/Database.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 4ae3191..2564086 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -87,8 +87,14 @@ public function create(array $log): Log public function createBatch(array $logs): array { $created = []; + $this->db->getAuthorization()->skip(function () use ($logs, &$created) { foreach ($logs as $log) { + $time = $log['time'] ?? new \DateTime(); + if (is_string($time)) { + $time = new \DateTime($time); + } + $log['time'] = DateTime::format($time); $created[] = $this->db->createDocument($this->getCollectionName(), new Document($log)); } }); From 308d74bfd9d2f3114504cd1bf6731cbae7620b5f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 11 Dec 2025 12:00:29 +0000 Subject: [PATCH 50/50] Fix type check --- src/Audit/Adapter/Database.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 2564086..23e4505 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -94,6 +94,7 @@ public function createBatch(array $logs): array if (is_string($time)) { $time = new \DateTime($time); } + assert($time instanceof \DateTime); $log['time'] = DateTime::format($time); $created[] = $this->db->createDocument($this->getCollectionName(), new Document($log)); }