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 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/Dockerfile b/Dockerfile index d9cc4c1..7788050 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" diff --git a/README.md b/README.md index 0cd79be..cff35ef 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,20 +23,24 @@ 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 +58,32 @@ $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 = new Audit(new DatabaseAdapter($database)); $audit->setup(); ``` +### Using a Custom Adapter + +You can create custom adapters by extending the `Utopia\Audit\Adapter` abstract class: + +```php +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' // User unique ID -); // Returns an array of all logs for specific user + userId: 'userId', + limit: 10, + offset: 0 +); + +// With time filtering using DateTime objects +$logs = $audit->getLogsByUser( + 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** + +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'], + 'time' => 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'], + 'time' => DateTime::now() + ] +]; + +$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: + +### Database Adapter (Default) + +The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a 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 Utopia Fetch. + +**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: + +```php +=8.0", - "utopia-php/database": "4.*" + "utopia-php/database": "4.*", + "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 c7224b5..3076c39 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": "7e7770e1778658a1376fdd3c1ffd73c3", "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/docker-compose.yml b/docker-compose.yml index 84dc821..8614fe1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,37 @@ -version: '3' - services: mariadb: image: mariadb:10.11 - environment: + environment: - MYSQL_ROOT_PASSWORD=password networks: - 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 + environment: + - CLICKHOUSE_DB=default + - CLICKHOUSE_USER=default + - CLICKHOUSE_PASSWORD=clickhouse + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 + networks: + - abuse + ports: + - "8123:8123" + - "9000:9000" + healthcheck: + test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "--query=SELECT 1", "--format=Null"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s tests: build: @@ -17,11 +40,20 @@ services: networks: - abuse depends_on: - - mariadb + mariadb: + 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: \ No newline at end of file + abuse: diff --git a/src/Audit/Adapter.php b/src/Audit/Adapter.php new file mode 100644 index 0000000..a8fea58 --- /dev/null +++ b/src/Audit/Adapter.php @@ -0,0 +1,205 @@ + + * } $log + * @return Log The created log entry + * + * @throws \Exception + */ + abstract public function create(array $log): Log; + + /** + * 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 + * @return array + * + * @throws \Exception + */ + abstract public function getByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; + + /** + * Count logs by user ID. + * + * @param string $userId + * @return int + * + * @throws \Exception + */ + abstract public function countByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int; + + /** + * Get logs by resource. + * + * @param string $resource + * @return array + * + * @throws \Exception + */ + abstract public function getByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; + + /** + * Count logs by resource. + * + * @param string $resource + * @return int + * + * @throws \Exception + */ + abstract public function countByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int; + + /** + * Get logs by user and events. + * + * @param string $userId + * @param array $events + * @return array + * + * @throws \Exception + */ + abstract public function getByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; + + /** + * Count logs by user and events. + * + * @param string $userId + * @param array $events + * @return int + * + * @throws \Exception + */ + abstract public function countByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int; + + /** + * Get logs by resource and events. + * + * @param string $resource + * @param array $events + * @return array + * + * @throws \Exception + */ + abstract public function getByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array; + + /** + * Count logs by resource and events. + * + * @param string $resource + * @param array $events + * @return int + * + * @throws \Exception + */ + abstract public function countByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int; + + /** + * Delete logs older than the specified datetime. + * + * @param \DateTime $datetime + * @return bool + * + * @throws \Exception + */ + abstract public function cleanup(\DateTime $datetime): bool; +} diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php new file mode 100644 index 0000000..fb60616 --- /dev/null +++ b/src/Audit/Adapter/ClickHouse.php @@ -0,0 +1,1112 @@ +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); + } + + /** + * Get adapter name. + */ + public function getName(): string + { + return 'ClickHouse'; + } + + /** + * Validate host parameter. + * + * @param string $host + * @throws Exception + */ + private function validateHost(string $host): void + { + $validator = new Hostname(); + if (!$validator->isValid($host)) { + throw new Exception('ClickHouse host is not 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) . '`'; + } + /** + * 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; + } + + /** + * Enable or disable HTTPS for ClickHouse HTTP interface. + */ + public function setSecure(bool $secure): self + { + $this->secure = $secure; + 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. + * + * 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. + * + * 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 + { + $scheme = $this->secure ? 'https' : 'http'; + $url = "{$scheme}://{$this->host}:{$this->port}/"; + + // 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: $body + ); + if ($response->getStatusCode() !== 200) { + $bodyStr = $response->getBody(); + $bodyStr = is_string($bodyStr) ? $bodyStr : ''; + throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$bodyStr}"); + } + + $body = $response->getBody(); + return is_string($body) ? $body : ''; + } catch (Exception $e) { + // 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 + ); + } + } + + /** + * 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. + * + * 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 + $escapedDatabase = $this->escapeIdentifier($this->database); + $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"; + $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', + ]; + + 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 + } + + // 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"; + } + + $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 {$escapedDatabaseAndTable} ( + " . 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): Log + { + $id = uniqid('', true); + $time = (new \DateTime())->format('Y-m-d H:i:s.v'); + + $tableName = $this->getTableName(); + + // Build column list and values based on sharedTables setting + $columns = ['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, + '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'] ?? []), + ]; + + if ($this->sharedTables) { + $columns[] = 'tenant'; + $placeholders[] = '{tenant:Nullable(UInt64)}'; + $params['tenant'] = $this->tenant; + } + + $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $insertSql = " + INSERT INTO {$escapedDatabaseAndTable} + (" . implode(', ', $columns) . ") + VALUES ( + " . implode(", ", $placeholders) . " + ) + "; + + $this->query($insertSql, $params); + + $result = [ + '$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'] ?? [], + ]; + + if ($this->sharedTables) { + $result['tenant'] = $this->tenant; + } + + return new Log($result); + } + + /** + * Create multiple audit log entries in batch. + * + * @throws Exception + */ + public function createBatch(array $logs): array + { + if (empty($logs)) { + return []; + } + + $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; + + // 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; + + $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) { + $paramKeys[] = 'tenant_' . $paramCounter; + $params[$paramKeys[9]] = $this->tenant; + } + + // 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}'; + } + } + + $valueClauses[] = '(' . implode(', ', $placeholders) . ')'; + $paramCounter++; + } + + $insertSql = " + INSERT INTO {$escapedDatabaseAndTable} + (" . implode(', ', $columns) . ") + VALUES " . implode(', ', $valueClauses); + + $this->query($insertSql, $params); + + // Return documents using the same IDs that were inserted + $documents = []; + foreach ($logs as $index => $log) { + $result = [ + '$id' => $ids[$index], + 'userId' => $log['userId'] ?? null, + 'event' => $log['event'], + 'resource' => $log['resource'], + 'userAgent' => $log['userAgent'], + 'ip' => $log['ip'], + 'location' => $log['location'] ?? null, + 'time' => $log['time'], + 'data' => $log['data'] ?? [], + ]; + + if ($this->sharedTables) { + $result['tenant'] = $this->tenant; + } + + $documents[] = new Log($result); + } + + return $documents; + } + + /** + * Parse ClickHouse query result into Log objects. + * + * @return array + */ + 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); + // 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'; + } + + // 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' => $parseNullableString($columns[1]), + 'event' => $columns[2], + 'resource' => $columns[3], + 'userAgent' => $columns[4], + 'ip' => $columns[5], + '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' || $columns[9] === '' ? null : (int) $columns[9]; + } + + $documents[] = new Log($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}"; + } + + /** + * Build time WHERE clause and parameters with safe parameter placeholders. + * + * @param \DateTime|null $after + * @param \DateTime|null $before + * @return array{clause: string, params: array} + */ + private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array + { + $params = []; + $conditions = []; + + $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'] = $afterStr; + $params['before'] = $beforeStr; + + return ['clause' => ' AND ' . $conditions[0], 'params' => $params]; + } + + if ($afterStr !== null) { + $conditions[] = 'time > {after:String}'; + $params['after'] = $afterStr; + } + + if ($beforeStr !== null) { + $conditions[] = 'time < {before:String}'; + $params['before'] = $beforeStr; + } + + if ($conditions === []) { + return ['clause' => '', 'params' => []]; + } + + return [ + 'clause' => ' AND ' . implode(' AND ', $conditions), + 'params' => $params, + ]; + } + + /** + * Build a formatted SQL IN list from an array of events. + * Events are parameterized for safe SQL inclusion. + * + * @param array $events + * @param int $paramOffset Base parameter number for creating unique param names + * @return array{clause: string, params: array} + */ + private function buildEventsList(array $events, int $paramOffset = 0): array + { + $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]; + } + + /** + * 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. + * + * @throws Exception + */ + public function getByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $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(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT " . $this->getSelectColumns() . " + FROM {$escapedTable} + WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} + ORDER BY time {$order} + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + 'limit' => $limit, + 'offset' => $offset, + ], $time['params'])); + + return $this->parseResults($result); + } + + /** + * Count logs by user ID. + * + * @throws Exception + */ + public function countByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT count() + FROM {$escapedTable} + WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + ], $time['params'])); + + return (int) trim($result); + } + + /** + * Get logs by resource. + * + * @throws Exception + */ + public function getByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $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(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT " . $this->getSelectColumns() . " + FROM {$escapedTable} + WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} + ORDER BY time {$order} + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + 'limit' => $limit, + 'offset' => $offset, + ], $time['params'])); + + return $this->parseResults($result); + } + + /** + * Count logs by resource. + * + * @throws Exception + */ + public function countByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT count() + FROM {$escapedTable} + WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + ], $time['params'])); + + return (int) trim($result); + } + + /** + * Get logs by user and events. + * + * @throws Exception + */ + public function getByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; + $eventList = $this->buildEventsList($events, 0); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT " . $this->getSelectColumns() . " + FROM {$escapedTable} + WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} + ORDER BY time {$order} + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + 'limit' => $limit, + 'offset' => $offset, + ], $eventList['params'], $time['params'])); + + return $this->parseResults($result); + } + + /** + * Count logs by user and events. + * + * @throws Exception + */ + public function countByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + $eventList = $this->buildEventsList($events, 0); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT count() + FROM {$escapedTable} + WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'userId' => $userId, + ], $eventList['params'], $time['params'])); + + return (int) trim($result); + } + + /** + * Get logs by resource and events. + * + * @throws Exception + */ + public function getByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, + ): array { + $time = $this->buildTimeClause($after, $before); + $order = $ascending ? 'ASC' : 'DESC'; + $eventList = $this->buildEventsList($events, 0); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT " . $this->getSelectColumns() . " + FROM {$escapedTable} + WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} + ORDER BY time {$order} + LIMIT {limit:UInt64} OFFSET {offset:UInt64} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + 'limit' => $limit, + 'offset' => $offset, + ], $eventList['params'], $time['params'])); + + return $this->parseResults($result); + } + + /** + * Count logs by resource and events. + * + * @throws Exception + */ + public function countByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $before = null, + ): int { + $time = $this->buildTimeClause($after, $before); + $eventList = $this->buildEventsList($events, 0); + $tableName = $this->getTableName(); + $tenantFilter = $this->getTenantFilter(); + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + + $sql = " + SELECT count() + FROM {$escapedTable} + WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} + FORMAT TabSeparated + "; + + $result = $this->query($sql, array_merge([ + 'resource' => $resource, + ], $eventList['params'], $time['params'])); + + return (int) trim($result); + } + + /** + * Delete logs older than the specified datetime. + * + * ClickHouse uses ALTER TABLE DELETE with synchronous mutations. + * + * @throws Exception + */ + 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.v'); + + // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) + // Falls back to ALTER TABLE DELETE with mutations_sync for older versions + $sql = " + DELETE FROM {$escapedTable} + WHERE time < {datetime:String}{$tenantFilter} + "; + + $this->query($sql, ['datetime' => $datetimeString]); + + return true; + } +} diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php new file mode 100644 index 0000000..23e4505 --- /dev/null +++ b/src/Audit/Adapter/Database.php @@ -0,0 +1,431 @@ +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 Log + * @throws AuthorizationException|\Exception + */ + 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)); + }); + + return new Log($document->getArrayCopy()); + } + + /** + * 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) { + $time = $log['time'] ?? new \DateTime(); + 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)); + } + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); + } + + /** + * Build time-related query conditions. + * + * @param \DateTime|null $after + * @param \DateTime|null $before + * @return array + */ + private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array + { + $queries = []; + + $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 ($afterStr !== null) { + $queries[] = Query::greaterThan('time', $afterStr); + } + + if ($beforeStr !== null) { + $queries[] = Query::lessThan('time', $beforeStr); + } + + return $queries; + } + + /** + * Get audit logs by user ID. + * + * @return array + * @throws AuthorizationException|\Exception + */ + public function getByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $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(), + queries: $queries, + ); + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + } + + /** + * Count audit logs by user ID. + * + * @throws AuthorizationException|\Exception + */ + public function countByUser( + string $userId, + ?\DateTime $after = null, + ?\DateTime $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]), + ...$timeQueries, + ] + ); + }); + } + + /** + * Get logs by resource. + * + * @param string $resource + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $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(), + queries: $queries, + ); + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + } + + /** + * Count logs by resource. + * + * @param string $resource + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByResource( + string $resource, + ?\DateTime $after = null, + ?\DateTime $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]), + ...$timeQueries, + ] + ); + }); + } + + /** + * Get logs by user and events. + * + * @param string $userId + * @param array $events + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $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(), + queries: $queries, + ); + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + } + + /** + * Count logs by user and events. + * + * @param string $userId + * @param array $events + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByUserAndEvents( + string $userId, + array $events, + ?\DateTime $after = null, + ?\DateTime $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), + ...$timeQueries, + ] + ); + }); + } + + /** + * Get logs by resource and events. + * + * @param string $resource + * @param array $events + * @return array + * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query + */ + public function getByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $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(), + queries: $queries, + ); + }); + + return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); + } + + /** + * Count logs by resource and events. + * + * @param string $resource + * @param array $events + * @return int + * @throws \Utopia\Database\Exception + */ + public function countByResourceAndEvents( + string $resource, + array $events, + ?\DateTime $after = null, + ?\DateTime $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), + ...$timeQueries, + ] + ); + }); + } + + /** + * Delete logs older than the specified datetime. + * + * @param \DateTime $datetime + /** + * @throws AuthorizationException|\Exception + */ + public function cleanup(\DateTime $datetime): bool + { + $datetimeString = DateTime::format($datetime); + $this->db->getAuthorization()->skip(function () use ($datetimeString) { + do { + $removed = $this->db->deleteDocuments($this->getCollectionName(), [ + Query::lessThan('time', $datetimeString), + ]); + } while ($removed > 0); + }); + + 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 new file mode 100644 index 0000000..3b196f2 --- /dev/null +++ b/src/Audit/Adapter/SQL.php @@ -0,0 +1,221 @@ + + * + * @return array> + */ + 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. + * + * Each index is an array with the following string keys: + * - $id: string (index identifier) + * - type: string + * - attributes: array + * + * @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. + * This method is database-specific and must be implemented by each concrete adapter. + * + * @param string $id Attribute identifier + * @return string Database-specific column definition + */ + abstract protected function getColumnDefinition(string $id): string; + + /** + * Get all SQL column definitions. + * Uses the concrete adapter's implementation of getColumnDefinition. + * + * @return array + */ + protected function getAllColumnDefinitions(): array + { + $definitions = []; + foreach ($this->getAttributes() as $attribute) { + /** @var string $id */ + $id = $attribute['$id']; + $definitions[] = $this->getColumnDefinition($id); + } + + return $definitions; + } +} diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index daf4c2e..2873edc 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -2,490 +2,245 @@ 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'; - - 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'], - ], - ]; - - 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], - ], - ]; + private Adapter $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 Log * - * @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 = []): Log { - $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 - * @return array + * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByUser( string $userId, - array $queries = [] + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): 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, $after, $before, $limit, $offset, $ascending); } /** * Count logs by user ID. * * @param string $userId - * @param array $queries * @return int - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByUser( string $userId, - array $queries = [] + ?\DateTime $after = null, + ?\DateTime $before = null, ): 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, $after, $before); } /** * Get all logs by resource. * * @param string $resource - * @param array $queries - * @return array + * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByResource( string $resource, - array $queries = [], + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): 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, $after, $before, $limit, $offset, $ascending); } /** * Count logs by resource. * * @param string $resource - * @param array $queries * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByResource( string $resource, - array $queries = [] + ?\DateTime $after = null, + ?\DateTime $before = null, ): 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, $after, $before); } /** * Get logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries - * @return array + * @param array $events + * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByUserAndEvents( string $userId, array $events, - array $queries = [], + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): 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, $after, $before, $limit, $offset, $ascending); } /** * Count logs by user and events. * * @param string $userId - * @param array $events - * @param array $queries + * @param array $events * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByUserAndEvents( string $userId, array $events, - array $queries = [], + ?\DateTime $after = null, + ?\DateTime $before = null, ): 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, $after, $before); } /** * Get logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries - * @return array + * @param array $events + * @return array * - * @throws Timeout - * @throws \Utopia\Database\Exception - * @throws \Utopia\Database\Exception\Query + * @throws \Exception */ public function getLogsByResourceAndEvents( string $resource, array $events, - array $queries = [], + ?\DateTime $after = null, + ?\DateTime $before = null, + int $limit = 25, + int $offset = 0, + bool $ascending = false, ): 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, $after, $before, $limit, $offset, $ascending); } /** * Count logs by resource and events. * * @param string $resource - * @param array $events - * @param array $queries + * @param array $events * @return int * - * @throws \Utopia\Database\Exception + * @throws \Exception */ public function countLogsByResourceAndEvents( string $resource, array $events, - array $queries = [], + ?\DateTime $after = null, + ?\DateTime $before = null, ): 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, $after, $before); } /** - * Delete all logs older than `$timestamp` seconds + * Delete all logs older than the specified datetime * - * @param string $datetime + * @param \DateTime $datetime * @return bool * - * @throws AuthorizationException * @throws \Exception */ - public function cleanup(string $datetime): bool + public function cleanup(\DateTime $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/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(); + } +} diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php new file mode 100644 index 0000000..947ec8a --- /dev/null +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -0,0 +1,319 @@ +setDatabase('default'); + + $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)); + } +} diff --git a/tests/Audit/Adapter/DatabaseTest.php b/tests/Audit/Adapter/DatabaseTest.php new file mode 100644 index 0000000..a026208 --- /dev/null +++ b/tests/Audit/Adapter/DatabaseTest.php @@ -0,0 +1,42 @@ +setDatabase('utopiaTests'); + $database->setNamespace('namespace'); + + $adapter = new Adapter\Database($database); + $this->audit = new Audit($adapter); + if (! $database->exists('utopiaTests')) { + $database->create(); + $this->audit->setup(); + } + } +} diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php new file mode 100644 index 0000000..301a7c8 --- /dev/null +++ b/tests/Audit/AuditBase.php @@ -0,0 +1,560 @@ +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 + { + $this->initializeAudit(); + $cleanup = new \DateTime(); + $cleanup = $cleanup->modify('+10 second'); + $this->audit->cleanup(new \DateTime()); + $this->createLogs(); + } + + /** + * Classes should override if they need custom teardown + */ + public function tearDown(): void + { + $cleanup = new \DateTime(); + $cleanup = $cleanup->modify('+10 second'); + $this->audit->cleanup(new \DateTime()); + } + + public function createLogs(): void + { + $userId = 'userId'; + $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; + $ip = '127.0.0.1'; + $location = 'US'; + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $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 + { + $logs = $this->audit->getLogsByUser('userId'); + $this->assertEquals(3, \count($logs)); + + $logsCount = $this->audit->countLogsByUser('userId'); + $this->assertEquals(3, $logsCount); + + $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', limit: 1, offset: 1); + $this->assertEquals(1, \count($logs2)); + $this->assertEquals($logs2[0]->getId(), $logs[1]->getId()); + } + + public function testGetLogsByUserAndEvents(): void + { + $logs1 = $this->audit->getLogsByUserAndEvents('userId', ['update']); + $logs2 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete']); + + $this->assertEquals(2, \count($logs1)); + $this->assertEquals(3, \count($logs2)); + + $logsCount1 = $this->audit->countLogsByUserAndEvents('userId', ['update']); + $logsCount2 = $this->audit->countLogsByUserAndEvents('userId', ['update', 'delete']); + + $this->assertEquals(2, $logsCount1); + $this->assertEquals(3, $logsCount2); + + $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'], limit: 1, offset: 1); + + $this->assertEquals(1, \count($logs4)); + $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); + } + + public function testGetLogsByResourceAndEvents(): void + { + $logs1 = $this->audit->getLogsByResourceAndEvents('database/document/1', ['update']); + $logs2 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete']); + + $this->assertEquals(1, \count($logs1)); + $this->assertEquals(2, \count($logs2)); + + $logsCount1 = $this->audit->countLogsByResourceAndEvents('database/document/1', ['update']); + $logsCount2 = $this->audit->countLogsByResourceAndEvents('database/document/2', ['update', 'delete']); + + $this->assertEquals(1, $logsCount1); + $this->assertEquals(2, $logsCount2); + + $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'], limit: 1, offset: 1); + + $this->assertEquals(1, \count($logs4)); + $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); + } + + public function testGetLogsByResource(): void + { + $logs1 = $this->audit->getLogsByResource('database/document/1'); + $logs2 = $this->audit->getLogsByResource('database/document/2'); + + $this->assertEquals(1, \count($logs1)); + $this->assertEquals(2, \count($logs2)); + + $logsCount1 = $this->audit->countLogsByResource('database/document/1'); + $logsCount2 = $this->audit->countLogsByResource('database/document/2'); + + $this->assertEquals(1, $logsCount1); + $this->assertEquals(2, $logsCount2); + + $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', limit: 1, offset: 1); + $this->assertEquals(1, \count($logs4)); + $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); + + $logs5 = $this->audit->getLogsByResource('user/null'); + $this->assertEquals(1, \count($logs5)); + $this->assertNull($logs5[0]['userId']); + $this->assertEquals('127.0.0.1', $logs5[0]['ip']); + } + + public function testLogByBatch(): void + { + // First cleanup existing logs + $this->audit->cleanup(new \DateTime()); + + $userId = 'batchUserId'; + $userAgent = 'Mozilla/5.0 (Test User Agent)'; + $ip = '192.168.1.1'; + $location = 'UK'; + + // Create timestamps 1 minute apart + $timestamp1 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -120)) ?? ''; + $timestamp2 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60)) ?? ''; + $timestamp3 = DateTime::formatTz(DateTime::now()) ?? ''; + + $batchEvents = [ + [ + 'userId' => $userId, + 'event' => 'create', + 'resource' => 'database/document/batch1', + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => ['key' => 'value1'], + 'time' => $timestamp1 + ], + [ + 'userId' => $userId, + 'event' => 'update', + 'resource' => 'database/document/batch2', + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => ['key' => 'value2'], + 'time' => $timestamp2 + ], + [ + 'userId' => $userId, + 'event' => 'delete', + 'resource' => 'database/document/batch3', + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => ['key' => 'value3'], + 'time' => $timestamp3 + ], + [ + 'userId' => null, + 'event' => 'insert', + 'resource' => 'user1/null', + 'userAgent' => $userAgent, + 'ip' => $ip, + 'location' => $location, + 'data' => ['key' => 'value4'], + 'time' => $timestamp3 + ] + ]; + + // Test batch insertion + $result = $this->audit->logBatch($batchEvents); + $this->assertIsArray($result); + $this->assertEquals(4, count($result)); + + // Verify the number of logs inserted + $logs = $this->audit->getLogsByUser($userId); + $this->assertEquals(3, count($logs)); + + // Verify chronological order (newest first due to orderDesc) + $this->assertEquals('delete', $logs[0]->getAttribute('event')); + $this->assertEquals('update', $logs[1]->getAttribute('event')); + $this->assertEquals('create', $logs[2]->getAttribute('event')); + + // Verify timestamps were preserved + $this->assertEquals($timestamp3, $logs[0]->getAttribute('time')); + $this->assertEquals($timestamp2, $logs[1]->getAttribute('time')); + $this->assertEquals($timestamp1, $logs[2]->getAttribute('time')); + + // Test resource-based retrieval + $resourceLogs = $this->audit->getLogsByResource('database/document/batch2'); + $this->assertEquals(1, count($resourceLogs)); + $this->assertEquals('update', $resourceLogs[0]->getAttribute('event')); + + // Test resource with userId null + $resourceLogs = $this->audit->getLogsByResource('user1/null'); + $this->assertEquals(1, count($resourceLogs)); + foreach ($resourceLogs as $log) { + $this->assertEquals('insert', $log->getAttribute('event')); + $this->assertNull($log['userId']); + } + + // Test event-based retrieval + $eventLogs = $this->audit->getLogsByUserAndEvents($userId, ['create', 'delete']); + $this->assertEquals(2, count($eventLogs)); + } + + public function testGetLogsCustomFilters(): void + { + $threshold = new \DateTime(); + $threshold->modify('-10 seconds'); + $logs = $this->audit->getLogsByUser('userId', after: $threshold); + + $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 = new \DateTime('2099-12-31 23:59:59'); + $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture); + $this->assertGreaterThanOrEqual(2, \count($beforeLogs)); + } + + public function testCleanup(): void + { + $status = $this->audit->cleanup(new \DateTime()); + $this->assertEquals($status, true); + + // Check that all logs have been deleted + $logs = $this->audit->getLogsByUser('userId'); + $this->assertEquals(0, \count($logs)); + + // Add three sample logs + $userId = 'userId'; + $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; + $ip = '127.0.0.1'; + $location = 'US'; + $data = ['key1' => 'value1', 'key2' => 'value2']; + + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); + sleep(5); + $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); + sleep(5); + $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 + $datetime = new \DateTime(); + $datetime->modify('-11 seconds'); + $status = $this->audit->cleanup($datetime); + $this->assertEquals($status, true); + + // Check if 1 log has been deleted + $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(new \DateTime()); + + $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 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)); + } +} diff --git a/tests/Audit/AuditTest.php b/tests/Audit/AuditTest.php deleted file mode 100644 index 9c78783..0000000 --- a/tests/Audit/AuditTest.php +++ /dev/null @@ -1,290 +0,0 @@ -setDatabase('utopiaTests'); - $database->setNamespace('namespace'); - - $this->audit = new Audit($database); - if (! $database->exists('utopiaTests')) { - $database->create(); - $this->audit->setup(); - } - - $this->createLogs(); - } - - public function tearDown(): void - { - $this->audit->cleanup(DateTime::now()); - } - - public function createLogs(): void - { - $userId = 'userId'; - $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; - $ip = '127.0.0.1'; - $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)); - } - - public function testGetLogsByUser(): void - { - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(3, \count($logs)); - - $logsCount = $this->audit->countLogsByUser('userId'); - $this->assertEquals(3, $logsCount); - - $logs1 = $this->audit->getLogsByUser('userId', [Query::limit(1), Query::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)]); - $this->assertEquals(1, \count($logs2)); - $this->assertEquals($logs2[0]->getId(), $logs[1]->getId()); - } - - public function testGetLogsByUserAndEvents(): void - { - $logs1 = $this->audit->getLogsByUserAndEvents('userId', ['update']); - $logs2 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete']); - - $this->assertEquals(2, \count($logs1)); - $this->assertEquals(3, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByUserAndEvents('userId', ['update']); - $logsCount2 = $this->audit->countLogsByUserAndEvents('userId', ['update', 'delete']); - - $this->assertEquals(2, $logsCount1); - $this->assertEquals(3, $logsCount2); - - $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], [Query::limit(1), Query::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)]); - - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - } - - public function testGetLogsByResourceAndEvents(): void - { - $logs1 = $this->audit->getLogsByResourceAndEvents('database/document/1', ['update']); - $logs2 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete']); - - $this->assertEquals(1, \count($logs1)); - $this->assertEquals(2, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByResourceAndEvents('database/document/1', ['update']); - $logsCount2 = $this->audit->countLogsByResourceAndEvents('database/document/2', ['update', 'delete']); - - $this->assertEquals(1, $logsCount1); - $this->assertEquals(2, $logsCount2); - - $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], [Query::limit(1), Query::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)]); - - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - } - - public function testGetLogsByResource(): void - { - $logs1 = $this->audit->getLogsByResource('database/document/1'); - $logs2 = $this->audit->getLogsByResource('database/document/2'); - - $this->assertEquals(1, \count($logs1)); - $this->assertEquals(2, \count($logs2)); - - $logsCount1 = $this->audit->countLogsByResource('database/document/1'); - $logsCount2 = $this->audit->countLogsByResource('database/document/2'); - - $this->assertEquals(1, $logsCount1); - $this->assertEquals(2, $logsCount2); - - $logs3 = $this->audit->getLogsByResource('database/document/2', [Query::limit(1), Query::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)]); - $this->assertEquals(1, \count($logs4)); - $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); - - $logs5 = $this->audit->getLogsByResource('user/null'); - $this->assertEquals(1, \count($logs5)); - $this->assertNull($logs5[0]['userId']); - $this->assertEquals('127.0.0.1', $logs5[0]['ip']); - } - - public function testLogByBatch(): void - { - // First cleanup existing logs - $this->audit->cleanup(DateTime::now()); - - $userId = 'batchUserId'; - $userAgent = 'Mozilla/5.0 (Test User Agent)'; - $ip = '192.168.1.1'; - $location = 'UK'; - - // Create timestamps 1 minute apart - $timestamp1 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -120)) ?? ''; - $timestamp2 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60)) ?? ''; - $timestamp3 = DateTime::formatTz(DateTime::now()) ?? ''; - - $batchEvents = [ - [ - 'userId' => $userId, - 'event' => 'create', - 'resource' => 'database/document/batch1', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'location' => $location, - 'data' => ['key' => 'value1'], - 'timestamp' => $timestamp1 - ], - [ - 'userId' => $userId, - 'event' => 'update', - 'resource' => 'database/document/batch2', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'location' => $location, - 'data' => ['key' => 'value2'], - 'timestamp' => $timestamp2 - ], - [ - 'userId' => $userId, - 'event' => 'delete', - 'resource' => 'database/document/batch3', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'location' => $location, - 'data' => ['key' => 'value3'], - 'timestamp' => $timestamp3 - ], - [ - 'userId' => null, - 'event' => 'insert', - 'resource' => 'user1/null', - 'userAgent' => $userAgent, - 'ip' => $ip, - 'location' => $location, - 'data' => ['key' => 'value4'], - 'timestamp' => $timestamp3 - ] - ]; - - // Test batch insertion - $this->assertTrue($this->audit->logBatch($batchEvents)); - - // Verify the number of logs inserted - $logs = $this->audit->getLogsByUser($userId); - $this->assertEquals(3, count($logs)); - - // Verify chronological order (newest first due to orderDesc) - $this->assertEquals('delete', $logs[0]->getAttribute('event')); - $this->assertEquals('update', $logs[1]->getAttribute('event')); - $this->assertEquals('create', $logs[2]->getAttribute('event')); - - // Verify timestamps were preserved - $this->assertEquals($timestamp3, $logs[0]->getAttribute('time')); - $this->assertEquals($timestamp2, $logs[1]->getAttribute('time')); - $this->assertEquals($timestamp1, $logs[2]->getAttribute('time')); - - // Test resource-based retrieval - $resourceLogs = $this->audit->getLogsByResource('database/document/batch2'); - $this->assertEquals(1, count($resourceLogs)); - $this->assertEquals('update', $resourceLogs[0]->getAttribute('event')); - - // Test resource with userId null - $resourceLogs = $this->audit->getLogsByResource('user1/null'); - $this->assertEquals(1, count($resourceLogs)); - foreach ($resourceLogs as $log) { - $this->assertEquals('insert', $log->getAttribute('event')); - $this->assertNull($log['userId']); - } - - // Test event-based retrieval - $eventLogs = $this->audit->getLogsByUserAndEvents($userId, ['create', 'delete']); - $this->assertEquals(2, count($eventLogs)); - } - - public function testGetLogsCustomFilters(): void - { - $logs = $this->audit->getLogsByUser('userId', queries: [ - Query::greaterThan('time', DateTime::addSeconds(new \DateTime(), -10)) - ]); - - $this->assertEquals(3, \count($logs)); - } - - public function testCleanup(): void - { - sleep(3); - // First delete all the logs - $status = $this->audit->cleanup(DateTime::now()); - $this->assertEquals($status, true); - - // Check that all logs have been deleted - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(0, \count($logs)); - - // Add three sample logs - $userId = 'userId'; - $userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36'; - $ip = '127.0.0.1'; - $location = 'US'; - $data = ['key1' => 'value1', 'key2' => 'value2']; - - $this->assertEquals($this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data), true); - sleep(5); - $this->assertEquals($this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data), true); - sleep(5); - $this->assertEquals($this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data), true); - sleep(5); - - // DELETE logs older than 11 seconds and check that status is true - $status = $this->audit->cleanup(DateTime::addSeconds(new \DateTime(), -11)); - $this->assertEquals($status, true); - - // Check if 1 log has been deleted - $logs = $this->audit->getLogsByUser('userId'); - $this->assertEquals(2, \count($logs)); - } -}