diff --git a/.env.example b/.env.example index bc60417..27e98eb 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,7 @@ # ============================================================================== # FILAMENT ACTIONGUARD - ENTERPRISE ENVIRONMENT CONFIGURATION # ============================================================================== -# Compliant with Laravel 11.x, 12.x, and Laravel 13.x Enterprise Architecture -# Includes OWASP Session Hardening, Hyphenated Cache Prefixes, -# Request Forgery Protection, and First-Party AI SDK Primitives. +# Local-development example only. Do not copy it to production unchanged. # ============================================================================== # ------------------------------------------------------------------------------ @@ -11,7 +9,7 @@ # ------------------------------------------------------------------------------ APP_NAME="Filament ActionGuard" APP_ENV=local -APP_KEY=base64:mwI8+tv+nFBfp+UiEtskdA471cijww4UZEwfIW/znbQ= +APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 APP_TIMEZONE=UTC @@ -55,7 +53,7 @@ DB_FOREIGN_KEYS=true # DB_PASSWORD=your_secure_password # ------------------------------------------------------------------------------ -# 4. SESSION & COOKIE SECURITY HARDENING (Laravel 13 & OWASP Standards) +# 4. SESSION & COOKIE SETTINGS # ------------------------------------------------------------------------------ SESSION_DRIVER=file SESSION_LIFETIME=120 @@ -64,6 +62,7 @@ SESSION_ENCRYPT=true SESSION_SERIALIZATION=json SESSION_PATH=/ SESSION_DOMAIN=null +# Set true in every HTTPS deployment. SESSION_SECURE_COOKIE=false SESSION_HTTP_ONLY=true SESSION_SAME_SITE=lax @@ -126,30 +125,23 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false # ------------------------------------------------------------------------------ -# 11. LARAVEL 13 FIRST-PARTY AI SDK & AGENT ECOSYSTEM +# 11. ACTIONGUARD POLICY SETTINGS # ------------------------------------------------------------------------------ -AI_DEFAULT_PROVIDER=openai -# OPENAI_API_KEY= -# ANTHROPIC_API_KEY= -# GEMINI_API_KEY= -# OLLAMA_BASE_URL=http://localhost:11434 - -# ------------------------------------------------------------------------------ -# 12. ACTIONGUARD POLICY & ENTERPRISE ENGINE SETTINGS -# ------------------------------------------------------------------------------ -# Enable or disable ActionGuard evaluation globally +# Enable or disable ActionGuard evaluation globally. Keep true in production. ACTIONGUARD_ENABLED=true # Security posture: Fail-closed (true) blocks actions if a check throws an unhandled exception ACTIONGUARD_FAIL_CLOSED=true -# Enterprise Business-Core bridge mode: -# - false: Standalone Community mode (evaluates native ActionGuard checks) -# - true: Delegates to allgorithm/business-core operation descriptors when installed -ACTIONGUARD_ENTERPRISE_MODE=false +# Disabled by default. Enable only for a controlled, logged maintenance task. +ACTIONGUARD_ALLOW_BYPASS=false # Dispatch user-facing Filament danger notifications on state invariant violations ACTIONGUARD_NOTIFICATIONS_ENABLED=true -# Enable detailed auditing for invariant checks +# Enable data-minimised structured audit events; route the channel to your SIEM. ACTIONGUARD_AUDIT_TRAIL=true +ACTIONGUARD_AUDIT_CHANNEL=stack + +# Relative and HTTPS resolution links are allowed. HTTP is blocked by default. +ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS=false diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1565de2..725a6ea 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -59,8 +59,15 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] - stability: ['prefer-stable'] + include: + - php: '8.3' + stability: 'prefer-stable' + - php: '8.4' + stability: 'prefer-stable' + - php: '8.5' + stability: 'prefer-stable' + - php: '8.3' + stability: 'prefer-lowest' steps: - name: Checkout code @@ -83,3 +90,25 @@ jobs: - name: Execute tests run: vendor/bin/pest --ci + + locked-test: + name: PHP 8.3 - locked dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv + coverage: none + + - name: Install locked dependencies + run: composer install --prefer-dist --no-interaction --ansi + + - name: Execute tests against the release lockfile + run: vendor/bin/pest --ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 6355758..e415322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Developer Experience & Tooling** - Root `artisan` CLI bridge to Orchestra Testbench and Workbench environment. - - Comprehensive test suite with 98 Pest tests and 225 assertions covering architecture, unit checks, traits, enterprise bridge hardening, and demo product scenarios. + - Comprehensive test suite with 103 Pest tests and 234 assertions covering architecture, unit checks, traits, enterprise bridge hardening, and demo product scenarios. [1.0.0]: https://github.com/allgorithm/filament-actionguard/releases/tag/v1.0.0 diff --git a/README.md b/README.md index f6e48ee..7cc1992 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Latest Version on Packagist PHP 8.3 - 8.5 Filament v5 - Pest Tests + Pest Tests PHPStan Level 8 License MIT

