├── .gitignore ├── pint.json ├── .github ├── workflows │ ├── linter.yml │ ├── codeql-analysis.yml │ └── tests.yml └── ISSUE_TEMPLATE │ ├── documentation.yaml │ ├── feature.yaml │ └── bug.yaml ├── phpunit.xml ├── Dockerfile ├── LICENSE.md ├── composer.json ├── tests └── Audit │ ├── Adapter │ ├── DatabaseTest.php │ └── ClickHouseTest.php │ └── AuditBase.php ├── docker-compose.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── src └── Audit │ ├── Log.php │ ├── Adapter.php │ ├── Adapter │ ├── SQL.php │ ├── Database.php │ └── ClickHouse.php │ └── Audit.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12" 3 | } -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: "Linter" 2 | 3 | on: [ pull_request ] 4 | jobs: 5 | lint: 6 | name: Linter 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Run Linter 18 | run: | 19 | docker run --rm -v $PWD:/app composer sh -c \ 20 | "composer install --profile --ignore-platform-reqs && composer lint" 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: [ pull_request ] 4 | jobs: 5 | lint: 6 | name: CodeQL 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Run CodeQL 18 | run: | 19 | docker run --rm -v $PWD:/app composer sh -c \ 20 | "composer install --profile --ignore-platform-reqs && composer check" -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: [ pull_request ] 4 | jobs: 5 | lint: 6 | name: Tests 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Build 18 | run: | 19 | docker compose build 20 | docker compose up -d --wait 21 | 22 | - name: Run Tests 23 | run: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer:2.8 AS step0 2 | 3 | WORKDIR /src/ 4 | 5 | COPY composer.lock /src/ 6 | COPY composer.json /src/ 7 | 8 | RUN composer install --ignore-platform-reqs --optimize-autoloader \ 9 | --no-plugins --no-scripts --prefer-dist 10 | 11 | FROM php:8.3.3-cli-alpine3.19 AS final 12 | 13 | LABEL maintainer="team@appwrite.io" 14 | 15 | RUN docker-php-ext-install pdo_mysql 16 | 17 | WORKDIR /code 18 | 19 | COPY --from=step0 /src/vendor /code/vendor 20 | 21 | # Add Source Code 22 | COPY ./tests /code/tests 23 | COPY ./src /code/src 24 | COPY ./phpunit.xml /code/phpunit.xml 25 | 26 | CMD [ "tail", "-f", "/dev/null" ] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Eldad Fux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utopia-php/audit", 3 | "description": "A simple audit library to manage application users logs", 4 | "type": "library", 5 | "keywords": [ 6 | "php", 7 | "framework", 8 | "upf", 9 | "utopia", 10 | "audit" 11 | ], 12 | "license": "MIT", 13 | "minimum-stability": "stable", 14 | "scripts": { 15 | "lint": "./vendor/bin/pint --test", 16 | "format": "./vendor/bin/pint", 17 | "check": "./vendor/bin/phpstan analyse --level max src tests" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Utopia\\Audit\\": "src/Audit" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "Utopia\\Tests\\": "tests" 27 | } 28 | }, 29 | "require": { 30 | "php": ">=8.0", 31 | "utopia-php/database": "4.*", 32 | "utopia-php/fetch": "^0.4.2", 33 | "utopia-php/validators": "^0.1.0" 34 | }, 35 | "require-dev": { 36 | "phpunit/phpunit": "9.*", 37 | "phpstan/phpstan": "1.*", 38 | "laravel/pint": "1.*" 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "php-http/discovery": true, 43 | "tbachert/spi": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: "📚 Documentation" 2 | description: "Report an issue related to documentation" 3 | title: "📚 Documentation: " 4 | labels: [documentation] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to make our documentation better 🙏 10 | - type: textarea 11 | id: issue-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "💭 Description" 16 | description: "A clear and concise description of what the issue is." 17 | placeholder: "Documentation should not ..." 18 | - type: checkboxes 19 | id: no-duplicate-issues 20 | attributes: 21 | label: "👀 Have you spent some time to check if this issue has been raised before?" 22 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 23 | options: 24 | - label: "I checked and didn't find similar issue" 25 | required: true 26 | - type: checkboxes 27 | id: read-code-of-conduct 28 | attributes: 29 | label: "🏢 Have you read the Code of Conduct?" 30 | options: 31 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 32 | required: true 33 | -------------------------------------------------------------------------------- /tests/Audit/Adapter/DatabaseTest.php: -------------------------------------------------------------------------------- 1 | setDatabase('utopiaTests'); 33 | $database->setNamespace('namespace'); 34 | 35 | $adapter = new Adapter\Database($database); 36 | $this->audit = new Audit($adapter); 37 | if (! $database->exists('utopiaTests')) { 38 | $database->create(); 39 | $this->audit->setup(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature 2 | description: "Submit a proposal for a new feature" 3 | title: "🚀 Feature: " 4 | labels: [feature] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our feature request form 🙏 10 | - type: textarea 11 | id: feature-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "🔖 Feature description" 16 | description: "A clear and concise description of what the feature is." 17 | placeholder: "You should add ..." 18 | - type: textarea 19 | id: pitch 20 | validations: 21 | required: true 22 | attributes: 23 | label: "🎤 Pitch" 24 | description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable." 25 | placeholder: "In my use-case, ..." 26 | - type: checkboxes 27 | id: no-duplicate-issues 28 | attributes: 29 | label: "👀 Have you spent some time to check if this issue has been raised before?" 30 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 31 | options: 32 | - label: "I checked and didn't find similar issue" 33 | required: true 34 | - type: checkboxes 35 | id: read-code-of-conduct 36 | attributes: 37 | label: "🏢 Have you read the Code of Conduct?" 38 | options: 39 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 40 | required: true 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mariadb: 3 | image: mariadb:10.11 4 | environment: 5 | - MYSQL_ROOT_PASSWORD=password 6 | networks: 7 | - abuse 8 | ports: 9 | - "9306:3306" 10 | healthcheck: 11 | test: ["CMD", "sh", "-c", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"] 12 | interval: 5s 13 | timeout: 3s 14 | retries: 10 15 | start_period: 30s 16 | 17 | clickhouse: 18 | image: clickhouse/clickhouse-server:25.11-alpine 19 | environment: 20 | - CLICKHOUSE_DB=default 21 | - CLICKHOUSE_USER=default 22 | - CLICKHOUSE_PASSWORD=clickhouse 23 | - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 24 | networks: 25 | - abuse 26 | ports: 27 | - "8123:8123" 28 | - "9000:9000" 29 | healthcheck: 30 | test: ["CMD", "clickhouse-client", "--host=localhost", "--port=9000", "--query=SELECT 1", "--format=Null"] 31 | interval: 5s 32 | timeout: 3s 33 | retries: 10 34 | start_period: 15s 35 | 36 | tests: 37 | build: 38 | context: . 39 | dockerfile: ./Dockerfile 40 | networks: 41 | - abuse 42 | depends_on: 43 | mariadb: 44 | condition: service_healthy 45 | clickhouse: 46 | condition: service_healthy 47 | volumes: 48 | - ./phpunit.xml:/code/phpunit.xml 49 | - ./src:/code/src 50 | - ./tests:/code/tests 51 | healthcheck: 52 | test: ["CMD", "php", "--version"] 53 | interval: 5s 54 | timeout: 3s 55 | retries: 3 56 | start_period: 5s 57 | 58 | networks: 59 | abuse: 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Submit a bug report to help us improve" 3 | title: "🐛 Bug Report: " 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our bug report form 🙏 10 | - type: textarea 11 | id: steps-to-reproduce 12 | validations: 13 | required: true 14 | attributes: 15 | label: "👟 Reproduction steps" 16 | description: "How do you trigger this bug? Please walk us through it step by step." 17 | placeholder: "When I ..." 18 | - type: textarea 19 | id: expected-behavior 20 | validations: 21 | required: true 22 | attributes: 23 | label: "👍 Expected behavior" 24 | description: "What did you think would happen?" 25 | placeholder: "It should ..." 26 | - type: textarea 27 | id: actual-behavior 28 | validations: 29 | required: true 30 | attributes: 31 | label: "👎 Actual Behavior" 32 | description: "What did actually happen? Add screenshots, if applicable." 33 | placeholder: "It actually ..." 34 | - type: dropdown 35 | id: utopia-version 36 | attributes: 37 | label: "🎲 Utopia Audit version" 38 | description: "What version of Utopia Audit are you running?" 39 | options: 40 | - Version 0.6.x 41 | - Version 0.5.x 42 | - Version 0.4.x 43 | - Version 0.3.x 44 | - Version 0.2.x 45 | - Version 0.1.x 46 | - Different version (specify in environment) 47 | validations: 48 | required: true 49 | - type: dropdown 50 | id: operating-system 51 | attributes: 52 | label: "💻 Operating system" 53 | description: "What OS is your server / device running on?" 54 | options: 55 | - Linux 56 | - MacOS 57 | - Windows 58 | - Something else 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: enviromnemt 63 | validations: 64 | required: false 65 | attributes: 66 | label: "🧱 Your Environment" 67 | description: "Is your environment customized in any way?" 68 | placeholder: "I use Cloudflare for ..." 69 | - type: checkboxes 70 | id: no-duplicate-issues 71 | attributes: 72 | label: "👀 Have you spent some time to check if this issue has been raised before?" 73 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 74 | options: 75 | - label: "I checked and didn't find similar issue" 76 | required: true 77 | - type: checkboxes 78 | id: read-code-of-conduct 79 | attributes: 80 | label: "🏢 Have you read the Code of Conduct?" 81 | options: 82 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 83 | required: true 84 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity, expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@appwrite.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would ❤️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. 4 | 5 | ## How to Start? 6 | 7 | If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you! 8 | 9 | ## Code of Conduct 10 | 11 | Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](https://github.com/appwrite/appwrite/blob/master/CODE_OF_CONDUCT.md). 12 | 13 | ## Submit a Pull Request 🚀 14 | 15 | Branch naming convention is as following 16 | 17 | `TYPE-ISSUE_ID-DESCRIPTION` 18 | 19 | example: 20 | 21 | ``` 22 | doc-548-submit-a-pull-request-section-to-contribution-guide 23 | ``` 24 | 25 | When `TYPE` can be: 26 | 27 | - **feat** - is a new feature 28 | - **doc** - documentation only changes 29 | - **cicd** - changes related to CI/CD system 30 | - **fix** - a bug fix 31 | - **refactor** - code change that neither fixes a bug nor adds a feature 32 | 33 | **All PRs must include a commit message with the changes description!** 34 | 35 | For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: 36 | 37 | 1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date. 38 | 39 | ``` 40 | $ git pull 41 | ``` 42 | 43 | 2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
44 | 45 | ``` 46 | $ git checkout -b [name_of_your_new_branch] 47 | ``` 48 | 49 | 3. Work - commit - repeat ( be sure to be in your branch ) 50 | 51 | 4. Push changes to GitHub 52 | 53 | ``` 54 | $ git push origin [name_of_your_new_branch] 55 | ``` 56 | 57 | 5. Submit your changes for review 58 | If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. 59 | 6. Start a Pull Request 60 | Now submit the pull request and click on `Create pull request`. 61 | 7. Get a code review approval/reject 62 | 8. After approval, merge your PR 63 | 9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). 64 | 65 | ## Introducing New Features 66 | 67 | We would 💖 you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement 🙏. 68 | 69 | For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. 70 | 71 | This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. 72 | 73 | This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). 74 | 75 | ## Other Ways to Help 76 | 77 | Pull requests are great, but there are many other areas where you can help Utopia-php. 78 | 79 | ### Blogging & Speaking 80 | 81 | Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow. 82 | 83 | ### Presenting at Meetups 84 | 85 | Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! 86 | 87 | ### Sending Feedbacks & Reporting Bugs 88 | 89 | Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA). 90 | 91 | ### Submitting New Ideas 92 | 93 | If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. 94 | 95 | ### Improving Documentation 96 | 97 | Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. 98 | 99 | ### Helping Someone 100 | 101 | Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo! -------------------------------------------------------------------------------- /src/Audit/Log.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class Log extends ArrayObject 16 | { 17 | /** 18 | * Construct a new audit log object. 19 | * 20 | * @param array $input 21 | */ 22 | public function __construct(array $input = []) 23 | { 24 | parent::__construct($input); 25 | } 26 | 27 | /** 28 | * Get the log ID. 29 | * 30 | * @return string 31 | */ 32 | public function getId(): string 33 | { 34 | $id = $this->getAttribute('$id', ''); 35 | return is_string($id) ? $id : ''; 36 | } 37 | 38 | /** 39 | * Get the user ID associated with this log entry. 40 | * 41 | * @return string|null 42 | */ 43 | public function getUserId(): ?string 44 | { 45 | $userId = $this->getAttribute('userId'); 46 | return is_string($userId) ? $userId : null; 47 | } 48 | 49 | /** 50 | * Get the event name. 51 | * 52 | * @return string 53 | */ 54 | public function getEvent(): string 55 | { 56 | $event = $this->getAttribute('event', ''); 57 | return is_string($event) ? $event : ''; 58 | } 59 | 60 | /** 61 | * Get the resource identifier. 62 | * 63 | * @return string 64 | */ 65 | public function getResource(): string 66 | { 67 | $resource = $this->getAttribute('resource', ''); 68 | return is_string($resource) ? $resource : ''; 69 | } 70 | 71 | /** 72 | * Get the user agent string. 73 | * 74 | * @return string 75 | */ 76 | public function getUserAgent(): string 77 | { 78 | $userAgent = $this->getAttribute('userAgent', ''); 79 | return is_string($userAgent) ? $userAgent : ''; 80 | } 81 | 82 | /** 83 | * Get the IP address. 84 | * 85 | * @return string 86 | */ 87 | public function getIp(): string 88 | { 89 | $ip = $this->getAttribute('ip', ''); 90 | return is_string($ip) ? $ip : ''; 91 | } 92 | 93 | /** 94 | * Get the location information. 95 | * 96 | * @return string|null 97 | */ 98 | public function getLocation(): ?string 99 | { 100 | $location = $this->getAttribute('location'); 101 | return is_string($location) ? $location : null; 102 | } 103 | 104 | /** 105 | * Get the timestamp. 106 | * 107 | * @return string 108 | */ 109 | public function getTime(): string 110 | { 111 | $time = $this->getAttribute('time', ''); 112 | return is_string($time) ? $time : ''; 113 | } 114 | 115 | /** 116 | * Get the additional data. 117 | * 118 | * @return array 119 | */ 120 | public function getData(): array 121 | { 122 | $data = $this->getAttribute('data', []); 123 | return is_array($data) ? $data : []; 124 | } 125 | 126 | /** 127 | * Get the tenant ID (for multi-tenant setups). 128 | * 129 | * @return int|null 130 | */ 131 | public function getTenant(): ?int 132 | { 133 | $tenant = $this->getAttribute('tenant'); 134 | 135 | if ($tenant === null) { 136 | return null; 137 | } 138 | 139 | if (is_int($tenant)) { 140 | return $tenant; 141 | } 142 | 143 | if (is_numeric($tenant)) { 144 | return (int) $tenant; 145 | } 146 | 147 | return null; 148 | } 149 | 150 | /** 151 | * Get an attribute by key. 152 | * 153 | * @param string $key 154 | * @param mixed $default 155 | * @return mixed 156 | */ 157 | public function getAttribute(string $key, mixed $default = null): mixed 158 | { 159 | return $this->offsetExists($key) ? $this->offsetGet($key) : $default; 160 | } 161 | 162 | /** 163 | * Set an attribute. 164 | * 165 | * @param string $key 166 | * @param mixed $value 167 | * @return self 168 | */ 169 | public function setAttribute(string $key, mixed $value): self 170 | { 171 | $this->offsetSet($key, $value); 172 | return $this; 173 | } 174 | 175 | /** 176 | * Remove an attribute. 177 | * 178 | * @param string $key 179 | * @return self 180 | */ 181 | public function removeAttribute(string $key): self 182 | { 183 | if ($this->offsetExists($key)) { 184 | $this->offsetUnset($key); 185 | } 186 | return $this; 187 | } 188 | 189 | /** 190 | * Check if an attribute exists. 191 | * 192 | * @param string $key 193 | * @return bool 194 | */ 195 | public function isSet(string $key): bool 196 | { 197 | return $this->offsetExists($key); 198 | } 199 | 200 | /** 201 | * Get all attributes as an array. 202 | * 203 | * @return array 204 | */ 205 | public function getArrayCopy(): array 206 | { 207 | return parent::getArrayCopy(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Audit/Adapter.php: -------------------------------------------------------------------------------- 1 | 38 | * } $log 39 | * @return Log The created log entry 40 | * 41 | * @throws \Exception 42 | */ 43 | abstract public function create(array $log): Log; 44 | 45 | /** 46 | * Create multiple audit log entries in batch. 47 | * 48 | * @param array 57 | * }> $logs 58 | * @return array 59 | * 60 | * @throws \Exception 61 | */ 62 | abstract public function createBatch(array $logs): array; 63 | 64 | /** 65 | * Get logs by user ID. 66 | * 67 | * @param string $userId 68 | * @return array 69 | * 70 | * @throws \Exception 71 | */ 72 | abstract public function getByUser( 73 | string $userId, 74 | ?\DateTime $after = null, 75 | ?\DateTime $before = null, 76 | int $limit = 25, 77 | int $offset = 0, 78 | bool $ascending = false, 79 | ): array; 80 | 81 | /** 82 | * Count logs by user ID. 83 | * 84 | * @param string $userId 85 | * @return int 86 | * 87 | * @throws \Exception 88 | */ 89 | abstract public function countByUser( 90 | string $userId, 91 | ?\DateTime $after = null, 92 | ?\DateTime $before = null, 93 | ): int; 94 | 95 | /** 96 | * Get logs by resource. 97 | * 98 | * @param string $resource 99 | * @return array 100 | * 101 | * @throws \Exception 102 | */ 103 | abstract public function getByResource( 104 | string $resource, 105 | ?\DateTime $after = null, 106 | ?\DateTime $before = null, 107 | int $limit = 25, 108 | int $offset = 0, 109 | bool $ascending = false, 110 | ): array; 111 | 112 | /** 113 | * Count logs by resource. 114 | * 115 | * @param string $resource 116 | * @return int 117 | * 118 | * @throws \Exception 119 | */ 120 | abstract public function countByResource( 121 | string $resource, 122 | ?\DateTime $after = null, 123 | ?\DateTime $before = null, 124 | ): int; 125 | 126 | /** 127 | * Get logs by user and events. 128 | * 129 | * @param string $userId 130 | * @param array $events 131 | * @return array 132 | * 133 | * @throws \Exception 134 | */ 135 | abstract public function getByUserAndEvents( 136 | string $userId, 137 | array $events, 138 | ?\DateTime $after = null, 139 | ?\DateTime $before = null, 140 | int $limit = 25, 141 | int $offset = 0, 142 | bool $ascending = false, 143 | ): array; 144 | 145 | /** 146 | * Count logs by user and events. 147 | * 148 | * @param string $userId 149 | * @param array $events 150 | * @return int 151 | * 152 | * @throws \Exception 153 | */ 154 | abstract public function countByUserAndEvents( 155 | string $userId, 156 | array $events, 157 | ?\DateTime $after = null, 158 | ?\DateTime $before = null, 159 | ): int; 160 | 161 | /** 162 | * Get logs by resource and events. 163 | * 164 | * @param string $resource 165 | * @param array $events 166 | * @return array 167 | * 168 | * @throws \Exception 169 | */ 170 | abstract public function getByResourceAndEvents( 171 | string $resource, 172 | array $events, 173 | ?\DateTime $after = null, 174 | ?\DateTime $before = null, 175 | int $limit = 25, 176 | int $offset = 0, 177 | bool $ascending = false, 178 | ): array; 179 | 180 | /** 181 | * Count logs by resource and events. 182 | * 183 | * @param string $resource 184 | * @param array $events 185 | * @return int 186 | * 187 | * @throws \Exception 188 | */ 189 | abstract public function countByResourceAndEvents( 190 | string $resource, 191 | array $events, 192 | ?\DateTime $after = null, 193 | ?\DateTime $before = null, 194 | ): int; 195 | 196 | /** 197 | * Delete logs older than the specified datetime. 198 | * 199 | * @param \DateTime $datetime 200 | * @return bool 201 | * 202 | * @throws \Exception 203 | */ 204 | abstract public function cleanup(\DateTime $datetime): bool; 205 | } 206 | -------------------------------------------------------------------------------- /src/Audit/Adapter/SQL.php: -------------------------------------------------------------------------------- 1 | 40 | * 41 | * @return array> 42 | */ 43 | protected function getAttributes(): array 44 | { 45 | return [ 46 | [ 47 | '$id' => 'userId', 48 | 'type' => Database::VAR_STRING, 49 | 'size' => Database::LENGTH_KEY, 50 | 'required' => false, 51 | 'signed' => true, 52 | 'array' => false, 53 | 'filters' => [], 54 | ], 55 | [ 56 | '$id' => 'event', 57 | 'type' => Database::VAR_STRING, 58 | 'size' => 255, 59 | 'required' => true, 60 | 'signed' => true, 61 | 'array' => false, 62 | 'filters' => [], 63 | ], 64 | [ 65 | '$id' => 'resource', 66 | 'type' => Database::VAR_STRING, 67 | 'size' => 255, 68 | 'required' => false, 69 | 'signed' => true, 70 | 'array' => false, 71 | 'filters' => [], 72 | ], 73 | [ 74 | '$id' => 'userAgent', 75 | 'type' => Database::VAR_STRING, 76 | 'size' => 65534, 77 | 'required' => true, 78 | 'signed' => true, 79 | 'array' => false, 80 | 'filters' => [], 81 | ], 82 | [ 83 | '$id' => 'ip', 84 | 'type' => Database::VAR_STRING, 85 | 'size' => 45, 86 | 'required' => true, 87 | 'signed' => true, 88 | 'array' => false, 89 | 'filters' => [], 90 | ], 91 | [ 92 | '$id' => 'location', 93 | 'type' => Database::VAR_STRING, 94 | 'size' => 45, 95 | 'required' => false, 96 | 'signed' => true, 97 | 'array' => false, 98 | 'filters' => [], 99 | ], 100 | [ 101 | '$id' => 'time', 102 | 'type' => Database::VAR_DATETIME, 103 | 'format' => '', 104 | 'size' => 0, 105 | 'signed' => true, 106 | 'required' => false, 107 | 'array' => false, 108 | 'filters' => ['datetime'], 109 | ], 110 | [ 111 | '$id' => 'data', 112 | 'type' => Database::VAR_STRING, 113 | 'size' => 16777216, 114 | 'required' => false, 115 | 'signed' => true, 116 | 'array' => false, 117 | 'filters' => ['json'], 118 | ], 119 | ]; 120 | } 121 | 122 | /** 123 | * Get attribute documents for audit logs. 124 | * 125 | * @return array 126 | */ 127 | protected function getAttributeDocuments(): array 128 | { 129 | return array_map(static fn (array $attribute) => new Document($attribute), $this->getAttributes()); 130 | } 131 | 132 | /** 133 | * Get index definitions for audit logs. 134 | * 135 | * Each index is an array with the following string keys: 136 | * - $id: string (index identifier) 137 | * - type: string 138 | * - attributes: array 139 | * 140 | * @return array> 141 | */ 142 | protected function getIndexes(): array 143 | { 144 | return [ 145 | [ 146 | '$id' => 'idx_event', 147 | 'type' => 'key', 148 | 'attributes' => ['event'], 149 | ], 150 | [ 151 | '$id' => 'idx_userId_event', 152 | 'type' => 'key', 153 | 'attributes' => ['userId', 'event'], 154 | ], 155 | [ 156 | '$id' => 'idx_resource_event', 157 | 'type' => 'key', 158 | 'attributes' => ['resource', 'event'], 159 | ], 160 | [ 161 | '$id' => 'idx_time_desc', 162 | 'type' => 'key', 163 | 'attributes' => ['time'], 164 | ], 165 | ]; 166 | } 167 | 168 | /** 169 | * Get index documents for audit logs. 170 | * 171 | * @return array 172 | */ 173 | protected function getIndexDocuments(): array 174 | { 175 | return array_map(static fn (array $index) => new Document($index), $this->getIndexes()); 176 | } 177 | 178 | /** 179 | * Get a single attribute by ID. 180 | * 181 | * @param string $id 182 | * @return array|null 183 | */ 184 | protected function getAttribute(string $id) 185 | { 186 | foreach ($this->getAttributes() as $attribute) { 187 | if ($attribute['$id'] === $id) { 188 | return $attribute; 189 | } 190 | } 191 | 192 | return null; 193 | } 194 | 195 | /** 196 | * Get SQL column definition for a given attribute ID. 197 | * This method is database-specific and must be implemented by each concrete adapter. 198 | * 199 | * @param string $id Attribute identifier 200 | * @return string Database-specific column definition 201 | */ 202 | abstract protected function getColumnDefinition(string $id): string; 203 | 204 | /** 205 | * Get all SQL column definitions. 206 | * Uses the concrete adapter's implementation of getColumnDefinition. 207 | * 208 | * @return array 209 | */ 210 | protected function getAllColumnDefinitions(): array 211 | { 212 | $definitions = []; 213 | foreach ($this->getAttributes() as $attribute) { 214 | /** @var string $id */ 215 | $id = $attribute['$id']; 216 | $definitions[] = $this->getColumnDefinition($id); 217 | } 218 | 219 | return $definitions; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/Audit/Audit.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 24 | } 25 | 26 | /** 27 | * Get the current adapter. 28 | * 29 | * @return Adapter 30 | */ 31 | public function getAdapter(): Adapter 32 | { 33 | return $this->adapter; 34 | } 35 | 36 | /** 37 | * Setup the audit log storage. 38 | * 39 | * @return void 40 | * @throws \Exception 41 | */ 42 | public function setup(): void 43 | { 44 | $this->adapter->setup(); 45 | } 46 | 47 | /** 48 | * Add event log. 49 | * 50 | * @param string|null $userId 51 | * @param string $event 52 | * @param string $resource 53 | * @param string $userAgent 54 | * @param string $ip 55 | * @param string $location 56 | * @param array $data 57 | * @return Log 58 | * 59 | * @throws \Exception 60 | */ 61 | public function log(?string $userId, string $event, string $resource, string $userAgent, string $ip, string $location, array $data = []): Log 62 | { 63 | return $this->adapter->create([ 64 | 'userId' => $userId, 65 | 'event' => $event, 66 | 'resource' => $resource, 67 | 'userAgent' => $userAgent, 68 | 'ip' => $ip, 69 | 'location' => $location, 70 | 'data' => $data, 71 | ]); 72 | } 73 | 74 | /** 75 | * Add multiple event logs in batch. 76 | * 77 | * @param array}> $events 78 | * @return array 79 | * 80 | * @throws \Exception 81 | */ 82 | public function logBatch(array $events): array 83 | { 84 | return $this->adapter->createBatch($events); 85 | } 86 | 87 | /** 88 | * Get all logs by user ID. 89 | * 90 | * @param string $userId 91 | * @return array 92 | * 93 | * @throws \Exception 94 | */ 95 | public function getLogsByUser( 96 | string $userId, 97 | ?\DateTime $after = null, 98 | ?\DateTime $before = null, 99 | int $limit = 25, 100 | int $offset = 0, 101 | bool $ascending = false, 102 | ): array { 103 | return $this->adapter->getByUser($userId, $after, $before, $limit, $offset, $ascending); 104 | } 105 | 106 | /** 107 | * Count logs by user ID. 108 | * 109 | * @param string $userId 110 | * @return int 111 | * @throws \Exception 112 | */ 113 | public function countLogsByUser( 114 | string $userId, 115 | ?\DateTime $after = null, 116 | ?\DateTime $before = null, 117 | ): int { 118 | return $this->adapter->countByUser($userId, $after, $before); 119 | } 120 | 121 | /** 122 | * Get all logs by resource. 123 | * 124 | * @param string $resource 125 | * @return array 126 | * 127 | * @throws \Exception 128 | */ 129 | public function getLogsByResource( 130 | string $resource, 131 | ?\DateTime $after = null, 132 | ?\DateTime $before = null, 133 | int $limit = 25, 134 | int $offset = 0, 135 | bool $ascending = false, 136 | ): array { 137 | return $this->adapter->getByResource($resource, $after, $before, $limit, $offset, $ascending); 138 | } 139 | 140 | /** 141 | * Count logs by resource. 142 | * 143 | * @param string $resource 144 | * @return int 145 | * 146 | * @throws \Exception 147 | */ 148 | public function countLogsByResource( 149 | string $resource, 150 | ?\DateTime $after = null, 151 | ?\DateTime $before = null, 152 | ): int { 153 | return $this->adapter->countByResource($resource, $after, $before); 154 | } 155 | 156 | /** 157 | * Get logs by user and events. 158 | * 159 | * @param string $userId 160 | * @param array $events 161 | * @return array 162 | * 163 | * @throws \Exception 164 | */ 165 | public function getLogsByUserAndEvents( 166 | string $userId, 167 | array $events, 168 | ?\DateTime $after = null, 169 | ?\DateTime $before = null, 170 | int $limit = 25, 171 | int $offset = 0, 172 | bool $ascending = false, 173 | ): array { 174 | return $this->adapter->getByUserAndEvents($userId, $events, $after, $before, $limit, $offset, $ascending); 175 | } 176 | 177 | /** 178 | * Count logs by user and events. 179 | * 180 | * @param string $userId 181 | * @param array $events 182 | * @return int 183 | * 184 | * @throws \Exception 185 | */ 186 | public function countLogsByUserAndEvents( 187 | string $userId, 188 | array $events, 189 | ?\DateTime $after = null, 190 | ?\DateTime $before = null, 191 | ): int { 192 | return $this->adapter->countByUserAndEvents($userId, $events, $after, $before); 193 | } 194 | 195 | /** 196 | * Get logs by resource and events. 197 | * 198 | * @param string $resource 199 | * @param array $events 200 | * @return array 201 | * 202 | * @throws \Exception 203 | */ 204 | public function getLogsByResourceAndEvents( 205 | string $resource, 206 | array $events, 207 | ?\DateTime $after = null, 208 | ?\DateTime $before = null, 209 | int $limit = 25, 210 | int $offset = 0, 211 | bool $ascending = false, 212 | ): array { 213 | return $this->adapter->getByResourceAndEvents($resource, $events, $after, $before, $limit, $offset, $ascending); 214 | } 215 | 216 | /** 217 | * Count logs by resource and events. 218 | * 219 | * @param string $resource 220 | * @param array $events 221 | * @return int 222 | * 223 | * @throws \Exception 224 | */ 225 | public function countLogsByResourceAndEvents( 226 | string $resource, 227 | array $events, 228 | ?\DateTime $after = null, 229 | ?\DateTime $before = null, 230 | ): int { 231 | return $this->adapter->countByResourceAndEvents($resource, $events, $after, $before); 232 | } 233 | 234 | /** 235 | * Delete all logs older than the specified datetime 236 | * 237 | * @param \DateTime $datetime 238 | * @return bool 239 | * 240 | * @throws \Exception 241 | */ 242 | public function cleanup(\DateTime $datetime): bool 243 | { 244 | return $this->adapter->cleanup($datetime); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /tests/Audit/Adapter/ClickHouseTest.php: -------------------------------------------------------------------------------- 1 | setDatabase('default'); 31 | 32 | $this->audit = new Audit($clickHouse); 33 | $this->audit->setup(); 34 | } 35 | 36 | /** 37 | * Test constructor validates host 38 | */ 39 | public function testConstructorValidatesHost(): void 40 | { 41 | $this->expectException(Exception::class); 42 | $this->expectExceptionMessage('ClickHouse host is not a valid hostname or IP address'); 43 | 44 | new ClickHouse( 45 | host: '', 46 | username: 'default', 47 | password: '' 48 | ); 49 | } 50 | 51 | /** 52 | * Test constructor validates port range 53 | */ 54 | public function testConstructorValidatesPortTooLow(): void 55 | { 56 | $this->expectException(Exception::class); 57 | $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); 58 | 59 | new ClickHouse( 60 | host: 'localhost', 61 | username: 'default', 62 | password: '', 63 | port: 0 64 | ); 65 | } 66 | 67 | /** 68 | * Test constructor validates port range upper bound 69 | */ 70 | public function testConstructorValidatesPortTooHigh(): void 71 | { 72 | $this->expectException(Exception::class); 73 | $this->expectExceptionMessage('ClickHouse port must be between 1 and 65535'); 74 | 75 | new ClickHouse( 76 | host: 'localhost', 77 | username: 'default', 78 | password: '', 79 | port: 65536 80 | ); 81 | } 82 | 83 | /** 84 | * Test constructor with valid parameters 85 | */ 86 | public function testConstructorWithValidParameters(): void 87 | { 88 | $adapter = new ClickHouse( 89 | host: 'clickhouse', 90 | username: 'testuser', 91 | password: 'testpass', 92 | port: 8443, 93 | secure: true 94 | ); 95 | 96 | $this->assertInstanceOf(ClickHouse::class, $adapter); 97 | $this->assertEquals('ClickHouse', $adapter->getName()); 98 | } 99 | 100 | /** 101 | * Test getName returns correct adapter name 102 | */ 103 | public function testGetName(): void 104 | { 105 | $adapter = new ClickHouse( 106 | host: 'clickhouse', 107 | username: 'default', 108 | password: 'clickhouse' 109 | ); 110 | 111 | $this->assertEquals('ClickHouse', $adapter->getName()); 112 | } 113 | 114 | /** 115 | * Test setDatabase validates empty identifier 116 | */ 117 | public function testSetDatabaseValidatesEmpty(): void 118 | { 119 | $this->expectException(Exception::class); 120 | $this->expectExceptionMessage('Database cannot be empty'); 121 | 122 | $adapter = new ClickHouse( 123 | host: 'clickhouse', 124 | username: 'default', 125 | password: 'clickhouse' 126 | ); 127 | 128 | $adapter->setDatabase(''); 129 | } 130 | 131 | /** 132 | * Test setDatabase validates identifier length 133 | */ 134 | public function testSetDatabaseValidatesLength(): void 135 | { 136 | $this->expectException(Exception::class); 137 | $this->expectExceptionMessage('Database cannot exceed 255 characters'); 138 | 139 | $adapter = new ClickHouse( 140 | host: 'clickhouse', 141 | username: 'default', 142 | password: 'clickhouse' 143 | ); 144 | 145 | $adapter->setDatabase(str_repeat('a', 256)); 146 | } 147 | 148 | /** 149 | * Test setDatabase validates identifier format 150 | */ 151 | public function testSetDatabaseValidatesFormat(): void 152 | { 153 | $this->expectException(Exception::class); 154 | $this->expectExceptionMessage('Database must start with a letter or underscore'); 155 | 156 | $adapter = new ClickHouse( 157 | host: 'clickhouse', 158 | username: 'default', 159 | password: 'clickhouse' 160 | ); 161 | 162 | $adapter->setDatabase('123invalid'); 163 | } 164 | 165 | /** 166 | * Test setDatabase rejects SQL keywords 167 | */ 168 | public function testSetDatabaseRejectsKeywords(): void 169 | { 170 | $this->expectException(Exception::class); 171 | $this->expectExceptionMessage('Database cannot be a reserved SQL keyword'); 172 | 173 | $adapter = new ClickHouse( 174 | host: 'clickhouse', 175 | username: 'default', 176 | password: 'clickhouse' 177 | ); 178 | 179 | $adapter->setDatabase('SELECT'); 180 | } 181 | 182 | /** 183 | * Test setDatabase with valid identifier 184 | */ 185 | public function testSetDatabaseWithValidIdentifier(): void 186 | { 187 | $adapter = new ClickHouse( 188 | host: 'clickhouse', 189 | username: 'default', 190 | password: 'clickhouse' 191 | ); 192 | 193 | $result = $adapter->setDatabase('my_database_123'); 194 | $this->assertInstanceOf(ClickHouse::class, $result); 195 | } 196 | 197 | /** 198 | * Test setNamespace allows empty string 199 | */ 200 | public function testSetNamespaceAllowsEmpty(): void 201 | { 202 | $adapter = new ClickHouse( 203 | host: 'clickhouse', 204 | username: 'default', 205 | password: 'clickhouse' 206 | ); 207 | 208 | $result = $adapter->setNamespace(''); 209 | $this->assertInstanceOf(ClickHouse::class, $result); 210 | $this->assertEquals('', $adapter->getNamespace()); 211 | } 212 | 213 | /** 214 | * Test setNamespace validates identifier format 215 | */ 216 | public function testSetNamespaceValidatesFormat(): void 217 | { 218 | $this->expectException(Exception::class); 219 | $this->expectExceptionMessage('Namespace must start with a letter or underscore'); 220 | 221 | $adapter = new ClickHouse( 222 | host: 'clickhouse', 223 | username: 'default', 224 | password: 'clickhouse' 225 | ); 226 | 227 | $adapter->setNamespace('9invalid'); 228 | } 229 | 230 | /** 231 | * Test setNamespace with valid identifier 232 | */ 233 | public function testSetNamespaceWithValidIdentifier(): void 234 | { 235 | $adapter = new ClickHouse( 236 | host: 'clickhouse', 237 | username: 'default', 238 | password: 'clickhouse' 239 | ); 240 | 241 | $result = $adapter->setNamespace('project_123'); 242 | $this->assertInstanceOf(ClickHouse::class, $result); 243 | $this->assertEquals('project_123', $adapter->getNamespace()); 244 | } 245 | 246 | /** 247 | * Test setSecure method 248 | */ 249 | public function testSetSecure(): void 250 | { 251 | $adapter = new ClickHouse( 252 | host: 'clickhouse', 253 | username: 'default', 254 | password: 'clickhouse', 255 | port: 8123, 256 | secure: false 257 | ); 258 | 259 | $result = $adapter->setSecure(true); 260 | $this->assertInstanceOf(ClickHouse::class, $result); 261 | } 262 | 263 | /** 264 | * Test shared tables configuration 265 | */ 266 | public function testSharedTablesConfiguration(): void 267 | { 268 | $adapter = new ClickHouse( 269 | host: 'clickhouse', 270 | username: 'default', 271 | password: 'clickhouse' 272 | ); 273 | 274 | // Test initial state 275 | $this->assertFalse($adapter->isSharedTables()); 276 | $this->assertNull($adapter->getTenant()); 277 | 278 | // Test setting shared tables 279 | $result = $adapter->setSharedTables(true); 280 | $this->assertInstanceOf(ClickHouse::class, $result); 281 | $this->assertTrue($adapter->isSharedTables()); 282 | 283 | // Test setting tenant 284 | $result2 = $adapter->setTenant(12345); 285 | $this->assertInstanceOf(ClickHouse::class, $result2); 286 | $this->assertEquals(12345, $adapter->getTenant()); 287 | 288 | // Test setting tenant to null 289 | $adapter->setTenant(null); 290 | $this->assertNull($adapter->getTenant()); 291 | } 292 | 293 | /** 294 | * Test batch operations with special characters 295 | */ 296 | public function testBatchOperationsWithSpecialCharacters(): void 297 | { 298 | // Test batch with special characters in data 299 | $batchEvents = [ 300 | [ 301 | 'userId' => 'user`with`backticks', 302 | 'event' => 'create', 303 | 'resource' => 'doc/"quotes"', 304 | 'userAgent' => "User'Agent\"With'Quotes", 305 | 'ip' => '192.168.1.1', 306 | 'location' => 'UK', 307 | 'data' => ['special' => "data with 'quotes'"], 308 | 'time' => \Utopia\Database\DateTime::formatTz(\Utopia\Database\DateTime::now()) ?? '' 309 | ] 310 | ]; 311 | 312 | $result = $this->audit->logBatch($batchEvents); 313 | $this->assertEquals(1, count($result)); 314 | 315 | // Verify retrieval 316 | $logs = $this->audit->getLogsByUser('user`with`backticks'); 317 | $this->assertGreaterThan(0, count($logs)); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utopia Audit 2 | 3 | [![Build Status](https://travis-ci.org/utopia-php/audit.svg?branch=master)](https://travis-ci.com/utopia-php/audit) 4 | ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/audit.svg) 5 | [![Discord](https://img.shields.io/discord/564160730845151244)](https://appwrite.io/discord) 6 | 7 | Utopia framework audit library is simple and lite library for managing application user logs. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). 8 | 9 | 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. 10 | 11 | ## Features 12 | 13 | - **Adapter Pattern**: Support for multiple storage backends through adapters 14 | - **Default Database Adapter**: Built-in support for utopia-php/database 15 | - **Extensible**: Easy to create custom adapters for different storage solutions 16 | - **Batch Operations**: Support for logging multiple events at once 17 | - **Query Support**: Rich querying capabilities for retrieving logs 18 | 19 | ## Getting Started 20 | 21 | Install using composer: 22 | ```bash 23 | composer require utopia-php/audit 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Using the Database Adapter (Default) 29 | 30 | The simplest way to use Utopia Audit is with the built-in Database adapter: 31 | 32 | ```php 33 | 3, 52 | PDO::ATTR_PERSISTENT => true, 53 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 54 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 55 | PDO::ATTR_EMULATE_PREPARES => true, 56 | PDO::ATTR_STRINGIFY_FETCHES => true, 57 | ]); 58 | 59 | $cache = new Cache(new NoCache()); 60 | 61 | $database = new Database(new MySQL($pdo), $cache); 62 | $database->setNamespace('namespace'); 63 | 64 | // Create audit instance with Database adapter 65 | $audit = new Audit(new DatabaseAdapter($database)); 66 | $audit->setup(); 67 | ``` 68 | 69 | ### Using a Custom Adapter 70 | 71 | You can create custom adapters by extending the `Utopia\Audit\Adapter` abstract class: 72 | 73 | ```php 74 | 'value1','key2' => 'value2']; // Any key-value pair you need to log 99 | 100 | $audit->log($userId, $event, $resource, $userAgent, $ip, $location, $data); 101 | ``` 102 | 103 | **Get Logs By User** 104 | 105 | Fetch all logs by given user ID with optional filtering parameters: 106 | 107 | ```php 108 | // Basic usage 109 | $logs = $audit->getLogsByUser('userId'); 110 | 111 | // With pagination 112 | $logs = $audit->getLogsByUser( 113 | userId: 'userId', 114 | limit: 10, 115 | offset: 0 116 | ); 117 | 118 | // With time filtering using DateTime objects 119 | $logs = $audit->getLogsByUser( 120 | userId: 'userId', 121 | after: new \DateTime('2024-01-01 00:00:00'), 122 | before: new \DateTime('2024-12-31 23:59:59'), 123 | limit: 25, 124 | offset: 0, 125 | ascending: false // false = newest first (default), true = oldest first 126 | ); 127 | ``` 128 | 129 | **Get Logs By User and Action** 130 | 131 | Fetch all logs by given user ID and specific event names with optional filtering: 132 | 133 | ```php 134 | // Basic usage 135 | $logs = $audit->getLogsByUserAndEvents( 136 | userId: 'userId', 137 | events: ['update', 'delete'] 138 | ); 139 | 140 | // With time filtering and pagination 141 | $logs = $audit->getLogsByUserAndEvents( 142 | userId: 'userId', 143 | events: ['update', 'delete'], 144 | after: new \DateTime('-7 days'), 145 | before: new \DateTime('now'), 146 | limit: 50, 147 | offset: 0, 148 | ascending: false 149 | ); 150 | ``` 151 | 152 | **Get Logs By Resource** 153 | 154 | Fetch all logs by a given resource name with optional filtering: 155 | 156 | ```php 157 | // Basic usage 158 | $logs = $audit->getLogsByResource('database/document-1'); 159 | 160 | // With time filtering and pagination 161 | $logs = $audit->getLogsByResource( 162 | resource: 'database/document-1', 163 | after: new \DateTime('-30 days'), 164 | before: new \DateTime('now'), 165 | limit: 100, 166 | offset: 0, 167 | ascending: true // Get oldest logs first 168 | ); 169 | ``` 170 | 171 | **Batch Logging** 172 | 173 | Log multiple events at once for better performance: 174 | 175 | ```php 176 | use Utopia\Database\DateTime; 177 | 178 | $events = [ 179 | [ 180 | 'userId' => 'user-1', 181 | 'event' => 'create', 182 | 'resource' => 'database/document/1', 183 | 'userAgent' => 'Mozilla/5.0...', 184 | 'ip' => '127.0.0.1', 185 | 'location' => 'US', 186 | 'data' => ['key' => 'value'], 187 | 'time' => DateTime::now() 188 | ], 189 | [ 190 | 'userId' => 'user-2', 191 | 'event' => 'update', 192 | 'resource' => 'database/document/2', 193 | 'userAgent' => 'Mozilla/5.0...', 194 | 'ip' => '192.168.1.1', 195 | 'location' => 'UK', 196 | 'data' => ['key' => 'value'], 197 | 'time' => DateTime::now() 198 | ] 199 | ]; 200 | 201 | $documents = $audit->logBatch($events); 202 | ``` 203 | 204 | **Counting Logs** 205 | 206 | All retrieval methods have corresponding count methods with the same filtering capabilities: 207 | 208 | ```php 209 | // Count all logs for a user 210 | $count = $audit->countLogsByUser('userId'); 211 | 212 | // Count logs within a time range 213 | $count = $audit->countLogsByUser( 214 | userId: 'userId', 215 | after: new \DateTime('-7 days'), 216 | before: new \DateTime('now') 217 | ); 218 | 219 | // Count logs by resource 220 | $count = $audit->countLogsByResource('database/document-1'); 221 | 222 | // Count logs by user and events 223 | $count = $audit->countLogsByUserAndEvents( 224 | userId: 'userId', 225 | events: ['create', 'update', 'delete'], 226 | after: new \DateTime('-30 days') 227 | ); 228 | 229 | // Count logs by resource and events 230 | $count = $audit->countLogsByResourceAndEvents( 231 | resource: 'database/document-1', 232 | events: ['update', 'delete'] 233 | ); 234 | ``` 235 | 236 | **Advanced Filtering** 237 | 238 | Get logs by resource and specific events: 239 | 240 | ```php 241 | $logs = $audit->getLogsByResourceAndEvents( 242 | resource: 'database/document-1', 243 | events: ['create', 'update'], 244 | after: new \DateTime('-24 hours'), 245 | limit: 20, 246 | offset: 0, 247 | ascending: false 248 | ); 249 | ``` 250 | 251 | ### Filtering Parameters 252 | 253 | All retrieval methods support the following optional parameters: 254 | 255 | - **after** (`?\DateTime`): Get logs created after this datetime 256 | - **before** (`?\DateTime`): Get logs created before this datetime 257 | - **limit** (`int`, default: 25): Maximum number of logs to return 258 | - **offset** (`int`, default: 0): Number of logs to skip (for pagination) 259 | - **ascending** (`bool`, default: false): Sort order - false for newest first, true for oldest first 260 | 261 | ## Adapters 262 | 263 | Utopia Audit uses an adapter pattern to support different storage backends. Currently available adapters: 264 | 265 | ### Database Adapter (Default) 266 | 267 | The Database adapter uses [utopia-php/database](https://github.com/utopia-php/database) to store audit logs in a database. 268 | 269 | 270 | ### ClickHouse Adapter 271 | 272 | 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. 273 | 274 | **Features:** 275 | - Optimized for analytical queries and aggregations 276 | - Handles billions of log entries efficiently 277 | - Column-oriented storage for fast queries 278 | - Automatic partitioning by month 279 | - Bloom filter indexes for fast lookups 280 | 281 | **Usage:** 282 | 283 | ```php 284 | setup(); // Creates database and table 301 | 302 | // Use as normal 303 | $document = $audit->log( 304 | userId: 'user-123', 305 | event: 'document.create', 306 | resource: 'database/document/1', 307 | userAgent: 'Mozilla/5.0...', 308 | ip: '127.0.0.1', 309 | location: 'US', 310 | data: ['key' => 'value'] 311 | ); 312 | ``` 313 | 314 | **Performance Benefits:** 315 | - Ideal for high-volume logging (millions of events per day) 316 | - Fast aggregation queries (counts, analytics) 317 | - Efficient storage with compression 318 | - Automatic data partitioning and retention policies 319 | 320 | ### Creating Custom Adapters 321 | 322 | To create a custom adapter, extend the `Utopia\Audit\Adapter` abstract class and implement all required methods: 323 | 324 | ```php 325 | db = $db; 27 | } 28 | 29 | /** 30 | * Get adapter name. 31 | */ 32 | public function getName(): string 33 | { 34 | return 'Database'; 35 | } 36 | 37 | /** 38 | * Setup database structure. 39 | * 40 | * @return void 41 | * @throws Exception|\Exception 42 | */ 43 | public function setup(): void 44 | { 45 | if (! $this->db->exists($this->db->getDatabase())) { 46 | throw new Exception('You need to create the database before running Audit setup'); 47 | } 48 | 49 | $attributes = $this->getAttributeDocuments(); 50 | $indexes = $this->getIndexDocuments(); 51 | 52 | try { 53 | $this->db->createCollection( 54 | $this->getCollectionName(), 55 | $attributes, 56 | $indexes 57 | ); 58 | } catch (DuplicateException) { 59 | // Collection already exists 60 | } 61 | } 62 | 63 | /** 64 | * Create an audit log entry. 65 | * 66 | * @param array $log 67 | * @return Log 68 | * @throws AuthorizationException|\Exception 69 | */ 70 | public function create(array $log): Log 71 | { 72 | $log['time'] = $log['time'] ?? DateTime::now(); 73 | $document = $this->db->getAuthorization()->skip(function () use ($log) { 74 | return $this->db->createDocument($this->getCollectionName(), new Document($log)); 75 | }); 76 | 77 | return new Log($document->getArrayCopy()); 78 | } 79 | 80 | /** 81 | * Create multiple audit log entries in batch. 82 | * 83 | * @param array> $logs 84 | * @return array 85 | * @throws AuthorizationException|\Exception 86 | */ 87 | public function createBatch(array $logs): array 88 | { 89 | $created = []; 90 | 91 | $this->db->getAuthorization()->skip(function () use ($logs, &$created) { 92 | foreach ($logs as $log) { 93 | $time = $log['time'] ?? new \DateTime(); 94 | if (is_string($time)) { 95 | $time = new \DateTime($time); 96 | } 97 | assert($time instanceof \DateTime); 98 | $log['time'] = DateTime::format($time); 99 | $created[] = $this->db->createDocument($this->getCollectionName(), new Document($log)); 100 | } 101 | }); 102 | 103 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $created); 104 | } 105 | 106 | /** 107 | * Build time-related query conditions. 108 | * 109 | * @param \DateTime|null $after 110 | * @param \DateTime|null $before 111 | * @return array 112 | */ 113 | private function buildTimeQueries(?\DateTime $after, ?\DateTime $before): array 114 | { 115 | $queries = []; 116 | 117 | $afterStr = $after ? DateTime::format($after) : null; 118 | $beforeStr = $before ? DateTime::format($before) : null; 119 | 120 | if ($afterStr !== null && $beforeStr !== null) { 121 | $queries[] = Query::between('time', $afterStr, $beforeStr); 122 | return $queries; 123 | } 124 | 125 | if ($afterStr !== null) { 126 | $queries[] = Query::greaterThan('time', $afterStr); 127 | } 128 | 129 | if ($beforeStr !== null) { 130 | $queries[] = Query::lessThan('time', $beforeStr); 131 | } 132 | 133 | return $queries; 134 | } 135 | 136 | /** 137 | * Get audit logs by user ID. 138 | * 139 | * @return array 140 | * @throws AuthorizationException|\Exception 141 | */ 142 | public function getByUser( 143 | string $userId, 144 | ?\DateTime $after = null, 145 | ?\DateTime $before = null, 146 | int $limit = 25, 147 | int $offset = 0, 148 | bool $ascending = false, 149 | ): array { 150 | $timeQueries = $this->buildTimeQueries($after, $before); 151 | $documents = $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries, $limit, $offset, $ascending) { 152 | $queries = [ 153 | Query::equal('userId', [$userId]), 154 | ...$timeQueries, 155 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), 156 | Query::limit($limit), 157 | Query::offset($offset), 158 | ]; 159 | 160 | return $this->db->find( 161 | collection: $this->getCollectionName(), 162 | queries: $queries, 163 | ); 164 | }); 165 | 166 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); 167 | } 168 | 169 | /** 170 | * Count audit logs by user ID. 171 | * 172 | * @throws AuthorizationException|\Exception 173 | */ 174 | public function countByUser( 175 | string $userId, 176 | ?\DateTime $after = null, 177 | ?\DateTime $before = null, 178 | ): int { 179 | $timeQueries = $this->buildTimeQueries($after, $before); 180 | return $this->db->getAuthorization()->skip(function () use ($userId, $timeQueries) { 181 | return $this->db->count( 182 | collection: $this->getCollectionName(), 183 | queries: [ 184 | Query::equal('userId', [$userId]), 185 | ...$timeQueries, 186 | ] 187 | ); 188 | }); 189 | } 190 | 191 | /** 192 | * Get logs by resource. 193 | * 194 | * @param string $resource 195 | * @return array 196 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query 197 | */ 198 | public function getByResource( 199 | string $resource, 200 | ?\DateTime $after = null, 201 | ?\DateTime $before = null, 202 | int $limit = 25, 203 | int $offset = 0, 204 | bool $ascending = false, 205 | ): array { 206 | $timeQueries = $this->buildTimeQueries($after, $before); 207 | $documents = $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries, $limit, $offset, $ascending) { 208 | $queries = [ 209 | Query::equal('resource', [$resource]), 210 | ...$timeQueries, 211 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), 212 | Query::limit($limit), 213 | Query::offset($offset), 214 | ]; 215 | 216 | return $this->db->find( 217 | collection: $this->getCollectionName(), 218 | queries: $queries, 219 | ); 220 | }); 221 | 222 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); 223 | } 224 | 225 | /** 226 | * Count logs by resource. 227 | * 228 | * @param string $resource 229 | * @return int 230 | * @throws \Utopia\Database\Exception 231 | */ 232 | public function countByResource( 233 | string $resource, 234 | ?\DateTime $after = null, 235 | ?\DateTime $before = null, 236 | ): int { 237 | $timeQueries = $this->buildTimeQueries($after, $before); 238 | return $this->db->getAuthorization()->skip(function () use ($resource, $timeQueries) { 239 | return $this->db->count( 240 | collection: $this->getCollectionName(), 241 | queries: [ 242 | Query::equal('resource', [$resource]), 243 | ...$timeQueries, 244 | ] 245 | ); 246 | }); 247 | } 248 | 249 | /** 250 | * Get logs by user and events. 251 | * 252 | * @param string $userId 253 | * @param array $events 254 | * @return array 255 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query 256 | */ 257 | public function getByUserAndEvents( 258 | string $userId, 259 | array $events, 260 | ?\DateTime $after = null, 261 | ?\DateTime $before = null, 262 | int $limit = 25, 263 | int $offset = 0, 264 | bool $ascending = false, 265 | ): array { 266 | $timeQueries = $this->buildTimeQueries($after, $before); 267 | $documents = $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries, $limit, $offset, $ascending) { 268 | $queries = [ 269 | Query::equal('userId', [$userId]), 270 | Query::equal('event', $events), 271 | ...$timeQueries, 272 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), 273 | Query::limit($limit), 274 | Query::offset($offset), 275 | ]; 276 | 277 | return $this->db->find( 278 | collection: $this->getCollectionName(), 279 | queries: $queries, 280 | ); 281 | }); 282 | 283 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); 284 | } 285 | 286 | /** 287 | * Count logs by user and events. 288 | * 289 | * @param string $userId 290 | * @param array $events 291 | * @return int 292 | * @throws \Utopia\Database\Exception 293 | */ 294 | public function countByUserAndEvents( 295 | string $userId, 296 | array $events, 297 | ?\DateTime $after = null, 298 | ?\DateTime $before = null, 299 | ): int { 300 | $timeQueries = $this->buildTimeQueries($after, $before); 301 | return $this->db->getAuthorization()->skip(function () use ($userId, $events, $timeQueries) { 302 | return $this->db->count( 303 | collection: $this->getCollectionName(), 304 | queries: [ 305 | Query::equal('userId', [$userId]), 306 | Query::equal('event', $events), 307 | ...$timeQueries, 308 | ] 309 | ); 310 | }); 311 | } 312 | 313 | /** 314 | * Get logs by resource and events. 315 | * 316 | * @param string $resource 317 | * @param array $events 318 | * @return array 319 | * @throws Timeout|\Utopia\Database\Exception|\Utopia\Database\Exception\Query 320 | */ 321 | public function getByResourceAndEvents( 322 | string $resource, 323 | array $events, 324 | ?\DateTime $after = null, 325 | ?\DateTime $before = null, 326 | int $limit = 25, 327 | int $offset = 0, 328 | bool $ascending = false, 329 | ): array { 330 | $timeQueries = $this->buildTimeQueries($after, $before); 331 | $documents = $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries, $limit, $offset, $ascending) { 332 | $queries = [ 333 | Query::equal('resource', [$resource]), 334 | Query::equal('event', $events), 335 | ...$timeQueries, 336 | $ascending ? Query::orderAsc('time') : Query::orderDesc('time'), 337 | Query::limit($limit), 338 | Query::offset($offset), 339 | ]; 340 | 341 | return $this->db->find( 342 | collection: $this->getCollectionName(), 343 | queries: $queries, 344 | ); 345 | }); 346 | 347 | return array_map(fn ($doc) => new Log($doc->getArrayCopy()), $documents); 348 | } 349 | 350 | /** 351 | * Count logs by resource and events. 352 | * 353 | * @param string $resource 354 | * @param array $events 355 | * @return int 356 | * @throws \Utopia\Database\Exception 357 | */ 358 | public function countByResourceAndEvents( 359 | string $resource, 360 | array $events, 361 | ?\DateTime $after = null, 362 | ?\DateTime $before = null, 363 | ): int { 364 | $timeQueries = $this->buildTimeQueries($after, $before); 365 | return $this->db->getAuthorization()->skip(function () use ($resource, $events, $timeQueries) { 366 | return $this->db->count( 367 | collection: $this->getCollectionName(), 368 | queries: [ 369 | Query::equal('resource', [$resource]), 370 | Query::equal('event', $events), 371 | ...$timeQueries, 372 | ] 373 | ); 374 | }); 375 | } 376 | 377 | /** 378 | * Delete logs older than the specified datetime. 379 | * 380 | * @param \DateTime $datetime 381 | /** 382 | * @throws AuthorizationException|\Exception 383 | */ 384 | public function cleanup(\DateTime $datetime): bool 385 | { 386 | $datetimeString = DateTime::format($datetime); 387 | $this->db->getAuthorization()->skip(function () use ($datetimeString) { 388 | do { 389 | $removed = $this->db->deleteDocuments($this->getCollectionName(), [ 390 | Query::lessThan('time', $datetimeString), 391 | ]); 392 | } while ($removed > 0); 393 | }); 394 | 395 | return true; 396 | } 397 | 398 | /** 399 | * Get database-agnostic column definition for a given attribute ID. 400 | * 401 | * For the Database adapter, this method is not actively used since the adapter 402 | * delegates to utopia-php/database's native Document/Collection API which handles 403 | * type mapping internally. However, this implementation is required to satisfy 404 | * the abstract method declaration in the base SQL adapter. 405 | * 406 | * @param string $id Attribute identifier 407 | * @return string Database-agnostic column description 408 | * @throws Exception 409 | */ 410 | protected function getColumnDefinition(string $id): string 411 | { 412 | $attribute = $this->getAttribute($id); 413 | 414 | if (!$attribute) { 415 | throw new Exception("Attribute {$id} not found"); 416 | } 417 | 418 | // For the Database adapter, we use Utopia's VAR_* type constants internally 419 | // This method provides a description for reference purposes 420 | /** @var string $type */ 421 | $type = $attribute['type']; 422 | /** @var int $size */ 423 | $size = $attribute['size'] ?? 0; 424 | 425 | if ($size > 0) { 426 | return "{$id}: {$type}({$size})"; 427 | } 428 | 429 | return "{$id}: {$type}"; 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /tests/Audit/AuditBase.php: -------------------------------------------------------------------------------- 1 | audit. 16 | */ 17 | trait AuditBase 18 | { 19 | protected Audit $audit; 20 | 21 | /** 22 | * Classes using this trait must implement this to initialize the audit instance 23 | * with their specific adapter configuration 24 | */ 25 | abstract protected function initializeAudit(): void; 26 | 27 | /** 28 | * Classes should override if they need custom setup 29 | */ 30 | public function setUp(): void 31 | { 32 | $this->initializeAudit(); 33 | $cleanup = new \DateTime(); 34 | $cleanup = $cleanup->modify('+10 second'); 35 | $this->audit->cleanup(new \DateTime()); 36 | $this->createLogs(); 37 | } 38 | 39 | /** 40 | * Classes should override if they need custom teardown 41 | */ 42 | public function tearDown(): void 43 | { 44 | $cleanup = new \DateTime(); 45 | $cleanup = $cleanup->modify('+10 second'); 46 | $this->audit->cleanup(new \DateTime()); 47 | } 48 | 49 | public function createLogs(): void 50 | { 51 | $userId = 'userId'; 52 | $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'; 53 | $ip = '127.0.0.1'; 54 | $location = 'US'; 55 | $data = ['key1' => 'value1', 'key2' => 'value2']; 56 | 57 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); 58 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); 59 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); 60 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log(null, 'insert', 'user/null', $userAgent, $ip, $location, $data)); 61 | } 62 | 63 | public function testGetLogsByUser(): void 64 | { 65 | $logs = $this->audit->getLogsByUser('userId'); 66 | $this->assertEquals(3, \count($logs)); 67 | 68 | $logsCount = $this->audit->countLogsByUser('userId'); 69 | $this->assertEquals(3, $logsCount); 70 | 71 | $logs1 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); 72 | $this->assertEquals(1, \count($logs1)); 73 | $this->assertEquals($logs1[0]->getId(), $logs[1]->getId()); 74 | 75 | $logs2 = $this->audit->getLogsByUser('userId', limit: 1, offset: 1); 76 | $this->assertEquals(1, \count($logs2)); 77 | $this->assertEquals($logs2[0]->getId(), $logs[1]->getId()); 78 | } 79 | 80 | public function testGetLogsByUserAndEvents(): void 81 | { 82 | $logs1 = $this->audit->getLogsByUserAndEvents('userId', ['update']); 83 | $logs2 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete']); 84 | 85 | $this->assertEquals(2, \count($logs1)); 86 | $this->assertEquals(3, \count($logs2)); 87 | 88 | $logsCount1 = $this->audit->countLogsByUserAndEvents('userId', ['update']); 89 | $logsCount2 = $this->audit->countLogsByUserAndEvents('userId', ['update', 'delete']); 90 | 91 | $this->assertEquals(2, $logsCount1); 92 | $this->assertEquals(3, $logsCount2); 93 | 94 | $logs3 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); 95 | 96 | $this->assertEquals(1, \count($logs3)); 97 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); 98 | 99 | $logs4 = $this->audit->getLogsByUserAndEvents('userId', ['update', 'delete'], limit: 1, offset: 1); 100 | 101 | $this->assertEquals(1, \count($logs4)); 102 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); 103 | } 104 | 105 | public function testGetLogsByResourceAndEvents(): void 106 | { 107 | $logs1 = $this->audit->getLogsByResourceAndEvents('database/document/1', ['update']); 108 | $logs2 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete']); 109 | 110 | $this->assertEquals(1, \count($logs1)); 111 | $this->assertEquals(2, \count($logs2)); 112 | 113 | $logsCount1 = $this->audit->countLogsByResourceAndEvents('database/document/1', ['update']); 114 | $logsCount2 = $this->audit->countLogsByResourceAndEvents('database/document/2', ['update', 'delete']); 115 | 116 | $this->assertEquals(1, $logsCount1); 117 | $this->assertEquals(2, $logsCount2); 118 | 119 | $logs3 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); 120 | 121 | $this->assertEquals(1, \count($logs3)); 122 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); 123 | 124 | $logs4 = $this->audit->getLogsByResourceAndEvents('database/document/2', ['update', 'delete'], limit: 1, offset: 1); 125 | 126 | $this->assertEquals(1, \count($logs4)); 127 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); 128 | } 129 | 130 | public function testGetLogsByResource(): void 131 | { 132 | $logs1 = $this->audit->getLogsByResource('database/document/1'); 133 | $logs2 = $this->audit->getLogsByResource('database/document/2'); 134 | 135 | $this->assertEquals(1, \count($logs1)); 136 | $this->assertEquals(2, \count($logs2)); 137 | 138 | $logsCount1 = $this->audit->countLogsByResource('database/document/1'); 139 | $logsCount2 = $this->audit->countLogsByResource('database/document/2'); 140 | 141 | $this->assertEquals(1, $logsCount1); 142 | $this->assertEquals(2, $logsCount2); 143 | 144 | $logs3 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); 145 | $this->assertEquals(1, \count($logs3)); 146 | $this->assertEquals($logs3[0]->getId(), $logs2[1]->getId()); 147 | 148 | $logs4 = $this->audit->getLogsByResource('database/document/2', limit: 1, offset: 1); 149 | $this->assertEquals(1, \count($logs4)); 150 | $this->assertEquals($logs4[0]->getId(), $logs2[1]->getId()); 151 | 152 | $logs5 = $this->audit->getLogsByResource('user/null'); 153 | $this->assertEquals(1, \count($logs5)); 154 | $this->assertNull($logs5[0]['userId']); 155 | $this->assertEquals('127.0.0.1', $logs5[0]['ip']); 156 | } 157 | 158 | public function testLogByBatch(): void 159 | { 160 | // First cleanup existing logs 161 | $this->audit->cleanup(new \DateTime()); 162 | 163 | $userId = 'batchUserId'; 164 | $userAgent = 'Mozilla/5.0 (Test User Agent)'; 165 | $ip = '192.168.1.1'; 166 | $location = 'UK'; 167 | 168 | // Create timestamps 1 minute apart 169 | $timestamp1 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -120)) ?? ''; 170 | $timestamp2 = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60)) ?? ''; 171 | $timestamp3 = DateTime::formatTz(DateTime::now()) ?? ''; 172 | 173 | $batchEvents = [ 174 | [ 175 | 'userId' => $userId, 176 | 'event' => 'create', 177 | 'resource' => 'database/document/batch1', 178 | 'userAgent' => $userAgent, 179 | 'ip' => $ip, 180 | 'location' => $location, 181 | 'data' => ['key' => 'value1'], 182 | 'time' => $timestamp1 183 | ], 184 | [ 185 | 'userId' => $userId, 186 | 'event' => 'update', 187 | 'resource' => 'database/document/batch2', 188 | 'userAgent' => $userAgent, 189 | 'ip' => $ip, 190 | 'location' => $location, 191 | 'data' => ['key' => 'value2'], 192 | 'time' => $timestamp2 193 | ], 194 | [ 195 | 'userId' => $userId, 196 | 'event' => 'delete', 197 | 'resource' => 'database/document/batch3', 198 | 'userAgent' => $userAgent, 199 | 'ip' => $ip, 200 | 'location' => $location, 201 | 'data' => ['key' => 'value3'], 202 | 'time' => $timestamp3 203 | ], 204 | [ 205 | 'userId' => null, 206 | 'event' => 'insert', 207 | 'resource' => 'user1/null', 208 | 'userAgent' => $userAgent, 209 | 'ip' => $ip, 210 | 'location' => $location, 211 | 'data' => ['key' => 'value4'], 212 | 'time' => $timestamp3 213 | ] 214 | ]; 215 | 216 | // Test batch insertion 217 | $result = $this->audit->logBatch($batchEvents); 218 | $this->assertIsArray($result); 219 | $this->assertEquals(4, count($result)); 220 | 221 | // Verify the number of logs inserted 222 | $logs = $this->audit->getLogsByUser($userId); 223 | $this->assertEquals(3, count($logs)); 224 | 225 | // Verify chronological order (newest first due to orderDesc) 226 | $this->assertEquals('delete', $logs[0]->getAttribute('event')); 227 | $this->assertEquals('update', $logs[1]->getAttribute('event')); 228 | $this->assertEquals('create', $logs[2]->getAttribute('event')); 229 | 230 | // Verify timestamps were preserved 231 | $this->assertEquals($timestamp3, $logs[0]->getAttribute('time')); 232 | $this->assertEquals($timestamp2, $logs[1]->getAttribute('time')); 233 | $this->assertEquals($timestamp1, $logs[2]->getAttribute('time')); 234 | 235 | // Test resource-based retrieval 236 | $resourceLogs = $this->audit->getLogsByResource('database/document/batch2'); 237 | $this->assertEquals(1, count($resourceLogs)); 238 | $this->assertEquals('update', $resourceLogs[0]->getAttribute('event')); 239 | 240 | // Test resource with userId null 241 | $resourceLogs = $this->audit->getLogsByResource('user1/null'); 242 | $this->assertEquals(1, count($resourceLogs)); 243 | foreach ($resourceLogs as $log) { 244 | $this->assertEquals('insert', $log->getAttribute('event')); 245 | $this->assertNull($log['userId']); 246 | } 247 | 248 | // Test event-based retrieval 249 | $eventLogs = $this->audit->getLogsByUserAndEvents($userId, ['create', 'delete']); 250 | $this->assertEquals(2, count($eventLogs)); 251 | } 252 | 253 | public function testGetLogsCustomFilters(): void 254 | { 255 | $threshold = new \DateTime(); 256 | $threshold->modify('-10 seconds'); 257 | $logs = $this->audit->getLogsByUser('userId', after: $threshold); 258 | 259 | $this->assertEquals(3, \count($logs)); 260 | } 261 | 262 | public function testAscendingOrderRetrieval(): void 263 | { 264 | // Test ascending order retrieval 265 | $logsDesc = $this->audit->getLogsByUser('userId', ascending: false); 266 | $logsAsc = $this->audit->getLogsByUser('userId', ascending: true); 267 | 268 | // Both should have same count 269 | $this->assertEquals(\count($logsDesc), \count($logsAsc)); 270 | 271 | // Events should be in opposite order 272 | if (\count($logsDesc) > 1) { 273 | $descEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsDesc); 274 | $ascEvents = array_map(fn ($log) => $log->getAttribute('event'), $logsAsc); 275 | $this->assertEquals($descEvents, array_reverse($ascEvents)); 276 | } 277 | } 278 | 279 | public function testLargeBatchInsert(): void 280 | { 281 | // Create a large batch (50 events) 282 | $batchEvents = []; 283 | $baseTime = DateTime::now(); 284 | for ($i = 0; $i < 50; $i++) { 285 | $batchEvents[] = [ 286 | 'userId' => 'largebatchuser', 287 | 'event' => 'event_' . $i, 288 | 'resource' => 'doc/' . $i, 289 | 'userAgent' => 'Mozilla', 290 | 'ip' => '127.0.0.1', 291 | 'location' => 'US', 292 | 'data' => ['index' => $i], 293 | 'time' => DateTime::formatTz($baseTime) ?? '' 294 | ]; 295 | } 296 | 297 | // Insert batch 298 | $result = $this->audit->logBatch($batchEvents); 299 | $this->assertEquals(50, \count($result)); 300 | 301 | // Verify all were inserted 302 | $count = $this->audit->countLogsByUser('largebatchuser'); 303 | $this->assertEquals(50, $count); 304 | 305 | // Test pagination 306 | $page1 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 0); 307 | $this->assertEquals(10, \count($page1)); 308 | 309 | $page2 = $this->audit->getLogsByUser('largebatchuser', limit: 10, offset: 10); 310 | $this->assertEquals(10, \count($page2)); 311 | } 312 | 313 | public function testTimeRangeFilters(): void 314 | { 315 | // Create logs with different timestamps 316 | $old = DateTime::format(new \DateTime('2024-01-01 10:00:00')); 317 | $recent = DateTime::now(); 318 | 319 | $batchEvents = [ 320 | [ 321 | 'userId' => 'timerangeuser', 322 | 'event' => 'old_event', 323 | 'resource' => 'doc/1', 324 | 'userAgent' => 'Mozilla', 325 | 'ip' => '127.0.0.1', 326 | 'location' => 'US', 327 | 'data' => [], 328 | 'time' => $old 329 | ], 330 | [ 331 | 'userId' => 'timerangeuser', 332 | 'event' => 'recent_event', 333 | 'resource' => 'doc/2', 334 | 'userAgent' => 'Mozilla', 335 | 'ip' => '127.0.0.1', 336 | 'location' => 'US', 337 | 'data' => [], 338 | 'time' => $recent 339 | ] 340 | ]; 341 | 342 | $this->audit->logBatch($batchEvents); 343 | 344 | // Test getting all logs 345 | $all = $this->audit->getLogsByUser('timerangeuser'); 346 | $this->assertGreaterThanOrEqual(2, \count($all)); 347 | 348 | // Test with before filter - should get both since they're both in the past relative to future 349 | $beforeFuture = new \DateTime('2099-12-31 23:59:59'); 350 | $beforeLogs = $this->audit->getLogsByUser('timerangeuser', before: $beforeFuture); 351 | $this->assertGreaterThanOrEqual(2, \count($beforeLogs)); 352 | } 353 | 354 | public function testCleanup(): void 355 | { 356 | $status = $this->audit->cleanup(new \DateTime()); 357 | $this->assertEquals($status, true); 358 | 359 | // Check that all logs have been deleted 360 | $logs = $this->audit->getLogsByUser('userId'); 361 | $this->assertEquals(0, \count($logs)); 362 | 363 | // Add three sample logs 364 | $userId = 'userId'; 365 | $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'; 366 | $ip = '127.0.0.1'; 367 | $location = 'US'; 368 | $data = ['key1' => 'value1', 'key2' => 'value2']; 369 | 370 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/1', $userAgent, $ip, $location, $data)); 371 | sleep(5); 372 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'update', 'database/document/2', $userAgent, $ip, $location, $data)); 373 | sleep(5); 374 | $this->assertInstanceOf('Utopia\\Audit\\Log', $this->audit->log($userId, 'delete', 'database/document/2', $userAgent, $ip, $location, $data)); 375 | sleep(5); 376 | 377 | // DELETE logs older than 11 seconds and check that status is true 378 | $datetime = new \DateTime(); 379 | $datetime->modify('-11 seconds'); 380 | $status = $this->audit->cleanup($datetime); 381 | $this->assertEquals($status, true); 382 | 383 | // Check if 1 log has been deleted 384 | $logs = $this->audit->getLogsByUser('userId'); 385 | $this->assertEquals(2, \count($logs)); 386 | } 387 | 388 | /** 389 | * Test all additional retrieval parameters: limit, offset, ascending, after, before 390 | */ 391 | public function testRetrievalParameters(): void 392 | { 393 | // Setup: Create logs with specific timestamps for testing 394 | $this->audit->cleanup(new \DateTime()); 395 | 396 | $userId = 'paramtestuser'; 397 | $userAgent = 'Mozilla/5.0'; 398 | $ip = '192.168.1.1'; 399 | $location = 'US'; 400 | 401 | // Create 5 logs with different timestamps 402 | $baseTime = new \DateTime('2024-06-15 12:00:00'); 403 | $batchEvents = []; 404 | for ($i = 0; $i < 5; $i++) { 405 | $offset = $i * 60; 406 | $logTime = new \DateTime('2024-06-15 12:00:00'); 407 | $logTime->modify("+{$offset} seconds"); 408 | $timestamp = DateTime::format($logTime); 409 | $batchEvents[] = [ 410 | 'userId' => $userId, 411 | 'event' => 'event_' . $i, 412 | 'resource' => 'doc/' . $i, 413 | 'userAgent' => $userAgent, 414 | 'ip' => $ip, 415 | 'location' => $location, 416 | 'data' => ['sequence' => $i], 417 | 'time' => $timestamp 418 | ]; 419 | } 420 | 421 | $this->audit->logBatch($batchEvents); 422 | 423 | // Test 1: limit parameter 424 | $logsLimit2 = $this->audit->getLogsByUser($userId, limit: 2); 425 | $this->assertEquals(2, \count($logsLimit2)); 426 | 427 | $logsLimit3 = $this->audit->getLogsByUser($userId, limit: 3); 428 | $this->assertEquals(3, \count($logsLimit3)); 429 | 430 | // Test 2: offset parameter 431 | $logsOffset0 = $this->audit->getLogsByUser($userId, limit: 10, offset: 0); 432 | $logsOffset2 = $this->audit->getLogsByUser($userId, limit: 10, offset: 2); 433 | $logsOffset4 = $this->audit->getLogsByUser($userId, limit: 10, offset: 4); 434 | 435 | $this->assertEquals(5, \count($logsOffset0)); 436 | $this->assertEquals(3, \count($logsOffset2)); 437 | $this->assertEquals(1, \count($logsOffset4)); 438 | 439 | // Verify offset returns different logs 440 | $this->assertNotEquals($logsOffset0[0]->getId(), $logsOffset2[0]->getId()); 441 | $this->assertNotEquals($logsOffset2[0]->getId(), $logsOffset4[0]->getId()); 442 | 443 | // Test 3: ascending parameter 444 | $logsDesc = $this->audit->getLogsByUser($userId, ascending: false); 445 | $logsAsc = $this->audit->getLogsByUser($userId, ascending: true); 446 | 447 | $this->assertEquals(5, \count($logsDesc)); 448 | $this->assertEquals(5, \count($logsAsc)); 449 | 450 | // Verify order is reversed 451 | if (\count($logsDesc) === \count($logsAsc)) { 452 | for ($i = 0; $i < \count($logsDesc); $i++) { 453 | $this->assertEquals( 454 | $logsDesc[$i]->getId(), 455 | $logsAsc[\count($logsAsc) - 1 - $i]->getId() 456 | ); 457 | } 458 | } 459 | 460 | // Test 4: after parameter (logs after a certain timestamp) 461 | $afterTimeObj = new \DateTime('2024-06-15 12:03:00'); // After 3rd log 462 | $logsAfter = $this->audit->getLogsByUser($userId, after: $afterTimeObj); 463 | // Should get logs at positions 3 and 4 (2 logs) 464 | $this->assertGreaterThanOrEqual(1, \count($logsAfter)); 465 | 466 | // Test 5: before parameter (logs before a certain timestamp) 467 | $beforeTimeObj = new \DateTime('2024-06-15 12:02:00'); // Before 3rd log 468 | $logsBefore = $this->audit->getLogsByUser($userId, before: $beforeTimeObj); 469 | // Should get logs at positions 0, 1, 2 (3 logs) 470 | $this->assertGreaterThanOrEqual(1, \count($logsBefore)); 471 | 472 | // Test 6: Combination of limit + offset 473 | $logsPage1 = $this->audit->getLogsByUser($userId, limit: 2, offset: 0); 474 | $logsPage2 = $this->audit->getLogsByUser($userId, limit: 2, offset: 2); 475 | $logsPage3 = $this->audit->getLogsByUser($userId, limit: 2, offset: 4); 476 | 477 | $this->assertEquals(2, \count($logsPage1)); 478 | $this->assertEquals(2, \count($logsPage2)); 479 | $this->assertEquals(1, \count($logsPage3)); 480 | 481 | // Verify pages don't overlap 482 | $this->assertNotEquals($logsPage1[0]->getId(), $logsPage2[0]->getId()); 483 | $this->assertNotEquals($logsPage2[0]->getId(), $logsPage3[0]->getId()); 484 | 485 | // Test 7: Combination of ascending + limit 486 | $ascLimit2 = $this->audit->getLogsByUser($userId, limit: 2, ascending: true); 487 | $this->assertEquals(2, \count($ascLimit2)); 488 | // First log should be oldest in ascending order 489 | $this->assertEquals('event_0', $ascLimit2[0]->getAttribute('event')); 490 | 491 | // Test 8: Combination of after + before (time range) 492 | $afterTimeObj2 = new \DateTime('2024-06-15 12:01:00'); // After 1st log 493 | $beforeTimeObj2 = new \DateTime('2024-06-15 12:04:00'); // Before 4th log 494 | $logsRange = $this->audit->getLogsByUser($userId, after: $afterTimeObj2, before: $beforeTimeObj2); 495 | $this->assertGreaterThanOrEqual(1, \count($logsRange)); 496 | 497 | // Test 9: Test with getLogsByResource using parameters 498 | $logsRes = $this->audit->getLogsByResource('doc/0', limit: 1, offset: 0); 499 | $this->assertEquals(1, \count($logsRes)); 500 | 501 | // Test 10: Test with getLogsByUserAndEvents using parameters 502 | $logsEvt = $this->audit->getLogsByUserAndEvents( 503 | $userId, 504 | ['event_1', 'event_2'], 505 | limit: 1, 506 | offset: 0, 507 | ascending: false 508 | ); 509 | $this->assertGreaterThanOrEqual(0, \count($logsEvt)); 510 | 511 | // Test 11: Test count methods with after/before filters 512 | $countAll = $this->audit->countLogsByUser($userId); 513 | $this->assertEquals(5, $countAll); 514 | 515 | $countAfter = $this->audit->countLogsByUser($userId, after: $afterTimeObj); 516 | $this->assertGreaterThanOrEqual(0, $countAfter); 517 | 518 | $countBefore = $this->audit->countLogsByUser($userId, before: $beforeTimeObj); 519 | $this->assertGreaterThanOrEqual(0, $countBefore); 520 | 521 | // Test 12: Test countLogsByResource with filters 522 | $countResAll = $this->audit->countLogsByResource('doc/0'); 523 | $this->assertEquals(1, $countResAll); 524 | 525 | $countResAfter = $this->audit->countLogsByResource('doc/0', after: $afterTimeObj); 526 | $this->assertGreaterThanOrEqual(0, $countResAfter); 527 | 528 | // Test 13: Test countLogsByUserAndEvents with filters 529 | $countEvtAll = $this->audit->countLogsByUserAndEvents($userId, ['event_1', 'event_2']); 530 | $this->assertGreaterThanOrEqual(0, $countEvtAll); 531 | 532 | $countEvtAfter = $this->audit->countLogsByUserAndEvents( 533 | $userId, 534 | ['event_1', 'event_2'], 535 | after: $afterTimeObj 536 | ); 537 | $this->assertGreaterThanOrEqual(0, $countEvtAfter); 538 | 539 | // Test 14: Test countLogsByResourceAndEvents with filters 540 | $countResEvtAll = $this->audit->countLogsByResourceAndEvents('doc/0', ['event_0']); 541 | $this->assertEquals(1, $countResEvtAll); 542 | 543 | $countResEvtAfter = $this->audit->countLogsByResourceAndEvents( 544 | 'doc/0', 545 | ['event_0'], 546 | after: $afterTimeObj 547 | ); 548 | $this->assertGreaterThanOrEqual(0, $countResEvtAfter); 549 | 550 | // Test 15: Test getLogsByResourceAndEvents with all parameters 551 | $logsResEvt = $this->audit->getLogsByResourceAndEvents( 552 | 'doc/1', 553 | ['event_1'], 554 | limit: 1, 555 | offset: 0, 556 | ascending: true 557 | ); 558 | $this->assertGreaterThanOrEqual(0, \count($logsResEvt)); 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /src/Audit/Adapter/ClickHouse.php: -------------------------------------------------------------------------------- 1 | validateHost($host); 63 | $this->validatePort($port); 64 | 65 | $this->host = $host; 66 | $this->port = $port; 67 | $this->username = $username; 68 | $this->password = $password; 69 | $this->secure = $secure; 70 | 71 | // Initialize the HTTP client for connection reuse 72 | $this->client = new Client(); 73 | $this->client->addHeader('X-ClickHouse-User', $this->username); 74 | $this->client->addHeader('X-ClickHouse-Key', $this->password); 75 | $this->client->setTimeout(30); 76 | } 77 | 78 | /** 79 | * Get adapter name. 80 | */ 81 | public function getName(): string 82 | { 83 | return 'ClickHouse'; 84 | } 85 | 86 | /** 87 | * Validate host parameter. 88 | * 89 | * @param string $host 90 | * @throws Exception 91 | */ 92 | private function validateHost(string $host): void 93 | { 94 | $validator = new Hostname(); 95 | if (!$validator->isValid($host)) { 96 | throw new Exception('ClickHouse host is not a valid hostname or IP address'); 97 | } 98 | } 99 | 100 | /** 101 | * Validate port parameter. 102 | * 103 | * @param int $port 104 | * @throws Exception 105 | */ 106 | private function validatePort(int $port): void 107 | { 108 | if ($port < 1 || $port > 65535) { 109 | throw new Exception('ClickHouse port must be between 1 and 65535'); 110 | } 111 | } 112 | 113 | /** 114 | * Validate identifier (database, table, namespace). 115 | * ClickHouse identifiers follow SQL standard rules. 116 | * 117 | * @param string $identifier 118 | * @param string $type Name of the identifier type for error messages 119 | * @throws Exception 120 | */ 121 | private function validateIdentifier(string $identifier, string $type = 'Identifier'): void 122 | { 123 | if (empty($identifier)) { 124 | throw new Exception("{$type} cannot be empty"); 125 | } 126 | 127 | if (strlen($identifier) > 255) { 128 | throw new Exception("{$type} cannot exceed 255 characters"); 129 | } 130 | 131 | // ClickHouse identifiers: alphanumeric, underscores, cannot start with number 132 | if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $identifier)) { 133 | throw new Exception("{$type} must start with a letter or underscore and contain only alphanumeric characters and underscores"); 134 | } 135 | 136 | // Check against SQL keywords (common ones) 137 | $keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'DATABASE']; 138 | if (in_array(strtoupper($identifier), $keywords, true)) { 139 | throw new Exception("{$type} cannot be a reserved SQL keyword"); 140 | } 141 | } 142 | 143 | /** 144 | * Escape an identifier (database name, table name, column name) for safe use in SQL. 145 | * Uses backticks as per SQL standard for identifier quoting. 146 | * 147 | * @param string $identifier 148 | * @return string 149 | */ 150 | private function escapeIdentifier(string $identifier): string 151 | { 152 | // Backtick escaping: replace any backticks in the identifier with double backticks 153 | return '`' . str_replace('`', '``', $identifier) . '`'; 154 | } 155 | /** 156 | * Set the namespace for multi-project support. 157 | * Namespace is used as a prefix for table names. 158 | * 159 | * @param string $namespace 160 | * @return self 161 | * @throws Exception 162 | */ 163 | public function setNamespace(string $namespace): self 164 | { 165 | if (!empty($namespace)) { 166 | $this->validateIdentifier($namespace, 'Namespace'); 167 | } 168 | $this->namespace = $namespace; 169 | return $this; 170 | } 171 | 172 | /** 173 | * Set the database name for subsequent operations. 174 | * 175 | * @param string $database 176 | * @return self 177 | * @throws Exception 178 | */ 179 | public function setDatabase(string $database): self 180 | { 181 | $this->validateIdentifier($database, 'Database'); 182 | $this->database = $database; 183 | return $this; 184 | } 185 | 186 | /** 187 | * Enable or disable HTTPS for ClickHouse HTTP interface. 188 | */ 189 | public function setSecure(bool $secure): self 190 | { 191 | $this->secure = $secure; 192 | return $this; 193 | } 194 | 195 | /** 196 | * Get the namespace. 197 | * 198 | * @return string 199 | */ 200 | public function getNamespace(): string 201 | { 202 | return $this->namespace; 203 | } 204 | 205 | /** 206 | * Set the tenant ID for multi-tenant support. 207 | * Tenant is used to isolate audit logs by tenant. 208 | * 209 | * @param int|null $tenant 210 | * @return self 211 | */ 212 | public function setTenant(?int $tenant): self 213 | { 214 | $this->tenant = $tenant; 215 | return $this; 216 | } 217 | 218 | /** 219 | * Get the tenant ID. 220 | * 221 | * @return int|null 222 | */ 223 | public function getTenant(): ?int 224 | { 225 | return $this->tenant; 226 | } 227 | 228 | /** 229 | * Set whether tables are shared across tenants. 230 | * When enabled, a tenant column is added to the table for data isolation. 231 | * 232 | * @param bool $sharedTables 233 | * @return self 234 | */ 235 | public function setSharedTables(bool $sharedTables): self 236 | { 237 | $this->sharedTables = $sharedTables; 238 | return $this; 239 | } 240 | 241 | /** 242 | * Get whether tables are shared across tenants. 243 | * 244 | * @return bool 245 | */ 246 | public function isSharedTables(): bool 247 | { 248 | return $this->sharedTables; 249 | } 250 | 251 | /** 252 | * Get the table name with namespace prefix. 253 | * Namespace is used to isolate tables for different projects/applications. 254 | * 255 | * @return string 256 | */ 257 | private function getTableName(): string 258 | { 259 | $tableName = $this->table; 260 | 261 | if (!empty($this->namespace)) { 262 | $tableName = $this->namespace . '_' . $tableName; 263 | } 264 | 265 | return $tableName; 266 | } 267 | 268 | /** 269 | * Execute a ClickHouse query via HTTP interface using Fetch Client. 270 | * 271 | * Uses ClickHouse query parameters (sent as POST multipart form data) to prevent SQL injection. 272 | * This is ClickHouse's native parameter mechanism - parameters are safely 273 | * transmitted separately from the query structure. 274 | * 275 | * Parameters are referenced in the SQL using the syntax: {paramName:Type}. 276 | * For example: SELECT * WHERE id = {id:String} 277 | * 278 | * ClickHouse handles all parameter escaping and type conversion internally, 279 | * making this approach fully injection-safe without needing manual escaping. 280 | * 281 | * Using POST body avoids URL length limits for batch operations with many parameters. 282 | * Equivalent to: curl -X POST -F 'query=...' -F 'param_key=value' http://host/ 283 | * 284 | * @param array $params Key-value pairs for query parameters 285 | * @throws Exception 286 | */ 287 | private function query(string $sql, array $params = []): string 288 | { 289 | $scheme = $this->secure ? 'https' : 'http'; 290 | $url = "{$scheme}://{$this->host}:{$this->port}/"; 291 | 292 | // Update the database header for each query (in case setDatabase was called) 293 | $this->client->addHeader('X-ClickHouse-Database', $this->database); 294 | 295 | // Build multipart form data body with query and parameters 296 | // The Fetch client will automatically encode arrays as multipart/form-data 297 | $body = ['query' => $sql]; 298 | foreach ($params as $key => $value) { 299 | $body['param_' . $key] = $this->formatParamValue($value); 300 | } 301 | 302 | try { 303 | $response = $this->client->fetch( 304 | url: $url, 305 | method: Client::METHOD_POST, 306 | body: $body 307 | ); 308 | if ($response->getStatusCode() !== 200) { 309 | $bodyStr = $response->getBody(); 310 | $bodyStr = is_string($bodyStr) ? $bodyStr : ''; 311 | throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$bodyStr}"); 312 | } 313 | 314 | $body = $response->getBody(); 315 | return is_string($body) ? $body : ''; 316 | } catch (Exception $e) { 317 | // Preserve the original exception context for better debugging 318 | // Re-throw with additional context while maintaining the original exception chain 319 | throw new Exception( 320 | "ClickHouse query execution failed: {$e->getMessage()}", 321 | 0, 322 | $e 323 | ); 324 | } 325 | } 326 | 327 | /** 328 | * Format a parameter value for safe transmission to ClickHouse. 329 | * 330 | * Converts PHP values to their string representation without SQL quoting. 331 | * ClickHouse's query parameter mechanism handles type conversion and escaping. 332 | * 333 | * @param mixed $value 334 | * @return string 335 | */ 336 | private function formatParamValue(mixed $value): string 337 | { 338 | if (is_int($value) || is_float($value)) { 339 | return (string) $value; 340 | } 341 | 342 | if ($value === null) { 343 | return ''; 344 | } 345 | 346 | if (is_bool($value)) { 347 | return $value ? '1' : '0'; 348 | } 349 | 350 | if (is_array($value)) { 351 | $encoded = json_encode($value); 352 | return is_string($encoded) ? $encoded : ''; 353 | } 354 | 355 | if (is_string($value)) { 356 | return $value; 357 | } 358 | 359 | // For objects or other types, attempt to convert to string 360 | if (is_object($value) && method_exists($value, '__toString')) { 361 | return (string) $value; 362 | } 363 | 364 | return ''; 365 | } 366 | 367 | /** 368 | * Setup ClickHouse table structure. 369 | * 370 | * Creates the database and table if they don't exist. 371 | * Uses schema definitions from the base SQL adapter. 372 | * 373 | * @throws Exception 374 | */ 375 | public function setup(): void 376 | { 377 | // Create database if not exists 378 | $escapedDatabase = $this->escapeIdentifier($this->database); 379 | $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"; 380 | $this->query($createDbSql); 381 | 382 | // Build column definitions from base adapter schema 383 | // Override time column to be NOT NULL since it's used in partition key 384 | $columns = [ 385 | 'id String', 386 | ]; 387 | 388 | foreach ($this->getAttributes() as $attribute) { 389 | /** @var string $id */ 390 | $id = $attribute['$id']; 391 | 392 | // Special handling for time column - must be NOT NULL for partition key 393 | if ($id === 'time') { 394 | $columns[] = 'time DateTime64(3)'; 395 | } else { 396 | $columns[] = $this->getColumnDefinition($id); 397 | } 398 | } 399 | 400 | // Add tenant column only if tables are shared across tenants 401 | if ($this->sharedTables) { 402 | $columns[] = 'tenant Nullable(UInt64)'; // Supports 11-digit MySQL auto-increment IDs 403 | } 404 | 405 | // Build indexes from base adapter schema 406 | $indexes = []; 407 | foreach ($this->getIndexes() as $index) { 408 | /** @var string $indexName */ 409 | $indexName = $index['$id']; 410 | /** @var array $attributes */ 411 | $attributes = $index['attributes']; 412 | $attributeList = implode(', ', $attributes); 413 | $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; 414 | } 415 | 416 | $tableName = $this->getTableName(); 417 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 418 | 419 | // Create table with MergeTree engine for optimal performance 420 | $createTableSql = " 421 | CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( 422 | " . implode(",\n ", $columns) . ", 423 | " . implode(",\n ", $indexes) . " 424 | ) 425 | ENGINE = MergeTree() 426 | ORDER BY (time, id) 427 | PARTITION BY toYYYYMM(time) 428 | SETTINGS index_granularity = 8192 429 | "; 430 | 431 | $this->query($createTableSql); 432 | } 433 | 434 | /** 435 | * Create an audit log entry. 436 | * 437 | * @throws Exception 438 | */ 439 | public function create(array $log): Log 440 | { 441 | $id = uniqid('', true); 442 | $time = (new \DateTime())->format('Y-m-d H:i:s.v'); 443 | 444 | $tableName = $this->getTableName(); 445 | 446 | // Build column list and values based on sharedTables setting 447 | $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; 448 | $placeholders = ['{id:String}', '{userId:Nullable(String)}', '{event:String}', '{resource:String}', '{userAgent:String}', '{ip:String}', '{location:Nullable(String)}', '{time:String}', '{data:String}']; 449 | 450 | $params = [ 451 | 'id' => $id, 452 | 'userId' => $log['userId'] ?? null, 453 | 'event' => $log['event'], 454 | 'resource' => $log['resource'], 455 | 'userAgent' => $log['userAgent'], 456 | 'ip' => $log['ip'], 457 | 'location' => $log['location'] ?? null, 458 | 'time' => $time, 459 | 'data' => json_encode($log['data'] ?? []), 460 | ]; 461 | 462 | if ($this->sharedTables) { 463 | $columns[] = 'tenant'; 464 | $placeholders[] = '{tenant:Nullable(UInt64)}'; 465 | $params['tenant'] = $this->tenant; 466 | } 467 | 468 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 469 | $insertSql = " 470 | INSERT INTO {$escapedDatabaseAndTable} 471 | (" . implode(', ', $columns) . ") 472 | VALUES ( 473 | " . implode(", ", $placeholders) . " 474 | ) 475 | "; 476 | 477 | $this->query($insertSql, $params); 478 | 479 | $result = [ 480 | '$id' => $id, 481 | 'userId' => $log['userId'] ?? null, 482 | 'event' => $log['event'], 483 | 'resource' => $log['resource'], 484 | 'userAgent' => $log['userAgent'], 485 | 'ip' => $log['ip'], 486 | 'location' => $log['location'] ?? null, 487 | 'time' => $time, 488 | 'data' => $log['data'] ?? [], 489 | ]; 490 | 491 | if ($this->sharedTables) { 492 | $result['tenant'] = $this->tenant; 493 | } 494 | 495 | return new Log($result); 496 | } 497 | 498 | /** 499 | * Create multiple audit log entries in batch. 500 | * 501 | * @throws Exception 502 | */ 503 | public function createBatch(array $logs): array 504 | { 505 | if (empty($logs)) { 506 | return []; 507 | } 508 | 509 | $tableName = $this->getTableName(); 510 | $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 511 | 512 | // Build column list based on sharedTables setting 513 | $columns = ['id', 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'time', 'data']; 514 | if ($this->sharedTables) { 515 | $columns[] = 'tenant'; 516 | } 517 | 518 | $ids = []; 519 | $paramCounter = 0; 520 | $params = []; 521 | $valueClauses = []; 522 | 523 | foreach ($logs as $log) { 524 | $id = uniqid('', true); 525 | $ids[] = $id; 526 | 527 | // Create parameter placeholders for this row 528 | $paramKeys = []; 529 | $paramKeys[] = 'id_' . $paramCounter; 530 | $paramKeys[] = 'userId_' . $paramCounter; 531 | $paramKeys[] = 'event_' . $paramCounter; 532 | $paramKeys[] = 'resource_' . $paramCounter; 533 | $paramKeys[] = 'userAgent_' . $paramCounter; 534 | $paramKeys[] = 'ip_' . $paramCounter; 535 | $paramKeys[] = 'location_' . $paramCounter; 536 | $paramKeys[] = 'time_' . $paramCounter; 537 | $paramKeys[] = 'data_' . $paramCounter; 538 | 539 | // Set parameter values 540 | $params[$paramKeys[0]] = $id; 541 | $params[$paramKeys[1]] = $log['userId'] ?? null; 542 | $params[$paramKeys[2]] = $log['event']; 543 | $params[$paramKeys[3]] = $log['resource']; 544 | $params[$paramKeys[4]] = $log['userAgent']; 545 | $params[$paramKeys[5]] = $log['ip']; 546 | $params[$paramKeys[6]] = $log['location'] ?? null; 547 | 548 | $time = $log['time'] ?? new \DateTime(); 549 | if (is_string($time)) { 550 | $time = new \DateTime($time); 551 | } 552 | $params[$paramKeys[7]] = $time->format('Y-m-d H:i:s.v'); 553 | $params[$paramKeys[8]] = json_encode($log['data'] ?? []); 554 | 555 | if ($this->sharedTables) { 556 | $paramKeys[] = 'tenant_' . $paramCounter; 557 | $params[$paramKeys[9]] = $this->tenant; 558 | } 559 | 560 | // Build placeholder string for this row 561 | $placeholders = []; 562 | for ($i = 0; $i < count($paramKeys); $i++) { 563 | if ($i === 1 || $i === 6) { // userId and location are nullable 564 | $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(String)}'; 565 | } elseif ($this->sharedTables && $i === 9) { // tenant is nullable UInt64 566 | $placeholders[] = '{' . $paramKeys[$i] . ':Nullable(UInt64)}'; 567 | } else { 568 | $placeholders[] = '{' . $paramKeys[$i] . ':String}'; 569 | } 570 | } 571 | 572 | $valueClauses[] = '(' . implode(', ', $placeholders) . ')'; 573 | $paramCounter++; 574 | } 575 | 576 | $insertSql = " 577 | INSERT INTO {$escapedDatabaseAndTable} 578 | (" . implode(', ', $columns) . ") 579 | VALUES " . implode(', ', $valueClauses); 580 | 581 | $this->query($insertSql, $params); 582 | 583 | // Return documents using the same IDs that were inserted 584 | $documents = []; 585 | foreach ($logs as $index => $log) { 586 | $result = [ 587 | '$id' => $ids[$index], 588 | 'userId' => $log['userId'] ?? null, 589 | 'event' => $log['event'], 590 | 'resource' => $log['resource'], 591 | 'userAgent' => $log['userAgent'], 592 | 'ip' => $log['ip'], 593 | 'location' => $log['location'] ?? null, 594 | 'time' => $log['time'], 595 | 'data' => $log['data'] ?? [], 596 | ]; 597 | 598 | if ($this->sharedTables) { 599 | $result['tenant'] = $this->tenant; 600 | } 601 | 602 | $documents[] = new Log($result); 603 | } 604 | 605 | return $documents; 606 | } 607 | 608 | /** 609 | * Parse ClickHouse query result into Log objects. 610 | * 611 | * @return array 612 | */ 613 | private function parseResults(string $result): array 614 | { 615 | if (empty(trim($result))) { 616 | return []; 617 | } 618 | 619 | $lines = explode("\n", trim($result)); 620 | $documents = []; 621 | 622 | foreach ($lines as $line) { 623 | if (empty(trim($line))) { 624 | continue; 625 | } 626 | 627 | $columns = explode("\t", $line); 628 | // Expect 9 columns without sharedTables, 10 with sharedTables 629 | $expectedColumns = $this->sharedTables ? 10 : 9; 630 | if (count($columns) < $expectedColumns) { 631 | continue; 632 | } 633 | 634 | $data = json_decode($columns[8], true) ?? []; 635 | 636 | // Convert ClickHouse timestamp format back to ISO 8601 637 | // ClickHouse: 2025-12-07 23:33:54.493 638 | // ISO 8601: 2025-12-07T23:33:54.493+00:00 639 | $time = $columns[7]; 640 | if (strpos($time, 'T') === false) { 641 | $time = str_replace(' ', 'T', $time) . '+00:00'; 642 | } 643 | 644 | // Helper function to parse nullable string fields 645 | // ClickHouse TabSeparated format uses \N for NULL, but empty strings are also treated as null for nullable fields 646 | $parseNullableString = static function ($value): ?string { 647 | if ($value === '\\N' || $value === '') { 648 | return null; 649 | } 650 | return $value; 651 | }; 652 | 653 | $document = [ 654 | '$id' => $columns[0], 655 | 'userId' => $parseNullableString($columns[1]), 656 | 'event' => $columns[2], 657 | 'resource' => $columns[3], 658 | 'userAgent' => $columns[4], 659 | 'ip' => $columns[5], 660 | 'location' => $parseNullableString($columns[6]), 661 | 'time' => $time, 662 | 'data' => $data, 663 | ]; 664 | 665 | // Add tenant only if sharedTables is enabled 666 | if ($this->sharedTables && isset($columns[9])) { 667 | $document['tenant'] = $columns[9] === '\\N' || $columns[9] === '' ? null : (int) $columns[9]; 668 | } 669 | 670 | $documents[] = new Log($document); 671 | } 672 | 673 | return $documents; 674 | } 675 | 676 | /** 677 | * Get the SELECT column list for queries. 678 | * Returns 9 columns if not using shared tables, 10 if using shared tables. 679 | * 680 | * @return string 681 | */ 682 | private function getSelectColumns(): string 683 | { 684 | if ($this->sharedTables) { 685 | return 'id, userId, event, resource, userAgent, ip, location, time, data, tenant'; 686 | } 687 | return 'id, userId, event, resource, userAgent, ip, location, time, data'; 688 | } 689 | 690 | /** 691 | * Build tenant filter clause based on current tenant context. 692 | * 693 | * @return string 694 | */ 695 | private function getTenantFilter(): string 696 | { 697 | if (!$this->sharedTables || $this->tenant === null) { 698 | return ''; 699 | } 700 | 701 | return " AND tenant = {$this->tenant}"; 702 | } 703 | 704 | /** 705 | * Build time WHERE clause and parameters with safe parameter placeholders. 706 | * 707 | * @param \DateTime|null $after 708 | * @param \DateTime|null $before 709 | * @return array{clause: string, params: array} 710 | */ 711 | private function buildTimeClause(?\DateTime $after, ?\DateTime $before): array 712 | { 713 | $params = []; 714 | $conditions = []; 715 | 716 | $afterStr = null; 717 | $beforeStr = null; 718 | 719 | if ($after !== null) { 720 | /** @var \DateTime $after */ 721 | $afterStr = \Utopia\Database\DateTime::format($after); 722 | } 723 | 724 | if ($before !== null) { 725 | /** @var \DateTime $before */ 726 | $beforeStr = \Utopia\Database\DateTime::format($before); 727 | } 728 | 729 | if ($afterStr !== null && $beforeStr !== null) { 730 | $conditions[] = 'time BETWEEN {after:String} AND {before:String}'; 731 | $params['after'] = $afterStr; 732 | $params['before'] = $beforeStr; 733 | 734 | return ['clause' => ' AND ' . $conditions[0], 'params' => $params]; 735 | } 736 | 737 | if ($afterStr !== null) { 738 | $conditions[] = 'time > {after:String}'; 739 | $params['after'] = $afterStr; 740 | } 741 | 742 | if ($beforeStr !== null) { 743 | $conditions[] = 'time < {before:String}'; 744 | $params['before'] = $beforeStr; 745 | } 746 | 747 | if ($conditions === []) { 748 | return ['clause' => '', 'params' => []]; 749 | } 750 | 751 | return [ 752 | 'clause' => ' AND ' . implode(' AND ', $conditions), 753 | 'params' => $params, 754 | ]; 755 | } 756 | 757 | /** 758 | * Build a formatted SQL IN list from an array of events. 759 | * Events are parameterized for safe SQL inclusion. 760 | * 761 | * @param array $events 762 | * @param int $paramOffset Base parameter number for creating unique param names 763 | * @return array{clause: string, params: array} 764 | */ 765 | private function buildEventsList(array $events, int $paramOffset = 0): array 766 | { 767 | $placeholders = []; 768 | $params = []; 769 | 770 | foreach ($events as $index => $event) { 771 | /** @var int $paramNumber */ 772 | $paramNumber = $paramOffset + (int) $index; 773 | $paramName = 'event_' . (string) $paramNumber; 774 | $placeholders[] = '{' . $paramName . ':String}'; 775 | $params[$paramName] = $event; 776 | } 777 | 778 | $clause = implode(', ', $placeholders); 779 | return ['clause' => $clause, 'params' => $params]; 780 | } 781 | 782 | /** 783 | * Get ClickHouse-specific SQL column definition for a given attribute ID. 784 | * 785 | * @param string $id Attribute identifier 786 | * @return string ClickHouse column definition with appropriate types and nullability 787 | * @throws Exception 788 | */ 789 | protected function getColumnDefinition(string $id): string 790 | { 791 | $attribute = $this->getAttribute($id); 792 | 793 | if (!$attribute) { 794 | throw new Exception("Attribute {$id} not found"); 795 | } 796 | 797 | // ClickHouse-specific type mapping 798 | $type = match ($id) { 799 | 'userId', 'event', 'resource', 'userAgent', 'ip', 'location', 'data' => 'String', 800 | 'time' => 'DateTime64(3)', 801 | default => 'String', 802 | }; 803 | 804 | $nullable = !$attribute['required'] ? 'Nullable(' . $type . ')' : $type; 805 | 806 | return "{$id} {$nullable}"; 807 | } 808 | 809 | /** 810 | * Get logs by user ID. 811 | * 812 | * @throws Exception 813 | */ 814 | public function getByUser( 815 | string $userId, 816 | ?\DateTime $after = null, 817 | ?\DateTime $before = null, 818 | int $limit = 25, 819 | int $offset = 0, 820 | bool $ascending = false, 821 | ): array { 822 | $time = $this->buildTimeClause($after, $before); 823 | $order = $ascending ? 'ASC' : 'DESC'; 824 | 825 | $tableName = $this->getTableName(); 826 | $tenantFilter = $this->getTenantFilter(); 827 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 828 | 829 | $sql = " 830 | SELECT " . $this->getSelectColumns() . " 831 | FROM {$escapedTable} 832 | WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} 833 | ORDER BY time {$order} 834 | LIMIT {limit:UInt64} OFFSET {offset:UInt64} 835 | FORMAT TabSeparated 836 | "; 837 | 838 | $result = $this->query($sql, array_merge([ 839 | 'userId' => $userId, 840 | 'limit' => $limit, 841 | 'offset' => $offset, 842 | ], $time['params'])); 843 | 844 | return $this->parseResults($result); 845 | } 846 | 847 | /** 848 | * Count logs by user ID. 849 | * 850 | * @throws Exception 851 | */ 852 | public function countByUser( 853 | string $userId, 854 | ?\DateTime $after = null, 855 | ?\DateTime $before = null, 856 | ): int { 857 | $time = $this->buildTimeClause($after, $before); 858 | 859 | $tableName = $this->getTableName(); 860 | $tenantFilter = $this->getTenantFilter(); 861 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 862 | 863 | $sql = " 864 | SELECT count() 865 | FROM {$escapedTable} 866 | WHERE userId = {userId:String}{$tenantFilter}{$time['clause']} 867 | FORMAT TabSeparated 868 | "; 869 | 870 | $result = $this->query($sql, array_merge([ 871 | 'userId' => $userId, 872 | ], $time['params'])); 873 | 874 | return (int) trim($result); 875 | } 876 | 877 | /** 878 | * Get logs by resource. 879 | * 880 | * @throws Exception 881 | */ 882 | public function getByResource( 883 | string $resource, 884 | ?\DateTime $after = null, 885 | ?\DateTime $before = null, 886 | int $limit = 25, 887 | int $offset = 0, 888 | bool $ascending = false, 889 | ): array { 890 | $time = $this->buildTimeClause($after, $before); 891 | $order = $ascending ? 'ASC' : 'DESC'; 892 | 893 | $tableName = $this->getTableName(); 894 | $tenantFilter = $this->getTenantFilter(); 895 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 896 | 897 | $sql = " 898 | SELECT " . $this->getSelectColumns() . " 899 | FROM {$escapedTable} 900 | WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} 901 | ORDER BY time {$order} 902 | LIMIT {limit:UInt64} OFFSET {offset:UInt64} 903 | FORMAT TabSeparated 904 | "; 905 | 906 | $result = $this->query($sql, array_merge([ 907 | 'resource' => $resource, 908 | 'limit' => $limit, 909 | 'offset' => $offset, 910 | ], $time['params'])); 911 | 912 | return $this->parseResults($result); 913 | } 914 | 915 | /** 916 | * Count logs by resource. 917 | * 918 | * @throws Exception 919 | */ 920 | public function countByResource( 921 | string $resource, 922 | ?\DateTime $after = null, 923 | ?\DateTime $before = null, 924 | ): int { 925 | $time = $this->buildTimeClause($after, $before); 926 | 927 | $tableName = $this->getTableName(); 928 | $tenantFilter = $this->getTenantFilter(); 929 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 930 | 931 | $sql = " 932 | SELECT count() 933 | FROM {$escapedTable} 934 | WHERE resource = {resource:String}{$tenantFilter}{$time['clause']} 935 | FORMAT TabSeparated 936 | "; 937 | 938 | $result = $this->query($sql, array_merge([ 939 | 'resource' => $resource, 940 | ], $time['params'])); 941 | 942 | return (int) trim($result); 943 | } 944 | 945 | /** 946 | * Get logs by user and events. 947 | * 948 | * @throws Exception 949 | */ 950 | public function getByUserAndEvents( 951 | string $userId, 952 | array $events, 953 | ?\DateTime $after = null, 954 | ?\DateTime $before = null, 955 | int $limit = 25, 956 | int $offset = 0, 957 | bool $ascending = false, 958 | ): array { 959 | $time = $this->buildTimeClause($after, $before); 960 | $order = $ascending ? 'ASC' : 'DESC'; 961 | $eventList = $this->buildEventsList($events, 0); 962 | $tableName = $this->getTableName(); 963 | $tenantFilter = $this->getTenantFilter(); 964 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 965 | 966 | $sql = " 967 | SELECT " . $this->getSelectColumns() . " 968 | FROM {$escapedTable} 969 | WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} 970 | ORDER BY time {$order} 971 | LIMIT {limit:UInt64} OFFSET {offset:UInt64} 972 | FORMAT TabSeparated 973 | "; 974 | 975 | $result = $this->query($sql, array_merge([ 976 | 'userId' => $userId, 977 | 'limit' => $limit, 978 | 'offset' => $offset, 979 | ], $eventList['params'], $time['params'])); 980 | 981 | return $this->parseResults($result); 982 | } 983 | 984 | /** 985 | * Count logs by user and events. 986 | * 987 | * @throws Exception 988 | */ 989 | public function countByUserAndEvents( 990 | string $userId, 991 | array $events, 992 | ?\DateTime $after = null, 993 | ?\DateTime $before = null, 994 | ): int { 995 | $time = $this->buildTimeClause($after, $before); 996 | $eventList = $this->buildEventsList($events, 0); 997 | $tableName = $this->getTableName(); 998 | $tenantFilter = $this->getTenantFilter(); 999 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 1000 | 1001 | $sql = " 1002 | SELECT count() 1003 | FROM {$escapedTable} 1004 | WHERE userId = {userId:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} 1005 | FORMAT TabSeparated 1006 | "; 1007 | 1008 | $result = $this->query($sql, array_merge([ 1009 | 'userId' => $userId, 1010 | ], $eventList['params'], $time['params'])); 1011 | 1012 | return (int) trim($result); 1013 | } 1014 | 1015 | /** 1016 | * Get logs by resource and events. 1017 | * 1018 | * @throws Exception 1019 | */ 1020 | public function getByResourceAndEvents( 1021 | string $resource, 1022 | array $events, 1023 | ?\DateTime $after = null, 1024 | ?\DateTime $before = null, 1025 | int $limit = 25, 1026 | int $offset = 0, 1027 | bool $ascending = false, 1028 | ): array { 1029 | $time = $this->buildTimeClause($after, $before); 1030 | $order = $ascending ? 'ASC' : 'DESC'; 1031 | $eventList = $this->buildEventsList($events, 0); 1032 | $tableName = $this->getTableName(); 1033 | $tenantFilter = $this->getTenantFilter(); 1034 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 1035 | 1036 | $sql = " 1037 | SELECT " . $this->getSelectColumns() . " 1038 | FROM {$escapedTable} 1039 | WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} 1040 | ORDER BY time {$order} 1041 | LIMIT {limit:UInt64} OFFSET {offset:UInt64} 1042 | FORMAT TabSeparated 1043 | "; 1044 | 1045 | $result = $this->query($sql, array_merge([ 1046 | 'resource' => $resource, 1047 | 'limit' => $limit, 1048 | 'offset' => $offset, 1049 | ], $eventList['params'], $time['params'])); 1050 | 1051 | return $this->parseResults($result); 1052 | } 1053 | 1054 | /** 1055 | * Count logs by resource and events. 1056 | * 1057 | * @throws Exception 1058 | */ 1059 | public function countByResourceAndEvents( 1060 | string $resource, 1061 | array $events, 1062 | ?\DateTime $after = null, 1063 | ?\DateTime $before = null, 1064 | ): int { 1065 | $time = $this->buildTimeClause($after, $before); 1066 | $eventList = $this->buildEventsList($events, 0); 1067 | $tableName = $this->getTableName(); 1068 | $tenantFilter = $this->getTenantFilter(); 1069 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 1070 | 1071 | $sql = " 1072 | SELECT count() 1073 | FROM {$escapedTable} 1074 | WHERE resource = {resource:String} AND event IN ({$eventList['clause']}){$tenantFilter}{$time['clause']} 1075 | FORMAT TabSeparated 1076 | "; 1077 | 1078 | $result = $this->query($sql, array_merge([ 1079 | 'resource' => $resource, 1080 | ], $eventList['params'], $time['params'])); 1081 | 1082 | return (int) trim($result); 1083 | } 1084 | 1085 | /** 1086 | * Delete logs older than the specified datetime. 1087 | * 1088 | * ClickHouse uses ALTER TABLE DELETE with synchronous mutations. 1089 | * 1090 | * @throws Exception 1091 | */ 1092 | public function cleanup(\DateTime $datetime): bool 1093 | { 1094 | $tableName = $this->getTableName(); 1095 | $tenantFilter = $this->getTenantFilter(); 1096 | $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); 1097 | 1098 | // Convert DateTime to string format expected by ClickHouse 1099 | $datetimeString = $datetime->format('Y-m-d H:i:s.v'); 1100 | 1101 | // Use DELETE statement for synchronous deletion (ClickHouse 23.3+) 1102 | // Falls back to ALTER TABLE DELETE with mutations_sync for older versions 1103 | $sql = " 1104 | DELETE FROM {$escapedTable} 1105 | WHERE time < {datetime:String}{$tenantFilter} 1106 | "; 1107 | 1108 | $this->query($sql, ['datetime' => $datetimeString]); 1109 | 1110 | return true; 1111 | } 1112 | } 1113 | --------------------------------------------------------------------------------