@@ -17,7 +17,7 @@ > **🛡️ Stop incomplete records and prevent data degradation across critical Filament actions.** -**Filament ActionGuard** is an enterprise-grade preflight gatekeeper and state invariant defense plugin for **Filament**. It prevents incomplete, invalid, or corrupted records from being published, approved, or transitioned into critical lifecycle states. +**Filament ActionGuard** is a production-grade preflight gatekeeper and state invariant defense plugin for **Filament**. It prevents incomplete, invalid, or corrupted records from being published, approved, or transitioned into critical lifecycle states. --- @@ -61,9 +61,8 @@ ActionGuard introduces a robust **Two-Phase Invariant Defense System**: - `RelationshipCheck`: Verifies loaded relationships (e.g. belongs-to, has-many). - `MediaCheck`: Checks media collections (Spatie MediaLibrary or file upload paths). - `CallbackCheck`: Flexible check with full `CheckResult` control. -- ⚡️ **Dual-Mode Enterprise Bridge (Coming Soon):** Works 100% standalone out-of-the-box (Community Edition), with seamless integration for `allgorithm/business-core` Domain Operation Descriptors coming soon. - 🌍 **Fully Localized (i18n):** Complete English and German translations included out-of-the-box. -- 💎 **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 98 comprehensive Pest tests. +- 💎 **Bulletproof Quality:** PHPStan **Level 8** (0 errors), 100% PSR-12 code style, and 103 comprehensive Pest tests. --- @@ -182,6 +181,10 @@ Product::withoutActionGuards(function () use ($product) { }); ``` +Bypasses are disabled by default. Enable `ACTIONGUARD_ALLOW_BYPASS=true` only +for a controlled maintenance operation; the bypass is scoped to that callback +and can emit a data-minimised audit event. + --- ### 3. Single Source of Truth (`forState`) @@ -197,28 +200,6 @@ ActionGuardAction::make('publish') --- -### 4. Dual-Mode Enterprise Bridge (`allgorithm/business-core`) *(Coming Soon)* - -> [!NOTE] -> **Enterprise Bridge (Coming Soon)**: ActionGuard is 100% functional standalone out-of-the-box. The direct bridge to `allgorithm/business-core` (`^1.2`) is currently in final integration testing and will be officially unlocked with the upcoming commercial Enterprise release. - -The Community package has no dependency on BusinessCore. Once you install a compatible licensed `allgorithm/business-core` package (`^1.2`), ActionGuard will detect its contracts automatically without requiring application or adapter changes: - -```php -use App\Domain\Operations\PublishProductOperation; - -ActionGuardAction::make('publish') - ->label('Publish Product') - ->operation(PublishProductOperation::class) - ->action(fn ($record) => $record->update(['status' => 'published'])); -``` - -If `operation()` is invoked without the licensed Core, ActionGuard fails closed with a clear, descriptive `LogicException`. Direct Community integrations through `checks()` and `forState()` remain fully available and recommended. - -ActionGuard registers a safe default `OperationContextFactoryContract` for Community installations. A licensed BusinessCore package can replace this container binding from its service provider to supply its authoritative actor, role, permission, tenant, and organization context. This activation is automatic: existing applications and `operation()` calls require no changes. Internal Enterprise exceptions are logged with a correlation reference while UI messages remain free of implementation details. - ---- - ## 🗂️ Built-in Checks Reference | Check Class | Evaluates | Example | @@ -250,24 +231,23 @@ ActionGuardAction::make('publish') ActionGuard is built with strict quality standards: ```bash -# Run automated test suite (98 tests, 225 assertions) +# Run automated test suite (103 tests, 234 assertions) composer test # Run code style fixer and static analysis (PHPStan Level 8) composer lint ``` ---- - -## 🏛️ Architecture Comparison +## Production safeguards -| Feature | `filament-actionguard` (This Plugin) | `allgorithm/business-core` (Enterprise Platform) | -| :--- | :--- | :--- | -| **Layer** | **Presentation Layer** (Filament UI Adapter) | **Domain & Application Layer** (Omnichannel Core) | -| **License** | **MIT (Open Source)** | Commercial Enterprise License | -| **Execution** | Preflight Action Modal & Model Post-Save Invariant | Omnichannel Guard Pipeline (API, Queue, CLI, UI) | -| **Audit Trail** | UI Feedback & Notifications | Tamper-proof 8-W Audit Log with SHA-256 Hash Chain | -| **Integration** | Native `operation()` & `forState()` bridge | Domain Operation Descriptors & Contracts | +ActionGuard is an invariant check, not an authorization, tenancy, or database +constraint system. Enforce authorization with Laravel policies and ensure that +critical bulk writes do not use `Model::where(...)->update()`, because Eloquent +does not dispatch model events for mass updates. For invariants that must hold +against every write path, add database constraints or enforce writes through an +application service. In production, keep `ACTIONGUARD_FAIL_CLOSED=true`, use a +unique `APP_KEY`, disable debug mode, enable secure cookies for HTTPS, and route +the optional data-minimised audit channel to your central logging system. --- @@ -275,6 +255,8 @@ composer lint The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +The project artwork was generated with AI; see [artwork attribution](art/ATTRIBUTION.md). + ---

diff --git a/SECURITY.md b/SECURITY.md index 050522b..3fe1258 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,15 @@ # Security Policy +## Supported Versions + +Only the latest released `1.x` version receives security fixes. Security support +is best effort; do not rely on this package as an authorization boundary. + ## Reporting Security Vulnerabilities -If you discover a security vulnerability within **Filament ActionGuard**, please send an e-mail to **security@allgorithm.io** or report it via [GitHub Security Advisories](https://github.com/allgorithm/filament-actionguard/security/advisories/new). +If you discover a security vulnerability within **Filament ActionGuard**, please send an e-mail to **security-allgorithm@online.de** or report it via [GitHub Security Advisories](https://github.com/allgorithm/filament-actionguard/security/advisories/new). Please do not disclose security issues publicly in issues or discussions until they have been addressed. -All security vulnerabilities will be promptly addressed, and we will issue a security advisory and patch as soon as possible. +We will acknowledge reports within five business days and publish a security +advisory and patch, or a remediation timeline, once the issue has been triaged. diff --git a/art/ATTRIBUTION.md b/art/ATTRIBUTION.md new file mode 100644 index 0000000..c243707 --- /dev/null +++ b/art/ATTRIBUTION.md @@ -0,0 +1,8 @@ +# Artwork attribution and AI disclosure + +`avatar.png` and `banner.png` were generated with AI for Filament ActionGuard. + +They are project artwork, not third-party stock assets, and are distributed with +this repository under the project MIT license. This disclosure is informational +and does not assert that the images are protected by copyright in every +jurisdiction. Do not remove this notice when redistributing the artwork. diff --git a/composer.json b/composer.json index 719683f..31b5ad3 100644 --- a/composer.json +++ b/composer.json @@ -61,11 +61,14 @@ }, "config": { "sort-packages": true, + "platform": { + "php": "8.3.0" + }, "allow-plugins": { "pestphp/pest-plugin": true } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true, "suggest": { "allgorithm/business-core": "Required only for the licensed Enterprise Bridge (^1.2); Community checks work without it." diff --git a/composer.lock b/composer.lock index 6f09151..73e838c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3274bd8b7b5dadc52ef9c785b4a17656", + "content-hash": "797dcf15498488420f2cb9f614fd0829", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -3250,6 +3250,73 @@ ], "time": "2026-09-07T16:02:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, { "name": "monolog/monolog", "version": "3.12.0", @@ -5057,21 +5124,22 @@ }, { "name": "symfony/clock", - "version": "v8.1.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/clock": "^1.0" + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" }, "provide": { "psr/clock-implementation": "1.0" @@ -5110,7 +5178,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.1.0" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -5130,53 +5198,51 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/console", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", + "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4.6|^8.0.6" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/event-dispatcher": "<8.1" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5210,7 +5276,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.6" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -5230,24 +5296,24 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:18:42+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", - "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5279,7 +5345,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" }, "funding": [ { @@ -5299,7 +5365,7 @@ "type": "tidelift" } ], - "time": "2026-08-23T10:06:25+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5374,32 +5440,33 @@ }, { "name": "symfony/error-handler", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", - "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5" + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -5431,7 +5498,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -5451,29 +5518,28 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", - "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5482,14 +5548,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5517,7 +5583,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -5537,7 +5603,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5621,23 +5687,23 @@ }, { "name": "symfony/finder", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^7.4|^8.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5665,7 +5731,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.5" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -5685,26 +5751,28 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199" + "reference": "5394e10c12b6190c019e3112e8b534180c22f0da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f8bbdb0704e6b6e9a3412481c1a87886a0633199", - "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/5394e10c12b6190c019e3112e8b534180c22f0da", + "reference": "5394e10c12b6190c019e3112e8b534180c22f0da", "shasum": "" }, "require": { "ext-dom": "*", "league/uri": "^6.5|^7.0", - "php": ">=8.4.1" + "masterminds/html5": "^2.7.2", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { @@ -5737,7 +5805,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.6" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.18" }, "funding": [ { @@ -5757,40 +5825,41 @@ "type": "tidelift" } ], - "time": "2026-08-30T01:03:44+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<4.3" + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^4.3", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5818,7 +5887,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -5838,69 +5907,78 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/dependency-injection": "<8.1", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/var-dumper": "<8.1", - "symfony/web-profiler-bundle": "<8.1", - "twig/twig": "<3.21" + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/routing": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^8.1", - "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21|^4.0" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -5928,7 +6006,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" }, "funding": [ { @@ -5948,39 +6026,43 @@ "type": "tidelift" } ], - "time": "2026-08-30T21:40:49+00:00" + "time": "2026-08-30T21:24:29+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.4.1", + "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5" + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/twig-bridge": "^7.4|^8.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6008,7 +6090,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.5" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -6028,41 +6110,44 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1" + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/property-info": "^7.4|^8.0", - "symfony/serializer": "^7.4.17|^8.1.5" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -6094,7 +6179,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.6" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -6114,7 +6199,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6702,6 +6787,86 @@ ], "time": "2026-05-26T12:45:58+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", @@ -7027,20 +7192,20 @@ }, { "name": "symfony/process", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -7068,7 +7233,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.6" + "source": "https://github.com/symfony/process/tree/v7.4.18" }, "funding": [ { @@ -7088,33 +7253,38 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/routing", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7148,7 +7318,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.6" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -7168,7 +7338,7 @@ "type": "tidelift" } ], - "time": "2026-08-17T13:18:34+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", @@ -7259,34 +7429,35 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7325,7 +7496,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v7.4.15" }, "funding": [ { @@ -7345,31 +7516,38 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/translation", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", - "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-mbstring": "^1.0", - "symfony/translation-contracts": "^3.6.1" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/service-contracts": "<2.5" + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -7377,17 +7555,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^7.4|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^7.4|^8.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7418,7 +7596,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.5" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -7438,7 +7616,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", @@ -7524,24 +7702,24 @@ }, { "name": "symfony/uid", - "version": "v8.1.5", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "a08aef47989093f32fe50fd11859be1b427df389" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/a08aef47989093f32fe50fd11859be1b427df389", - "reference": "a08aef47989093f32fe50fd11859be1b427df389", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7578,7 +7756,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.1.5" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -7598,35 +7776,35 @@ "type": "tidelift" } ], - "time": "2026-08-11T13:39:01+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "3783365b58972f4779254d98372af80fbf15e170" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", - "reference": "3783365b58972f4779254d98372af80fbf15e170", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<7.4", - "symfony/error-handler": "<7.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12|^4.0" }, "bin": [ @@ -7665,7 +7843,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -7685,7 +7863,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -12052,110 +12230,30 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/polyfill-php83", - "version": "v1.41.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-01T12:47:55+00:00" - }, { "name": "symfony/yaml", - "version": "v8.1.6", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", - "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<7.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", - "yaml/yaml-test-suite": "*" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -12186,7 +12284,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.18" }, "funding": [ { @@ -12206,7 +12304,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T01:03:44+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -12385,7 +12483,7 @@ } ], "aliases": [], - "minimum-stability": "dev", + "minimum-stability": "stable", "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, @@ -12393,5 +12491,8 @@ "php": "^8.3|^8.4|^8.5" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, "plugin-api-version": "2.9.0" } diff --git a/config/filament-actionguard.php b/config/filament-actionguard.php new file mode 100644 index 0000000..26541fa --- /dev/null +++ b/config/filament-actionguard.php @@ -0,0 +1,31 @@ + (bool) env('ACTIONGUARD_ENABLED', true), + + /* Errors in a check must block the operation unless explicitly disabled. */ + 'fail_closed' => (bool) env('ACTIONGUARD_FAIL_CLOSED', true), + + /* A bypass is disabled by default and is always scoped to one callback. */ + 'allow_bypass' => (bool) env('ACTIONGUARD_ALLOW_BYPASS', false), + + 'notifications' => (bool) env('ACTIONGUARD_NOTIFICATIONS_ENABLED', true), + + /* + * The package never logs model attributes, messages, record IDs, or actor IDs. + * Applications may route this channel to their central audit system. + */ + 'audit' => [ + 'enabled' => (bool) env('ACTIONGUARD_AUDIT_TRAIL', false), + 'channel' => env('ACTIONGUARD_AUDIT_CHANNEL'), + ], + + /* Relative URLs and HTTPS are allowed; HTTP requires an explicit opt-in. */ + 'allow_insecure_resolution_urls' => (bool) env('ACTIONGUARD_ALLOW_INSECURE_RESOLUTION_URLS', false), +]; diff --git a/src/ActionGuardServiceProvider.php b/src/ActionGuardServiceProvider.php index 582c152..9742e23 100644 --- a/src/ActionGuardServiceProvider.php +++ b/src/ActionGuardServiceProvider.php @@ -26,6 +26,7 @@ public function configurePackage(Package $package): void */ $package ->name('filament-actionguard') + ->hasConfigFile() ->hasTranslations() ->hasViews(); } diff --git a/src/Actions/ActionGuardAction.php b/src/Actions/ActionGuardAction.php index bdf905d..73686dd 100644 --- a/src/Actions/ActionGuardAction.php +++ b/src/Actions/ActionGuardAction.php @@ -6,12 +6,14 @@ use Allgorithm\FilamentActionGuard\Contracts\ActionGuardCheckContract; use Allgorithm\FilamentActionGuard\Results\ActionGuardResult; use Allgorithm\FilamentActionGuard\Results\CheckResult; +use Allgorithm\FilamentActionGuard\Support\ActionGuardAudit; use Filament\Actions\Action; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; +use InvalidArgumentException; class ActionGuardAction extends Action { @@ -30,7 +32,7 @@ protected function setUp(): void $this->modalWidth('lg'); $rawLabel = $this->getLabel(); - $label = $rawLabel instanceof Htmlable ? $rawLabel->toHtml() : (string) ($rawLabel ?? ''); + $label = $rawLabel instanceof Htmlable ? strip_tags($rawLabel->toHtml()) : (string) ($rawLabel ?? ''); $this->modalHeading(__('filament-actionguard::ui.modal.heading', ['label' => $label])); $this->modalContent(function (ActionGuardAction $action, ?Model $record) { return $action->evaluateAndRender($record); @@ -62,7 +64,11 @@ public function checks(array $checks): static { $this->checks = array_map(function ($check) { if (is_string($check) && class_exists($check)) { - return app($check); + $check = app($check); + } + + if (! $check instanceof ActionGuardCheckContract) { + throw new InvalidArgumentException('Every ActionGuard check must implement '.ActionGuardCheckContract::class.'.'); } return $check; @@ -104,6 +110,10 @@ public function forState(string $state): static public function evaluateChecks(?Model $record): ActionGuardResult { + if (! config('filament-actionguard.enabled', true)) { + return ActionGuardResult::fromChecks([]); + } + $checks = $this->checks; // Auto-resolve guards from model if none were explicitly set and model uses HasActionGuards @@ -120,7 +130,7 @@ public function evaluateChecks(?Model $record): ActionGuardResult label: __('filament-actionguard::ui.errors.record_label'), message: __('filament-actionguard::ui.errors.record_missing'), ), - ]); + ], (bool) config('filament-actionguard.fail_closed', true)); } return ActionGuardResult::fromChecks([]); @@ -142,7 +152,15 @@ public function evaluateChecks(?Model $record): ActionGuardResult } } - return ActionGuardResult::fromChecks($results); + $result = ActionGuardResult::fromChecks($results, (bool) config('filament-actionguard.fail_closed', true)); + + ActionGuardAudit::record('action_evaluated', [ + 'passed' => $result->passed, + 'failed' => $result->summary['failed'], + 'errors' => $result->summary['errors'], + ]); + + return $result; } public function evaluateAndRender(?Model $record): HtmlString|View|string diff --git a/src/Bridge/BusinessCoreGuardAdapter.php b/src/Bridge/BusinessCoreGuardAdapter.php index f5bf157..98f12e6 100644 --- a/src/Bridge/BusinessCoreGuardAdapter.php +++ b/src/Bridge/BusinessCoreGuardAdapter.php @@ -155,6 +155,11 @@ protected function sanitizeResolutionUrl(mixed $url): ?string $scheme = parse_url($url, PHP_URL_SCHEME); - return in_array(strtolower((string) $scheme), ['https', 'http'], true) ? $url : null; + $allowedSchemes = ['https']; + if (config('filament-actionguard.allow_insecure_resolution_urls', false)) { + $allowedSchemes[] = 'http'; + } + + return in_array(strtolower((string) $scheme), $allowedSchemes, true) ? $url : null; } } diff --git a/src/Checks/CallbackCheck.php b/src/Checks/CallbackCheck.php index 7515e14..e014849 100644 --- a/src/Checks/CallbackCheck.php +++ b/src/Checks/CallbackCheck.php @@ -6,6 +6,7 @@ use Allgorithm\FilamentActionGuard\Results\CheckResult; use Closure; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class CallbackCheck implements ActionGuardCheckContract { @@ -57,10 +58,13 @@ public function evaluate(Model $record): CheckResult return $result; } catch (\Throwable $e) { + $reference = (string) Str::uuid(); + report($e); + return CheckResult::error( key: $this->key, label: $label, - message: "CallbackCheck '{$this->key}' encountered an error: ".$e->getMessage() + message: __('filament-actionguard::ui.errors.check_failed', ['reference' => $reference]) ); } } diff --git a/src/Checks/ConditionCheck.php b/src/Checks/ConditionCheck.php index b82e070..2224f1c 100644 --- a/src/Checks/ConditionCheck.php +++ b/src/Checks/ConditionCheck.php @@ -6,6 +6,7 @@ use Allgorithm\FilamentActionGuard\Results\CheckResult; use Closure; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class ConditionCheck implements ActionGuardCheckContract { @@ -69,10 +70,13 @@ public function evaluate(Model $record): CheckResult try { $passed = (bool) call_user_func($this->condition, $record); } catch (\Throwable $e) { + $reference = (string) Str::uuid(); + report($e); + return CheckResult::error( key: $this->key, label: $label, - message: "ConditionCheck '{$this->key}' encountered an error: ".$e->getMessage() + message: __('filament-actionguard::ui.errors.check_failed', ['reference' => $reference]) ); } diff --git a/src/Checks/MediaCheck.php b/src/Checks/MediaCheck.php index e403165..1582aa6 100644 --- a/src/Checks/MediaCheck.php +++ b/src/Checks/MediaCheck.php @@ -5,6 +5,7 @@ use Allgorithm\FilamentActionGuard\Contracts\ActionGuardCheckContract; use Allgorithm\FilamentActionGuard\Results\CheckResult; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class MediaCheck implements ActionGuardCheckContract { @@ -53,10 +54,13 @@ public function evaluate(Model $record): CheckResult } } } catch (\Throwable $e) { + $reference = (string) Str::uuid(); + report($e); + return CheckResult::error( key: "media.{$this->collection}", label: $label, - message: "MediaCheck for '{$label}' encountered an error: ".$e->getMessage() + message: __('filament-actionguard::ui.errors.check_failed', ['reference' => $reference]) ); } diff --git a/src/Checks/RelationshipCheck.php b/src/Checks/RelationshipCheck.php index d809f0f..f4f9f7b 100644 --- a/src/Checks/RelationshipCheck.php +++ b/src/Checks/RelationshipCheck.php @@ -6,6 +6,7 @@ use Allgorithm\FilamentActionGuard\Results\CheckResult; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Str; class RelationshipCheck implements ActionGuardCheckContract { @@ -49,10 +50,13 @@ public function evaluate(Model $record): CheckResult try { $relationValue = $record->getRelationValue($this->relationship); } catch (\Throwable $e) { + $reference = (string) Str::uuid(); + report($e); + return CheckResult::error( key: $this->relationship, label: $label, - message: "Relationship '{$this->relationship}' could not be resolved: ".$e->getMessage() + message: __('filament-actionguard::ui.errors.check_failed', ['reference' => $reference]) ); } diff --git a/src/Exceptions/StateInvariantViolationException.php b/src/Exceptions/StateInvariantViolationException.php index 109018f..b6a846a 100644 --- a/src/Exceptions/StateInvariantViolationException.php +++ b/src/Exceptions/StateInvariantViolationException.php @@ -71,7 +71,7 @@ public static function fromResult(Model $model, string $state, ActionGuardResult */ public function sendNotification(): void { - if (! class_exists(Notification::class)) { + if (! config('filament-actionguard.notifications', true) || ! class_exists(Notification::class)) { return; } diff --git a/src/Results/ActionGuardResult.php b/src/Results/ActionGuardResult.php index 3093aa0..7c61e2e 100644 --- a/src/Results/ActionGuardResult.php +++ b/src/Results/ActionGuardResult.php @@ -17,7 +17,7 @@ public function __construct( /** * @param array $checks */ - public static function fromChecks(array $checks): self + public static function fromChecks(array $checks, bool $failClosed = true): self { $passedCount = 0; $failedCount = 0; @@ -35,7 +35,9 @@ public static function fromChecks(array $checks): self } } elseif ($check->status === CheckStatus::ERROR) { $errorCount++; - $overallPassed = false; // Errors always fail-closed as per spec + if ($failClosed) { + $overallPassed = false; + } } } diff --git a/src/Support/ActionGuardAudit.php b/src/Support/ActionGuardAudit.php new file mode 100644 index 0000000..56c8142 --- /dev/null +++ b/src/Support/ActionGuardAudit.php @@ -0,0 +1,26 @@ + $context + */ + public static function record(string $event, array $context = []): void + { + if (! config('filament-actionguard.audit.enabled', false)) { + return; + } + + Log::channel(config('filament-actionguard.audit.channel')) + ->notice('filament-actionguard.'.$event, $context); + } +} diff --git a/src/Traits/HasActionGuards.php b/src/Traits/HasActionGuards.php index 5e02b4d..b4cfa73 100644 --- a/src/Traits/HasActionGuards.php +++ b/src/Traits/HasActionGuards.php @@ -9,8 +9,10 @@ use Allgorithm\FilamentActionGuard\Exceptions\StateInvariantViolationException; use Allgorithm\FilamentActionGuard\Results\ActionGuardResult; use Allgorithm\FilamentActionGuard\Results\CheckResult; +use Allgorithm\FilamentActionGuard\Support\ActionGuardAudit; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; +use LogicException; /** * Trait HasActionGuards @@ -20,10 +22,7 @@ */ trait HasActionGuards { - /** - * Set to true to temporarily bypass action guard enforcement. - */ - public bool $skipActionGuards = false; + private static int $actionGuardBypassDepth = 0; /** * Boot the trait and register the Eloquent saving event. @@ -43,7 +42,7 @@ public static function bootHasActionGuards(): void */ public function enforceActionGuards(): void { - if ($this->skipActionGuards) { + if (! config('filament-actionguard.enabled', true) || static::$actionGuardBypassDepth > 0) { return; } @@ -72,9 +71,16 @@ public function enforceActionGuards(): void } } - $result = ActionGuardResult::fromChecks($results); + $result = ActionGuardResult::fromChecks($results, (bool) config('filament-actionguard.fail_closed', true)); if (! $result->passed) { + ActionGuardAudit::record('invariant_blocked', [ + 'model' => static::class, + 'state' => $state, + 'failed' => $result->summary['failed'], + 'errors' => $result->summary['errors'], + ]); + throw StateInvariantViolationException::fromResult($this, $state, $result); } } @@ -126,15 +132,19 @@ public function getActionGuardStateColumn(): string /** * Executes a callback without triggering ActionGuard checks. */ - public function withoutActionGuards(callable $callback): mixed + public static function withoutActionGuards(callable $callback): mixed { - $previous = $this->skipActionGuards; - $this->skipActionGuards = true; + if (! config('filament-actionguard.allow_bypass', false)) { + throw new LogicException('ActionGuard bypasses are disabled. Set ACTIONGUARD_ALLOW_BYPASS=true only for a controlled maintenance operation.'); + } + + static::$actionGuardBypassDepth++; + ActionGuardAudit::record('bypass_used', ['model' => static::class]); try { - return $callback($this); + return $callback(); } finally { - $this->skipActionGuards = $previous; + static::$actionGuardBypassDepth--; } } } diff --git a/tests/Feature/ActionGuardActionTest.php b/tests/Feature/ActionGuardActionTest.php index 57f238e..5b33a10 100644 --- a/tests/Feature/ActionGuardActionTest.php +++ b/tests/Feature/ActionGuardActionTest.php @@ -124,6 +124,22 @@ public function evaluate(Model $record): CheckResult ->and($result->summary['total'])->toBe(0); }); +it('can be explicitly disabled by configuration', function () { + config()->set('filament-actionguard.enabled', false); + + $action = ActionGuardAction::make('publish')->checks([ + new class implements ActionGuardCheckContract + { + public function evaluate(Model $record): CheckResult + { + return CheckResult::fail('blocked', 'Blocked', 'This must not run.'); + } + }, + ]); + + expect($action->evaluateChecks(new class extends Model {})->passed)->toBeTrue(); +}); + it('catches check exceptions during evaluateChecks and fails closed', function () { $action = ActionGuardAction::make('publish') ->checks([ diff --git a/tests/Feature/Security/ResolutionLinkXssSecurityTest.php b/tests/Feature/Security/ResolutionLinkXssSecurityTest.php index 35827ea..2fe5381 100644 --- a/tests/Feature/Security/ResolutionLinkXssSecurityTest.php +++ b/tests/Feature/Security/ResolutionLinkXssSecurityTest.php @@ -168,6 +168,19 @@ public function evaluate(Model $record): CheckResult ->and($html)->toContain('Review compliance documents'); })->with([ 'https://billing.example.com/invoice/123', - 'http://localhost:8000/fix', '/admin/compliance/review', ]); + +it('blocks HTTP resolution URLs unless the explicit compatibility switch is enabled', function () { + SecTestMaliciousGuard::$payloadUrl = 'http://localhost:8000/fix'; + SecTestMaliciousGuard::$payloadLabel = 'Local compatibility link'; + + $action = ActionGuardAction::make('publish')->operation(SecTestMaliciousOperation::class); + expect($action->evaluateAndRender(new class extends Model {})->render()) + ->not->toContain('href="http://localhost:8000/fix"'); + + config()->set('filament-actionguard.allow_insecure_resolution_urls', true); + + expect($action->evaluateAndRender(new class extends Model {})->render()) + ->toContain('href="http://localhost:8000/fix"'); +}); diff --git a/tests/Unit/ActionGuardResultTest.php b/tests/Unit/ActionGuardResultTest.php index 2dd7602..91d2ea9 100644 --- a/tests/Unit/ActionGuardResultTest.php +++ b/tests/Unit/ActionGuardResultTest.php @@ -45,3 +45,12 @@ expect($result->passed)->toBeFalse() ->and($result->summary['errors'])->toBe(1); }); + +it('can explicitly fail open for check execution errors', function () { + $result = ActionGuardResult::fromChecks([ + CheckResult::error('unavailable', 'Unavailable', 'The dependency is unavailable.'), + ], failClosed: false); + + expect($result->passed)->toBeTrue() + ->and($result->summary['errors'])->toBe(1); +}); diff --git a/tests/Unit/Checks/CallbackCheckTest.php b/tests/Unit/Checks/CallbackCheckTest.php index a0fe278..5c5483d 100644 --- a/tests/Unit/Checks/CallbackCheckTest.php +++ b/tests/Unit/Checks/CallbackCheckTest.php @@ -32,7 +32,8 @@ $result = $check->evaluate($model); expect($result->status)->toBe(CheckStatus::ERROR) - ->and($result->message)->toContain('Callback crashed unexpectedly'); + ->and($result->message)->not->toContain('Callback crashed unexpectedly') + ->and($result->message)->toContain('Reference:'); }); it('returns error when callback does not return a CheckResult instance', function () { diff --git a/tests/Unit/Checks/ConditionCheckTest.php b/tests/Unit/Checks/ConditionCheckTest.php index fd55a63..5b96796 100644 --- a/tests/Unit/Checks/ConditionCheckTest.php +++ b/tests/Unit/Checks/ConditionCheckTest.php @@ -47,7 +47,8 @@ $result = $check->evaluate($model); expect($result->status)->toBe(CheckStatus::ERROR) - ->and($result->message)->toContain('Database connection lost during condition'); + ->and($result->message)->not->toContain('Database connection lost during condition') + ->and($result->message)->toContain('Reference:'); }); it('respects optional setting in ConditionCheck', function () { diff --git a/tests/Unit/Checks/MediaCheckTest.php b/tests/Unit/Checks/MediaCheckTest.php index 2177729..6db5b9a 100644 --- a/tests/Unit/Checks/MediaCheckTest.php +++ b/tests/Unit/Checks/MediaCheckTest.php @@ -96,7 +96,8 @@ public function getMedia(string $collection) $result = $check->evaluate($model); expect($result->status)->toBe(CheckStatus::ERROR) - ->and($result->message)->toContain('Media library disk unreachable'); + ->and($result->message)->not->toContain('Media library disk unreachable') + ->and($result->message)->toContain('Reference:'); }); it('respects optional setting in MediaCheck', function () { diff --git a/tests/Unit/Checks/RelationshipCheckTest.php b/tests/Unit/Checks/RelationshipCheckTest.php index 35d0b89..c1f2f9e 100644 --- a/tests/Unit/Checks/RelationshipCheckTest.php +++ b/tests/Unit/Checks/RelationshipCheckTest.php @@ -67,7 +67,8 @@ public function problematicRelation() $result = $check->evaluate($model); expect($result->status)->toBe(CheckStatus::ERROR) - ->and($result->message)->toContain('could not be resolved'); + ->and($result->message)->not->toContain('Database query failed for relation') + ->and($result->message)->toContain('Reference:'); }); it('respects optional setting in RelationshipCheck', function () { diff --git a/tests/Unit/Support/ActionGuardAuditTest.php b/tests/Unit/Support/ActionGuardAuditTest.php new file mode 100644 index 0000000..7c8ff1f --- /dev/null +++ b/tests/Unit/Support/ActionGuardAuditTest.php @@ -0,0 +1,32 @@ +set('filament-actionguard.audit.enabled', false); + Log::spy(); + + ActionGuardAudit::record('invariant_blocked', ['model' => 'Example']); + + Log::shouldNotHaveReceived('channel'); +}); + +it('writes only the supplied structured audit context to the configured channel', function () { + config()->set('filament-actionguard.audit.enabled', true); + config()->set('filament-actionguard.audit.channel', 'stack'); + Log::shouldReceive('channel')->once()->with('stack')->andReturnSelf(); + Log::shouldReceive('notice')->once()->with('filament-actionguard.invariant_blocked', [ + 'model' => 'Example', + 'state' => 'published', + 'failed' => 1, + 'errors' => 0, + ]); + + ActionGuardAudit::record('invariant_blocked', [ + 'model' => 'Example', + 'state' => 'published', + 'failed' => 1, + 'errors' => 0, + ]); +}); diff --git a/tests/Unit/Traits/HasActionGuardsTest.php b/tests/Unit/Traits/HasActionGuardsTest.php index 6bc7a4b..642fc20 100644 --- a/tests/Unit/Traits/HasActionGuardsTest.php +++ b/tests/Unit/Traits/HasActionGuardsTest.php @@ -160,8 +160,10 @@ public function actionGuards(): array $article->image_url = null; - $saved = $article->withoutActionGuards(function ($item) { - return $item->save(); + config()->set('filament-actionguard.allow_bypass', true); + + $saved = TestArticle::withoutActionGuards(function () use ($article) { + return $article->save(); }); expect($saved)->toBeTrue(); @@ -169,6 +171,13 @@ public function actionGuards(): array expect($article->image_url)->toBeNull(); }); +it('rejects bypasses unless explicitly enabled', function () { + config()->set('filament-actionguard.allow_bypass', false); + + expect(fn () => TestArticle::withoutActionGuards(fn () => null)) + ->toThrow(LogicException::class); +}); + it('supports custom state column names', function () { $comment = new TestComment([ 'content' => '',