diff --git a/.manifest b/.manifest index 9039cbdf..ea9f26d8 100644 --- a/.manifest +++ b/.manifest @@ -3,8 +3,8 @@ # APP_CHANNEL defines the target branch inside the specified repository. ##> aavion/studio manifest ### -APP_VERSION=0.2.4 -APP_DATE=2026-06-14 +APP_VERSION=0.2.5 +APP_DATE=2026-06-17 APP_NAME=Studio APP_AUTHOR=Dominik Letica APP_DESCRIPTION=Symfony 8.1 based content-management system for structured project websites. diff --git a/AGENTS.md b/AGENTS.md index c1e7aa5f..efd5edf0 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,21 @@ - Translation changes must keep matching source catalogue files and keys synchronized across all locale directories under `translations/languages/`; runtime `translations/messages.*.yaml` files are generated from those sources. - Refactors before the first public `1.0.0` release may remove obsolete code instead of keeping compatibility shims, but callers, tests, docs, and class map entries must be updated immediately. - If a requested narrow change exposes unrelated drift, fix it only when it blocks the task; otherwise record the follow-up in `dev/WORKLOG.md`. -- When addressing review findings, trace adjacent and analogous code paths that share the same policy, transition, or boundary, and apply or explicitly rule out the same fix there to avoid one-path-only hardening. + +### Review Finding Fixes +- Before applying a fix for a review finding, trace the affected boundary from source to sink and inspect adjacent, related, and analogous code paths that share the same classifier, subscriber, guard, resolver, route family, subject selection, response behavior, storage boundary, or policy decision. +- Prefer fixing the narrowest central boundary that covers all affected paths. Apply a path-local fix only when evidence shows the issue is truly path-specific. +- Keep review fixes simple, modular, and minimally invasive. Do not broaden them into unrelated refactors, compatibility shims, or speculative redesigns. +- While tracing the affected boundary, actively look for additional unreported edge cases, including bypasses, abuse paths, privacy leaks, performance regressions, setup/pre-auth behavior, disabled-feature fallbacks, response redaction, and cache/storage failure behavior. +- Fix small in-scope adjacent issues directly when they share the same boundary and risk profile. Record larger or behavior-changing follow-ups in `dev/WORKLOG.md` instead of hiding them inside the review fix. +- Add or update regression coverage for the reported finding and any adjacent paths changed by the fix. When an analogous path is inspected and intentionally not changed, make that reasoning clear in the worklog, final notes, or PR response where useful. + +### PR Readiness Audits +- Before marking a branch, pull request, or feature slice ready for review, run the PR-readiness checklist as a real audit pass over the branch diff and the affected runtime surfaces. Do not treat checklist items as passive boxes to tick. +- The audit must explicitly review security/privacy considerations; public entry points; authentication, authorization, sessions, secrets, browser storage, and response redaction; package/module boundaries; access levels; route/API/live endpoint scopes; naming and collision risks; setup/init/CI behavior; cross-platform behavior; disabled-feature fallbacks; process and environment handling; default seed coverage for implemented config keys; translations and user-facing copy; project-rule, architecture, naming, documentation, and performance drift; and captured follow-up tasks. +- Use evidence from code inspection, focused tests, render checks, linting, documentation diffs, class map/worklog updates, and seed/default coverage as appropriate for the changed surface. If a checklist item is not applicable, record why instead of silently skipping it. +- Fix small readiness issues directly when they are in scope and low risk. Record larger, behavior-changing, or separate-domain issues in `dev/WORKLOG.md` with a clear next action. +- PR notes must summarize the readiness audit outcome, including verification commands, skipped checks or proof gaps, documentation/worklog/classmap status, translation status, security/privacy considerations, and remaining follow-ups. ## Build and Verification Commands - `bin/init` initializes the repository, refreshes dependencies and assets, locks referenced Symfony UX icons locally when possible, and is the preferred recovery path for broken or incomplete `vendor/` packages because it removes an existing `vendor/` tree before Composer runs. @@ -183,6 +197,8 @@ ## Review Mode - In code review, lead with findings ordered by severity and include file and line references. +- Review-fix implementation must follow the Review Finding Fixes rules under Change Expectations before applying code changes. +- PR-readiness sign-off must follow the PR Readiness Audits rules under Change Expectations instead of only copying checklist items. - Verify worklog, documentation, tests, class map, translations, screenshots, security notes, and PR checklist items when they are relevant to the reviewed change. - Check drift between code and feature drafts in `dev/draft/`; update it only when asked to make changes, otherwise report the drift. - Review translation coverage with `bin/lint ` when user-facing copy changed. diff --git a/README.md b/README.md index afcffd1a..1d8a6a9b 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Studio -> **Version**: 0.2.4 +> **Version**: 0.2.5 > **Status**: Active development -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-17 > **Owner**: Dominik Letica > **Purpose:** A Symfony-based CMS foundation for structured, extensible project websites. diff --git a/SECURITY.md b/SECURITY.md index 825202c3..3e71d031 100755 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ # Security Policy > **Status**: Active -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-16 > **Owner**: Dominik Letica > **Purpose:** Support and reporting policy. ## Supported Versions -### main-Channel -Stable builds (`main`-branch or stable releases, where `main` always reflects the latest stable release) are supported at least until the next version is publicly available. +### stable-Channel +Stable builds (`stable-*`-branch or tagless releases) are supported at least until the next version is publicly available. Please consider to keep your environment always up to date for security patches to apply. If you're facing any problems, please feel free to [report](https://github.com/aavion/studio/issues) them to get assistance. **Note:** Versions prior to the first major release are considered `dev` (see below). | Version | Supported | | ------- | ------------------ | -| < 1.0.0 | :x: (see `dev`) | +| < 1.0.0 | :x: | ### beta-Channel Beta builds (`beta-*` branch or releases with `beta`-tag) receive limited support via GitHub Issues at least until the next version is publicly available. diff --git a/assets/styles/system/base.css b/assets/styles/system/base.css index a04b3eef..31c56b0d 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -685,6 +685,24 @@ html { border-bottom: 0; } +.system-acl-group-grid { + display: grid; + gap: 0.5rem; + min-width: min(100%, 22rem); +} + +.system-acl-group-row { + align-items: center; + display: grid; + gap: 0.5rem; + grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 12rem); +} + +.system-acl-group-row code { + display: block; + margin-top: 0.125rem; +} + .system-markdown, .system-rich-text { @apply text-base leading-7; diff --git a/composer.lock b/composer.lock index b6c17c59..5adce6c6 100755 --- a/composer.lock +++ b/composer.lock @@ -1536,16 +1536,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43", + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43", "shasum": "" }, "require": { @@ -1635,7 +1635,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.0" }, "funding": [ { @@ -1651,7 +1651,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-16T21:50:11+00:00" }, { "name": "intervention/image", @@ -1739,16 +1739,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.9.0", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -1758,7 +1758,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -1808,9 +1808,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2026-06-05T14:05:24+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "lcobucci/jwt", @@ -11707,16 +11707,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -11767,9 +11767,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -12394,16 +12394,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.2.0", + "version": "13.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a" + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3796ea973f1e7698f0d432c1c66662af9764fd9a", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", "shasum": "" }, "require": { @@ -12417,7 +12417,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2", + "phpunit/php-code-coverage": "^14.2.2", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", @@ -12474,7 +12474,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" }, "funding": [ { @@ -12482,7 +12482,7 @@ "type": "other" } ], - "time": "2026-06-05T03:13:07+00:00" + "time": "2026-06-15T13:14:22+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/services.yaml b/config/services.yaml index da508fcb..44dc979e 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -41,6 +41,9 @@ services: App\Core\Event\EventHookDescriptorProviderInterface: tags: - { name: 'system.event_hook_provider', priority: 0 } + App\Core\Geo\GeoIpProviderInterface: + tags: + - { name: 'system.geoip_provider', priority: 0 } App\Backend\BackendViewProviderInterface: tags: - { name: 'system.backend_view_provider', priority: 0 } @@ -71,6 +74,9 @@ services: App\Security\AclGroupReferenceProviderInterface: tags: - { name: 'system.acl_group_reference_provider', priority: 0 } + App\Core\AdminAcl\AdminFeatureProviderInterface: + tags: + - { name: 'system.admin_feature_provider', priority: 0 } # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -106,6 +112,7 @@ services: - '../src/**/*Scope.php' - '../src/**/*Source.php' - '../src/Debug/RouteRenderOptions.php' + - '../src/Core/Geo/GeoIp2MaxMindDatabaseReader.php' - '../src/Core/Package/PackageSpec.php' - '../src/**/*Spec.php' - '../src/**/*Status.php' @@ -172,6 +179,10 @@ services: arguments: $sessionFactory: '@session.factory' + App\Command\RenderRouteCommand: + arguments: + $environment: '%kernel.environment%' + App\Setup\SetupRedirectSubscriber: arguments: $projectDir: '%kernel.project_dir%' @@ -211,6 +222,10 @@ services: arguments: $providers: !tagged_iterator { tag: system.backend_view_provider } + App\Core\AdminAcl\AdminFeatureRegistry: + arguments: + $providers: !tagged_iterator { tag: system.admin_feature_provider } + App\View\Injection\ViewInjectionRegistry: arguments: $staticProviders: !tagged_iterator { tag: system.static_view_injection_provider } @@ -249,6 +264,20 @@ services: arguments: $providers: !tagged_iterator { tag: system.acl_group_reference_provider } + App\Security\RateLimit\RateLimitLimiterFactory: + arguments: + $cachePool: '@cache.rate_limiter' + $lockFactory: '@lock.factory' + + App\Security\RateLimit\RateLimitRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + $projectDir: '%kernel.project_dir%' + + App\Security\RateLimit\RateLimitAuthenticationSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' @@ -372,7 +401,33 @@ services: alias: App\Core\Log\AccessLogger App\Core\Geo\GeoIpResolverInterface: - alias: App\Core\Geo\NullGeoIpResolver + alias: App\Core\Geo\GeoIpResolver + + App\Core\Geo\GeoIpResolver: + arguments: + $providers: !tagged_iterator { tag: system.geoip_provider } + $fallbackProvider: '@App\Core\Geo\NullGeoIpProvider' + + App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface: + alias: App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory + + App\Core\Geo\MaxMindGeoIpDownloadClientInterface: + alias: App\Core\Geo\HttpMaxMindGeoIpDownloadClient + + App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface: + alias: App\Core\Geo\MaxMindGeoIpArchiveExtractor + + App\Core\Geo\HttpMaxMindGeoIpDownloadClient: + arguments: + $httpClient: null + + App\Core\Geo\MaxMindGeoIpProvider: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Core\Geo\MaxMindGeoIpDatabaseUpdater: + arguments: + $projectDir: '%kernel.project_dir%' App\Core\Log\OperationLoggerInterface: alias: App\Core\Log\OperationLogger @@ -407,6 +462,10 @@ services: $cacheDir: '%kernel.project_dir%/var/cache' $environment: '%kernel.environment%' + App\Security\Abuse\AbuseSubjectResolver: + arguments: + $secret: '%kernel.secret%' + App\Core\Security\SecretPayloadProtector: arguments: $secret: '%kernel.secret%' @@ -511,6 +570,8 @@ services: App\View\Http\HttpErrorRenderer: arguments: + $projectDir: '%kernel.project_dir%' + $environment: '%kernel.environment%' $debug: '%kernel.debug%' App\View\Http\HttpErrorSubscriber: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 8bbacd39..c1d07982 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** This document tracks callable entry points (services, commands, controllers, Twig components, Stimulus controllers). Keep it up to date as new classes are added or interfaces change. This document is meant to evolve alongside the codebase—treat it as a living index for developers to quickly discover callables without grepping through the project. @@ -60,10 +60,11 @@ | Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic package-aware asset rebuild queue with package asset sync, translation aggregation, Symfony asset commands, UX Translator warm-cache output, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | | Service/commands | `App\Core\Mercure\MercureBinaryManager`, `App\Core\Mercure\MercureRuntime`, `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Provides optional local Mercure hub tooling with a YAML-configured fixed version, fixed OS/architecture asset names for the Caddy-based prebuilt hub, SHA256-pinned release archive downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, Bolt transport storage at `var/mercure/updates.db`, release-provided Caddyfile startup, JWT secrets passed through a protected `var/mercure/mercure.env` file instead of command arguments, non-secret local Caddy/Mercure directives passed through the detached-process environment, relaxed read-only hub reachability diagnostics for `2xx`, `400`, and `401` responses, strict publish-health probes that require a successful authenticated POST, colon-only local listen and configured hub URL normalization for probe URLs, Mercure-fingerprinted public EventSource subscribe health probes, best-effort macOS quarantine release, a `bin/mercure` wrapper, publish self-healing, public-endpoint failure shutdown, read-only diagnostics, PID-first plus exact-binary process detection, OS-aware stop support that waits for the tracked PID and exact-binary fallback processes to disappear, disabled-integration health no-op success, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | Service | `App\Core\Output\JsonOutputRenderer` | Shared raw JSON response renderer for `/api/live/**` UI flows, captcha seeds, polling, and future small JSON endpoints. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Output/JsonOutputRendererTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver` | Shared segment-bound path-scope matchers for raw technical route scopes and request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling, request-scoped authenticated or anonymous API context, read-only method gating, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | -| Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | +| Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler and Admin ACL feature states, secret-redacted settings API output, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and Admin ACL-gated package lifecycle review/confirmation endpoints that start LiveOperation runs only after the caller is authorized. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | | Content API | `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler` | Provides collision-free content API dynamic resources below `items/`, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content collection pagination/filtering/sorting after ACL filtering, deferred non-published status read surfaces, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php` | | Package/user API | `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackagePathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, package read/navigation resources, package API path-pattern validation below the owned package namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php` | | Service | `App\Core\Lint\CssLinter` | Reusable string-based CSS syntax linter using the strict Sabberworm CSS parser. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | @@ -86,6 +87,7 @@ | Enum | `App\Core\Access\AccessLevel` | Shared 0-9 access-level constants and validation for public, user, moderator, author, publisher, curator, manager, director, admin, and owner role tiers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\Access\AccessResolver` | Resolves inherited level-plus-group ACL rules for actors and capabilities. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php` | | Value object | `App\Core\Access\AccessRule` | Value object for explicit or inherited min-level plus group ACL rules, including the shared stored-group identifier normalization used by content, schema, and menu ACL fields. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Core/Access/AccessRuleTest.php` | +| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState`, `App\Api\Admin\AdminFeatureApiGuard`, `App\Backend\AdminOperationFeatureResolver` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, denied/visible/mutable state evaluation, API-key-owner feature checks, operation-continuation target-feature resolution, and redacted matrix-change audit summaries used by settings forms, backend actions, package/theme UI/API callers, user/ACL/review workflows, scheduler/operations/log APIs, dynamic package settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | | Interface | `App\Security\AccessLevelAwareUserInterface` | Symfony user bridge for security users that expose the project's role-derived ACL access level. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Enum | `App\Security\UserRole` | Global account role enum that maps one exact user role to inherited Symfony roles and numeric ACL access levels. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserGroupMembershipManager`, `App\Security\AccountReactivationAccessResolver` | Shared user-group membership and deleted-account reactivation helpers used by admin and account-link flows so controllers do not duplicate group replacement, existing-group filtering, or role-preserving reactivation decisions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | @@ -97,16 +99,16 @@ | Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, and cache clearing, with shared CSRF validation, translated flashes, JSON operation-start responses, and audit logging for controller adapters. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | -| Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | +| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and optional Admin ACL feature metadata so visible-only actions render disabled while backend execution still rechecks mutability. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | +| Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions; package lifecycle callers are now gated by the thematic `admin.packages` Admin ACL feature before UI action rendering, API confirmation, or LiveOperation start. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | -| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | -| Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Own focused Admin package install/detail/lifecycle and Admin Operations maintenance/detail/continuation routes that previously lived in the dynamic backend dispatcher. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider`, `App\Backend\CoreAdminSettingsBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the Admin Settings tree through a dedicated provider plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\View\Twig\AdminSettingsFormViewFactory`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, ACL-aware operation action states, mutable-only Audit/Security Signal log source exposure, database-backed pagination/filtering/sorting, focused settings/package form view models for Twig, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController`, `App\Controller\AdminUserController`, `App\Controller\AdminAclGroupController`, `App\Controller\AdminUserReviewController`, `App\Controller\AdminUserInvitationController` | Own focused Admin package install/detail/lifecycle, Operations maintenance/detail/continuation, user management, ACL-group, invitation, and review routes with thematic Admin ACL feature enforcement before mutation or sensitive reveal; visible-only states keep expected UI controls rendered disabled where the workflow layout depends on them. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | | Service | `App\Core\Id\UuidFactory`, `App\Core\Operation\Live\LiveOperationHttpResponder` | Generates Symfony UID-backed UUIDv7 identifiers for controller-created records and renders LiveLog operation start responses with follow-up status URLs. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Service | `App\Core\Console\ConsoleResultRenderer` | Renders workflow result issues/messages, JSON payloads, and status/WorkflowResult exit codes for console commands. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Console/ConsoleResultRendererTest.php` | -| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides project-wide CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | +| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides development/test-only CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, request headers, response-header output, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | | Event subscriber | `App\Backend\BackendNavigationSubscriber` | Adds registered backend view route-target navigation entries through the shared navigation hook so packages can later contribute menu items through the same boundary. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Navigation/NavigationBuilderTest.php` | | Registry | `App\View\Injection\ViewInjectionRegistry`, `App\View\Injection\StaticViewInjection`, `App\View\Injection\ConfigurableStaticViewInjectionSet`, `App\View\Injection\ConfigurableStaticViewInjectionRoute`, `App\View\Injection\DynamicViewInjection`, `App\View\Injection\DynamicViewInjectionFilter`, `App\View\Injection\ViewSurface`, `App\View\Injection\DynamicViewInjectionSlot` | Collects package-facing static route/menu injections, package-setting-backed configurable static route sets, and content-aware dynamic slot or variant-route injections for the `public`, `admin`, and `editor` surfaces. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/DemoControllerTest.php` | | Event payload | `App\View\Injection\Event\StaticViewInjectionRegistryEvent`, `App\View\Injection\Event\DynamicViewInjectionRegistryEvent` | Public mutable hooks that let packages add static and dynamic view injections before route, menu, slot, or variant resolution. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Event/PublicEventHookRegistryTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php` | @@ -128,9 +130,9 @@ |------|--------|---------|------|-------| | Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, and Security audit policy controls. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php` | +| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit/signal policy controls, Log Settings database-retention controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | +| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, password inputs for sensitive settings, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | | Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | | Value object | `App\Core\Message\Message` | Universal message value object carrying log level, code, translation key, parameters, and context for logs, output, validation, future localization, and invalid-argument diagnostics. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageTest.php` | | Registry | `App\Core\Message\MessageCode`, domain-owned `*MessageCode` catalogues | Aggregates core-owned machine-readable message code catalogues while keeping constants close to their owning domains and leaving room for validated package-owned catalogues. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageCodeTest.php` | @@ -192,14 +194,17 @@ | Event payload | `App\Core\Package\Event\PackageAssetSyncCompletedEvent` | Public observe hook dispatched with asset sync metrics after generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | -| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpResolver` | Replaceable GeoIP lookup boundary used by access logging and access statistics; the initial null provider returns normalized `n/a` location values without external dependencies or hard failures. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Geo/NullGeoIpResolverTest.php` | +| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | -| Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | -| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates and audits established sessions that reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogEntryPresenter`, `App\Core\Log\LogPagination` | Parses Monolog line output and provides a small Admin Logs facade over known source discovery, reverse line reading, filtering, entry IDs/summaries, pagination, and filter option models. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | -| Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | +| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, and cached configurable suspicious probe path patterns while avoiding non-existent contact/captcha path assumptions, assigns symbolic action costs without enforcing limits, and records clear passive signals for high-signal probes and unsafe prefetch attempts. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php` | +| Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\State\StateMarkerRecorder` | Upserts current/last lifecycle state markers through Doctrine so marker writes stay atomic with the owning mutation, and reads ordered marker summaries for detail views such as Admin User account history. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/State/StateMarkerRecorderTest.php`, `tests/Controller/UserControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | @@ -216,8 +221,8 @@ | Interface/service | `App\Setup\SetupCommandExecutorInterface`, `App\Setup\ProcessSetupCommandExecutor` | Abstraction and default Symfony Process executor for setup subprocess calls so CLI and web setup share command execution behavior, including local Composer environment fallback values and web/CGI environment filtering. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/ProcessSetupCommandExecutorTest.php` | | Value object | `App\Setup\SetupCommandResult` | Value object for setup subprocess exit code, output, and error output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupDatabaseConnectionFactory` | Creates DBAL setup connections from Doctrine database URLs across SQLite, MySQL/MariaDB, and PostgreSQL, including setup-time Symfony placeholder resolution and platform-safe SQLite path handling. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDatabaseConnectionFactoryTest.php`, `tests/Setup/SetupPasswordResetRunnerTest.php`, `tests/Setup/SetupRunnerTest.php` | -| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | -| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | +| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, sensitive GeoIP2 credential defaults, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | +| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS and GeoIP2 defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | | Service | `App\Setup\SetupDryRunPlanner` | Builds setup dry-run ActionLog steps from the shared default seed, describing planned writes, commands, settings, admin seed data, cache clearing, package discovery/rebuild subprocesses, and completion marking without mutating state, while keeping PHP CLI command planning non-throwing when no usable CLI binary is available. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service/value object | `App\Setup\SetupEnvironmentWriter`, `App\Setup\SetupEnvironmentSnapshot` | Writes setup environment overrides into `.env.{APP_ENV}.local` while preserving unrelated keys, and snapshots pre-existing env files so setup rollback can restore unrelated configuration instead of deleting it; snapshot restore failures are reported in rollback context without hiding the original setup error. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupEnvironmentSnapshotTest.php` | | Value object/service | `App\Setup\SetupInput`, `App\Setup\SetupInputNormalizer`, `App\Setup\SetupInputValidator` | Callable setup input DTO for installer language, site metadata, database connection data, admin credentials, APP_SECRET handling, and dry-run mode, plus shared CLI/web normalization and validation for database drivers, database URLs, table prefixes, boolean flags, default admin email values, supported languages, default URI, admin credentials, and APP_SECRET length. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupCliInputFactoryTest.php`, `tests/Setup/SetupWebInputFactoryTest.php`, `tests/Setup/SetupInputValidatorTest.php` | @@ -259,7 +264,7 @@ | Enum | `App\Content\Routing\ContentRedirectTargetType` | Enum for redirect target kinds such as internal route and external URL. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRedirectResolverTest.php` | | Value object | `App\Content\Routing\ContentRoutePath` | Value object for normalized content paths with optional trailing generic variant marker extraction. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Read/PublishedContentResolverTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentRedirectTest.php` | | Value object | `App\Content\Routing\ContentSlug` | Value object for strict lowercase ASCII content slug validation. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentSlugTest.php` | -| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes such as `admin`, `editor`, `setup`, `cron`, `system`, and `user`, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | +| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes including app, API, generated asset, profiler/toolbar, media, package, and system namespaces, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | | Value object | `App\Content\Routing\ContentSystemRoute` | Defines the root parent sentinel plus the internal-only `/system/...` content namespace and virtual parent marker. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Entity/ContentItemTest.php`, `tests/Content/Read/PublishedContentResolverTest.php` | | Value object | `App\Content\Schema\ContentSchemaField` | Constants for reserved required base field identifiers that every content schema must define. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Content/Schema/ContentSchemaFieldTest.php` | | Enum | `App\Content\Schema\ContentSchemaSource` | Enum for schema sources such as preset, custom, and module. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Entity/ContentSchemaTest.php` | @@ -315,14 +320,14 @@ | Routes `backend_admin_users`, `backend_admin_deleted_users`, `backend_admin_deleted_users_cleanup`, `backend_admin_deleted_user_activate`, `backend_admin_deleted_user_deactivate`, `backend_admin_user_detail`, `backend_admin_user_password_reset` | `App\Controller\AdminUserController` | Admin account list/detail HTTP adapter for searchable/paginated non-deleted accounts, retained deleted-account inspection, retention cleanup, account lifecycle marker summaries, filtered Audit Log links, status/role/group forms, and password-reset actions; assignment options, update mutations, deleted-account status changes, and reset-token creation live in Security services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_groups`, `backend_admin_user_group_detail`, `backend_admin_user_group_delete` | `App\Controller\AdminAclGroupController` | Admin ACL group routes with `details/` dynamic paths for searchable/paginated ACL groups, group detail member summaries, create/update/delete actions, hierarchy guardrails, impact review before updates/deletes, and LiveLog-backed group apply. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_reviews`, `backend_admin_user_review_reactivate`, `backend_admin_user_review_delete` | `App\Controller\AdminUserReviewController` | Admin review queue routes with `details/` disputed-account actions for contextual registration/invitation/dispute rows plus disputed-account reactivation and confirmed delete actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserReviewControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | -| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | +| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, token-state-derived review/user ACL selection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_api_keys`, `user_api_key_reveal`, `user_api_key_revoke` | `App\Controller\UserApiKeyController` | Authenticated user API-key routes for generation, revocation, and password-confirmed reveal of encrypted key material. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/UserApiKeyControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_register`, `user_invitation_accept` | `App\Controller\UserRegistrationController` | Public registration and invitation routes for disabled/admin-approval/auto-approval registration, existing-account notices, optional default registration groups, deleted-account reactivation with token role/group reset, and invitation/registration token acceptance through a Security-owned mutation service. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | | Routes `user_reset_password`, `user_password_reset_token`, `user_security_review` | `App\Controller\UserPasswordRecoveryController` | Public password recovery and security-review routes for non-enumerating reset requests, reset completion, password-change review links, and password-change dispute locking. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | -| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter for language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter whose pre-completion ordinary wizard navigation is skipped by rate-limit enforcement except static suspicious-probe matching, while the final `POST /setup/review` apply submission may consume the setup-apply limiter before the controller handles language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | | Routes `backend_admin_index`, `backend_admin_route`, `backend_admin_log_detail`, `backend_editor_*` | `App\Controller\BackendController` | Native backend/editor dispatcher for area route resolution, area access checks, route messages, backend navigation context, generated Admin Settings submissions, and Admin Log detail rendering. Focused package and operation routes live in dedicated Admin controllers. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Routes `backend_admin_package_*`, `backend_admin_operation_*` | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Focused Admin package install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_admin_package_*`, `backend_admin_operation_*` | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Focused Admin package install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes that require both mutable operations access and mutable target-domain access before starting continuation work. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | | Routes `api_live_operation_status`, `api_live_operation_continue` | `App\Controller\LiveOperationController` | Public but token-protected JSON endpoints for live ActionLog operation state and provider-declared review continuations below the reserved `/api/live/**` internal API branch. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | | Route `api_live_package_dispatch` | `App\Controller\LiveEndpointController` | Dispatches package-owned live endpoint definitions below `/api/live/{package_slug}/...` while system routes keep priority for reserved live branches, endpoint minimum access levels are enforced before handler execution, and pattern matches are rejected when the matched route slug does not belong to the endpoint owner. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php` | | Route `privacy_cookie_consent` | `App\Controller\CookieConsentController` | Validates visitor-bound stateless cookie-consent form tokens, stores selected optional cookie consent in a signed long-lived required cookie, expires optional cookies whose consent was withdrawn, and redirects back only to safe relative paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Privacy/Cookie/CookieConsentManagerTest.php` | @@ -367,7 +372,7 @@ | Macro registry templates | `templates/macros/**/*.html.twig` | Namespaced native Twig macro templates and aggregator entrypoint. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | | Package template path resolver | `App\View\Template\PackageTemplatePathResolver`, `App\View\Template\PackageTemplatePathConfigurator` | Builds and registers deterministic Twig namespace path order for active packages through the central package gate, including `@frontend`, `@backend`, `@root`, `@provider`, root override protection through `system-template`, provider fallback ordering, and Console registration before UX icon locking or AssetMapper cache warming scans templates. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Template/PackageTemplatePathResolverTest.php`, `tests/View/Template/PackageTemplatePathConfiguratorTest.php` | | Provider templates | `templates/provider/{captcha,editor}/*.html.twig`, `templates/frontend/partials/forms/fields/captcha.html.twig`, `templates/backend/editor/fields/richtext.html.twig` | Native provider fallbacks and area stubs for optional captcha and editor-provider rendering through `@provider`, with CodeMirror as the base editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/View/Template/PackageTemplatePathConfiguratorTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Renders recoverable HTTP errors through system content, frontend error templates, default error fallback, or anonymous-login `401` response. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | +| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Provides the browser error-page resolver entry point through `HttpErrorRenderer::resolve()`, rendering recoverable HTTP errors through system content, frontend error templates, default error fallback, anonymous-login `401` response, or forced/minimal bare HTML, with central `no-store` cache headers for rendered error pages and DB-free minimal HTML responses for all known `4xx`/`5xx` statuses before setup completion. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/View/Http/HttpErrorRendererTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | | Frontend error pages | `templates/frontend/error-pages/*.html.twig` | Native frontend-scoped fallback templates for HTTP error pages including lightweight `429` and `503`. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | N/A | | Live polling controllers | `assets/js/live/live_poll.js`, `assets/controllers/live_poll_controller.js`, `assets/controllers/operation_overlay_controller.js` | Provides a reusable live JSON polling primitive and Stimulus controller for `/api/live/**` endpoints with automatic polling, immediate `has_more` page draining when cursors advance, optional recoverable-error retry for fallback channels, `next_poll_ms: 0` manual-mode support, and one-shot `live-poll#poll`/`live-poll#refresh` actions while operation forms surface progress through notification-center runner alerts, keep the triggering button disabled while running, automatically run the OK/redirect/reload action shortly after successful operations unless the ActionLog overlay is opened, remap warning or failed triggers to details, and open the ActionLog overlay only on demand for details, continuation, retry, close controls, non-terminal hide controls that keep polling alive, and reusable running-alert detail actions that can reopen the overlay after it was hidden. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs`, `tests/Controller/LiveOperationControllerTest.php` | | UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\WorkflowResultAlertSelector`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `App\Command\UiAlertInboxCleanupCommand`, `App\Controller\LiveAlertController`, `assets/controllers/alert_stack_controller.js`, `assets/controllers/ui_alert_stream_controller.js`, `assets/controllers/ui_alert_poll_controller.js`, `assets/js/alerts/*.js` | Renders server-created, translated, client-created, Mercure-pushed, or polling-delivered UI alerts through one `addAlert()` interface with explicit `Direct`, robust `Queue`, and volatile low-level `Push` delivery modes, success-preserving workflow-result alert selection, DB-backed user/session topic inbox that only accepts system-owned UI-alert URN topics and stores bounded HMAC topic keys with setup-completion gating, canonical UID topics from account entities, account UIDs, or case-preserved resolvable usernames, portable append success reporting without sequence-specific insert IDs, paginated catch-up cursors, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, private-subscription authorization cookies for rendered alert stream topics, paginated inbox catch-up drains before stream connection, on stream open/reconnect, and during polling fallback, existing session-cookie topics, transient-failure retries, session-scoped sessionStorage-backed notification center with badge counts, visible no-JavaScript server-rendered alerts, silent stored-alert hydration that does not hide fresh server-rendered flashes, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, sanitized quiet text/link actions, presentation modes, and optional titles/actions/loading state. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/View/Alert/UiAlertDispatcherTest.php`, `tests/View/Alert/UiAlertInboxTest.php`, `tests/View/Alert/WorkflowResultAlertSelectorTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c153dfee..c0f8dd68 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -66,88 +66,75 @@ - [ ] Editor/API follow-up: when the final content/editor model lands, replace provisional API content list filtering with a domain-owned actor-aware content list/read resolver covering canonical paths, language, variants, optional version selection, pagination, filtering, and sorting. - [ ] Before production readiness, review public package/developer-facing class, interface, function, and Twig helper names for clarity and ergonomics; decide whether to rename directly or provide stable aliases so extension APIs read as intentional rather than provisional. - [ ] Audit follow-up: add a durable package lifecycle operation journal/coordinator for multi-step activation, deactivation, install, rollback, and cleanup flows. -- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding intentionally covers visitor changes, not complete cookie-pair duplication. +- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding now records visitor changes as high-risk signals, but still does not detect complete cookie-pair duplication. - [ ] Audit follow-up: implement remember-me with Symfony-style persistent server-side tokens, visitor binding, explicit revocation, token rotation, and audit signals in the Security branch. - [ ] Audit follow-up: replace the debug account-link mail/message-log delivery stub with the real Mailer delivery contract and a dedicated Mail Message/API catalogue. +- [ ] Security follow-up: define and test production HTTP security-header policy, including CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and documented route exceptions. +- [ ] Frontend-delivery follow-up: change custom system error-page rendering so `/system/error-pages/{status}` resolves a content entity for the inner error-page body/fieldset, then lets the status-specific error template decide the full page chrome. The current renderer sends custom error entities through the normal frontend content entity template, which is too rigid for lightweight `400`/`429` responses versus full `404` pages. +- [ ] Security/Admin ACL follow-up: add explicit Owner/configurable ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions across Admin UI, Admin API, Operations, and service boundaries. +- [ ] Audit follow-up: split the remaining large Admin ACL-adjacent controllers and API handlers along route/action boundaries when those domains are touched next; this slice already extracted the new matrix/form construction, while broader splits for `BackendController`, Admin user/ACL/package controllers, package APIs, operation/scheduler APIs, and the pre-existing large user ACL/review API handlers would be safer as a dedicated behavior-stable refactor. +- [ ] Editor/Content/Config follow-up: warn non-blockingly when a proposed route or slug would match a configured suspicious probe path, so legitimate content remains possible but accidental high-signal probe namespace collisions are visible before publication. +- [ ] Captcha/rate-limit follow-up: add a short-lived opaque 429 recovery context when real captcha challenges are wired, so verified provider-backed solves can reset only the whitelisted/resettable descriptor and subject scope that produced the rendered 429 without exposing bucket IDs, subject keys, IP data, or limiter internals. - [ ] Audit follow-up: decide whether optional branding packages need capabilities beyond `system-template`; package CSS class namespace validation is now enforced for package-owned selectors. - [ ] Evaluate whether the documented minimum memory requirement should become 256M after PHPUnit 13.2/full-suite runs needed a higher CLI memory limit; do not fix this requirement until setup/init/lint/runtime memory behavior has been reviewed across target hosting platforms. ## Branch Logs **Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place new entries chronologically under the matching branch/date heading so reviewers can follow the PR context without reading full verification transcripts. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. -### 2026-06-13 feat-symfony-ux-integration -- Added namespace-aware Twig component primitives for root, frontend, backend, and package-adjacent UI surfaces, then wired shared alert stacks, buttons, page headers, empty states, chart/map wrappers, and form field enhancements without removing the override-friendly partial entry points. -- Added reusable Stimulus/JS foundations for live polling, filter forms, dialog, clipboard, disclosure, tabs, notification-center behavior, Mercure alert streams, and manual one-shot polls; applied the filter/dialog/clipboard pieces to existing Admin logs/statistics/users/package/API-key surfaces where useful. -- Reworked UI alerts into a unified dispatcher and notification center with direct, queued, and low-level push delivery modes; request-time alerts, DB-backed inbox fallback, Mercure best-effort push, polling fallback, titles, actions, loading state, and `auto`/`hidden`/`persistent` presentation now share one public `addAlert()` path. -- Added a package-owned `/api/live/{package_slug}/...` endpoint registry and dispatch boundary for lightweight GET-only polling/manual interactions such as future captcha seed reloads. -- Added a package-extendable cookie-consent foundation with duplicate-name rejection, stateless public CSRF, consent-aware cookie helpers, optional-cookie withdrawal expiry, DNT/GPC-aware defaults, and a reusable overlay that can be reopened from later privacy/footer links. -- Added optional local Mercure tooling with installer/start/stop/health/check commands, fixed versioned Caddy-based release assets, `var/mercure` storage, read-only diagnostics, public subscribe probes, publish probes, setup seeding, scheduler health refresh, and graceful polling fallback when Push is unavailable. -- Switched local Mercure signing to derive from the validated/generated `APP_SECRET` by default, moved local hub secrets out of process arguments, required a 32-byte `APP_SECRET`, and made unsupported short app secrets fail before the app-secret rotation recovery flow can mark them as healed. -- Removed unused Alpine and ApexCharts wiring now covered by Symfony UX packages, kept UX assets lazy, repaired demo package/theme CSS validation, and clarified that `asset-map:compile` is production/release-only. -- Bounded large log reading from the end of the file to avoid Admin log timeouts on large application logs. -- Updated the design-system draft, Mercure web-server notes, class map, and related translation/catalogue entries for the new UI alert, live polling, component, cookie-consent, Mercure, and Symfony UX foundations. - -### 2026-06-14 feat-symfony-ux-integration -- Addressed review findings around live endpoint access and registration by making package live endpoints GET-only, enforcing minimum access levels before handler dispatch, reserving system live slugs, and preferring exact endpoint paths before broad pattern matches. -- Applied the same exact-before-pattern selection to regular API endpoint dispatch and split the oversized API class-map entry into focused API foundation/security, endpoint registry/documentation, admin/settings, content, and package/user rows. -- Folded `ui_alert_inbox` into the pre-`1.0.0` baseline migration and hardened prefixed index/constraint naming for alert-inbox schema objects. -- Hardened alert delivery and storage by scoping notification-center storage by user/session/surface, preserving closed-alert dedupe, giving queued/pushed alerts stable fallback IDs, and making explicit alert-inbox cleanup failures return a failing command status. -- Tightened cookie consent behavior by making rejection work without JavaScript, avoiding anonymous session creation from hidden CSRF tokens, rejecting duplicate consent definitions, and expiring withdrawn optional cookies. -- Kept profile views on cached Mercure availability only, required authenticated publish success for Mercure health, and kept setup/profile paths from starting or installing Mercure implicitly. -- Switched local Mercure downloads from deprecated legacy assets to the Caddy-based `mercure_{OS}_{ARCH}` archives, used the release Caddyfile plus protected env file for secrets, normalized Windows paths, and kept PID plus exact-binary process detection for start/stop/check diagnostics. -- Clarified Mercure public URL/reverse-proxy expectations in the web-server manual while keeping local checks precise enough to distinguish Symfony fallback responses from real Mercure SSE endpoints. -- Replaced committed Mercure JWT defaults with `${APP_SECRET}`, removed setup-time `MERCURE_JWT_SECRET` generation, and kept app-secret rotation naturally coupled to the default Mercure JWT key unless an operator explicitly configures a dedicated JWT secret. -- Deferred Mercure startup out of `bin/init`, made setup stop stale local hubs before health recovery starts them with persisted setup secrets, and coupled app-secret rotation recovery to Mercure stop/health refresh so local hubs do not keep stale signing keys. -- Stripped diagnostic message context from UI alert serialization so UI payloads expose only display-safe alert fields. -- Bound cookie-consent CSRF tokens to the existing visitor identity and hardened package live/API path-pattern guards plus live dispatch route-slug checks so package-owned endpoints cannot escape their namespace through broad regex patterns. -- Hardened late review edge cases for UI alerts, Mercure health, and setup copy by notifying only for newly created alerts, keeping server-rendered flashes visible during storage hydration, retrying transient alert-poll failures, treating disabled Mercure health as a configured success, and deriving setup secret browser constraints from the shared validator. -- Hardened queued alert fallback by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions. -- Hardened follow-up review edges by removing PostgreSQL-sensitive `lastInsertId()` dependency from queued alert appends, making initial server-rendered alerts visible without JavaScript, normalizing colon-only Mercure listen addresses for local probes, and restoring timed removal of transient auto alerts without marking them as manually closed. -- Extended the Mercure colon-only listen hardening to configured hub URLs so `.env`-derived `http://:3000/.well-known/mercure` values normalize before publish/public probes. -- Hardened cookie consent and alert dispatch follow-up edges by clearing all rejected optional cookies even without stored consent, preserving clear-cookie response headers for rejected cookies, enforcing registered cookie identity in the consent jar, skipping topic-specific Mercure publishes while unavailable, and adding a root Twig-component namespace smoke test for `root:*` components. -- Tightened production-readiness edges by SHA256-pinning Mercure release archive downloads, rejecting custom consent cookies that change registered security attributes, signing and TTL-validating consent cookies, and covering safe relative consent redirects. -- Added lightweight native `node --test` JavaScript behavior testing through `bin/jstest` without a `node_modules` dependency tree, with first coverage for alert payload normalization and live polling cursor/retry/error behavior. -- Expanded JavaScript behavior coverage with a small test-only fake DOM and Stimulus controller loader for stable controller contract tests around clipboard/dialog/disclosure/tabs/filter forms, cookie consent, alert stack behavior, alert polling, and Mercure stream reconnect handling. -- Hardened final review edges by verifying stored Mercure PIDs against the exact binary before termination, avoiding parallel alert stream/poll delivery while adding one-shot stream catch-up from the inbox and stable Mercure event IDs, remembering auto-dismissed alert IDs, constraining package-owned necessary cookies to package-scoped host-only names, and marking Mercure unavailable when app-secret rotation cannot safely stop the local hub. -- Hardened cookie-consent package review edges by rejecting duplicate or core-reserved package cookie definitions during package loading and validating optional-cookie privacy links before they can render in the public consent UI. -- Hardened follow-up setup, redirect, alert-inbox, and naming edges by passing the persisted setup `APP_SECRET` as the Mercure setup health JWT secret, rejecting backslash/control-character local redirect targets, storing queued alert topics as bounded HMAC keys, and renaming the consent cookie to a system-owned name. -- Replaced host-derived UI-alert Mercure topic URLs with system-owned URN topics so alert transport identifiers no longer consume HTTP route namespace or depend on `DEFAULT_URI` length. -- Hardened additional review edges by running consent cookie filtering after response cookie writers, standardizing queued user alert topics on canonical account UIDs with username-to-UID normalization when resolvable, and rejecting package live endpoint root paths that cannot be routed by `/api/live/{packageSlug}/{resourcePath}`. -- Hardened follow-up alert edges by preserving username case during username-to-UID topic resolution. -- Removed the native browser notification opt-in and `symfony/ux-notify` dependency because UX Notify only works while a page keeps an active Mercure/EventSource stream; real closed-browser notifications need a separate future Web Push design. -- Completed another broad review pass across URL/link sinks, browser storage naming, and Mercure secret-file handling; hardened alert action links, package metadata URLs, content redirect targets, filter-form storage names, and protected Mercure env-file rewrites. -- Hardened the alert stream fallback so browsers without `EventSource` support or streams that fail before their first open switch to inbox polling without enabling parallel normal stream/poll delivery. -- Kept operation detail overlays dismissible during running operations by showing close controls in non-terminal states and hiding details without stopping the live poller. -- Hardened additional alert/CSS review edges by draining paginated queued-alert catch-up pages after Mercure stream opens, authorizing rendered stream subscriptions for private alert pushes, and suppressing strict-parser CSS limitations for Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks only when a normalized second parse finds no adjacent syntax error. -- Follow-up hardening pass: removed unused legacy CSS-linter wrapper names after broadening the parser-normalization scope, made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream catch-up, and made Mercure stream views drain queued alerts before connecting while queuing a follow-up drain when the stream opens mid-catch-up. -- Follow-up: when the next feature branch starts, evaluate focused controller foundations for public consent/privacy settings, package live endpoint documentation/navigation, captcha provider/live seed flows, notification preference detail settings, and package-owned webhook/job callback endpoints before adding more broad UI surface. -- Follow-up: add a public privacy/footer trigger for `cookie_consent_trigger_attributes()` so visitors with stored consent can reopen cookie preferences and withdraw or adjust optional-cookie consent. -- Follow-up: evaluate converting high-use backend filters from GET-refresh enhancement to Symfony UX LiveComponent slices with URL-bound writable `LiveProp`s so filter input updates can re-render only the list component while keeping shareable query parameters. -- Follow-up: revisit the full operation overlay controller after the first real UI/UX feature slice; the polling core is now shared, but renderer/storage responsibilities can still be split further when more live consumers exist. - -### 2026-06-12 docs-cleanup -- Refreshed the `.codex` context inventory: marked the branding-neutral naming migration and first readiness audit as completed/historical, removed the obsolete standalone Symfony docs notes, made the framework recap the version-pinned dependency documentation cache, and updated it with current installed-dependency guidance for Symfony 8.1, Doctrine ORM/DBAL, Twig 3.27, Tailwind v4/TailwindBundle, Symfony UX, CommonMark, and PHPUnit 13. -- Extended `bin/lint` into the all-in-one diff linting entry point: it now supports `--diff`, `--diff=`, and `--diff:`, collects staged/unstaged or explicit Git diff files when Git is available, lints extensionless PHP scripts such as `bin/lint`, and runs a non-Markdown Git whitespace check that preserves intentional Markdown hard line breaks. -- Added Markdown parse coverage to `bin/lint` using the existing League CommonMark/GFM dependency so Markdown targets produce a real parse/render smoke-check instead of being reported as unsupported. -- Documented the Git whitespace/Markdown hard-break rule in `AGENTS.md`, updated the `.codex` tool index and class map for the new lint modes, and compacted the old 2026-06-07 API session into `dev/WORKLOG_HISTORY.md`. -- Moved the binding project rules from `.codex/PROJECT_RULES.md` into `AGENTS.md` so architecture, naming, pre-`1.0.0`, database, content-revision, security, and audit rules remain available when Codex project context changes or the `.codex` notes are not loaded. -- Removed the obsolete `.codex/PROJECT_RULES.md` duplicate, updated `.codex/ENVIRONMENT.md` for the new `/Volumes/Projekte/studio` checkout path, and refreshed `.codex/README.md` with active context, historical audit, tool, and cleanup guidance. -- Verified `.codex/resolve_cloud_artifacts.php` reports no cloud conflict artifacts; `.codex/clean_ignored_artifacts.php` dry-run still lists normal ignored generated/dependency directories such as `vendor/`, `var/`, `assets/vendor/`, `translations/runtime/`, and package build outputs, so no deletion was applied. - -### 2026-06-13 docs-cleanup -- Moved route rendering from the `.codex` helper into project code with `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-auth, and API debug context support; removed the obsolete `.codex/render.php` helper and updated render-review references. -- Extended `bin/lint` with `--staged` and `--changed=` while keeping Git-dependent target collection and whitespace checks graceful when Git or a work tree is unavailable. -- Reviewed the newly installed Symfony UX package set, kept optional UX Stimulus controllers lazy, removed generated React/Vue/Icon demo files, and tied committed Mercure defaults to `DEFAULT_URI` and `APP_SECRET` for development while documenting production override expectations. -- Updated the class map, dependency recap, local agent tooling notes, and active worklog/history to reflect the render command, lint modes, Symfony UX baseline, and branch-oriented worklog boundary. -- Changed worklog retention from per-session compaction to branch-scoped archival, restored the current `docs-cleanup` branch context from history, and mirrored the rule in `AGENTS.md`. -- Added Symfony UX icon locking to `bin/init` and the package-aware asset rebuild queue as non-blocking dependency steps, and registered active package template paths for icon/AssetMapper console scans so core and package icon references can be imported locally when Iconify is reachable without breaking offline CI or admin rebuilds. -- Added a local-only Symfony UX icon reference check to `bin/lint` so static Twig icon references fail when the required locked SVG is missing, without running the mutating network-backed `ux:icons:lock` command. -- Documented that locked SVGs in `assets/icons` should be committed as reviewable dependency snapshots while avoiding bulk-locking complete upstream icon sets by default. -- Declared `ext-sodium` as a direct Composer platform requirement and added it to the PR verification runner because the Symfony Mercure/JWT dependency chain requires `lcobucci/jwt`, which requires Sodium. -- Clarified `AGENTS.md` wording around session notes with branch/PR context and the boundary between agent-only `.codex` helpers and project-wide tooling. -- Normalized `AGENTS.md` wording so the document reads as a standalone first-version guide rather than as a patch over earlier agent habits. -- Disabled UX Translator TypeScript type dumps in production because the current AssetMapper setup uses JavaScript, not TypeScript, and recorded the UX Turbo 3.1 stream-listen deprecation in the dependency recap. -- Added cache warmup to `bin/init` and `ux:translator:warm-cache` to the package-aware asset rebuild queue so `var/translations/index.js` exists before AssetMapper resolves `assets/translator.js`. +### 2026-06-18 feat-security-rate-enforcement +- Added binding review-fix rules to `AGENTS.md`: review findings must be traced across adjacent and analogous boundaries before changes, fixed at the narrowest central boundary where practical, kept simple/modular/minimally invasive, checked for unreported neighboring edge cases, and covered with regression tests or documented reasoning for inspected-but-unchanged analogous paths. +- Added binding PR-readiness audit rules to `AGENTS.md`: readiness checklist items must be reviewed as evidence-backed audit passes over the branch diff and affected runtime surfaces, including security/privacy, entry points, sessions/secrets/storage, module boundaries, route/API/live scopes, setup/init/CI, cross-platform and disabled-feature behavior, process/env handling, default seeds, translations/copy, drift, documentation, and captured follow-ups. +- Addressed follow-up rate-limit review findings: pre-setup ordinary wizard navigation remains skipped, but the final `POST /setup/review` apply action can now consume the DB-ready/default-backed setup-apply limiter before setup completes, and the scheduler interval intent/bucket is scoped to the exact `/cron/run` route so `/cron/*` misses cannot poison legitimate scheduler triggers. +- Hardened pre-setup HTTP error rendering: all known `4xx`/`5xx` statuses rendered through the shared browser error renderer now return minimal DB-free HTML `no-store` responses with status text and a Request ID resolved through `AccessRequestMetadata` before setup completion, avoiding custom content/error-page rendering while the database may be unavailable; pre-setup rate-limit/probe `400`/`429` responses reuse that same bare renderer path. +- Added the public `HttpErrorRenderer::resolve()` entry point for browser error-page resolution and migrated existing browser error triggers from direct render calls to that single resolver path; API JSON error rendering remains separate through the API responder, and callers can force the minimal bare response for future block surfaces such as auto-ban. +- Addressed follow-up rate-limit review findings: all non-empty `Authorization` API preflights now classify by `Access-Control-Request-Method` for rate-limit buckets so non-Bearer credentialed preflights cannot bypass write/admin budgets, and submitted-account workflow buckets now consume local Visitor/IP guards before account/email subjects so locally blocked clients cannot poison other users' shared login, registration, or password-reset buckets. +- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, API CORS, and rate-limit enforcer coverage passed with 89 tests and 474 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; full `php bin/phpunit` passed with 1567 tests and 10342 assertions. +- Addressed follow-up rate-limit review findings: account-token workflows now add HMAC-redacted submitted-account token subjects for `/user/invitation/{token}` and `/user/reset-password/{token}`, scheduler JSON rate-limit responses use segment-bound `/cron` matching so browser content such as `/cronjobs` stays HTML, and rate enforcement now pre-checks all planned descriptor/subject consumes before committing the batch so later global bucket rejections do not spend earlier workflow/account buckets. +- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for subject resolution, limiter factory, enforcer, response renderer, request subscriber, and controller enforcement coverage passed with 90 tests and 928 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; `git diff --check`; full `php bin/phpunit` passed with 1578 tests and 10442 assertions. +- Documented the intentional multi-bucket consume trade-off: the pre-check/commit flow prevents repeatable partial spends and account-bucket poisoning without adding cross-bucket transaction/rollback complexity; the residual concurrent-request race is bounded by per-key limiter locks and accepted as non-practical for repeated unrelated account bucket draining. +- Consolidated API effective-method handling into `ApiRequestMethodPolicy` so API CORS, read-only API key gating, abuse intent classification, and read-only Owner rate-limit exceptions share the same path-bound API v1, Authorization-header, credentialed OPTIONS, and `Access-Control-Request-Method` semantics. +- Added shared segment-bound `PathScopeMatcher` routing helper and moved API v1 detection plus rate-limit technical exclusions/JSON response-surface checks onto raw technical path matching so `/api/v10`, `/cronjobs`, `/_wdtfoo`, localized public lookalikes, and similar paths do not inherit protected path behavior by raw prefix accident. +- Moved rate-limit enforcement-stage eligibility and subject-selection policy onto bucket descriptors through `RateLimitSubjectPolicy`, keeping login/auth-failure, recovery-render, API/admin auth-failure, scheduler credential/IP anchoring, submitted-account workflows, and authenticated multipliers centrally declared with the bucket policy instead of duplicated in the stage enum and selector. +- Added HMAC-redacted submitted-account token subjects for `POST /user/security-review/{token}` and route-attributed localized security-review posts, matching the existing invitation/reset token workflow handling so leaked review-token submissions share the intended password-reset limiter bucket across visitors/IPs. +- Added shared `RequestPathResolver` request-segment resolution with gated URL locale-prefix stripping for locale-prefix UI/account scopes; API/Cron/Setup/static technical scopes remain raw prefixless route scopes that still use the resolved request locale, while access-log surface detection, request-intent classification, scheduler credential scoping, and submitted-account workflow subject detection share exact path-part semantics. +- Addressed follow-up rate-limit review findings: localized Cron/API lookalike paths no longer spend scheduler/API buckets or receive scheduler/API JSON responses, adjacent API v1 and scheduler guards now use segment-bound helpers instead of raw `str_starts_with()` prefixes, descriptorless Admin/Editor navigation now falls back to the global website buckets, and suspicious-probe handling runs before package loading with a forced minimal `400 Invalid Request` response while preserving passive probe signal recording. + +### 2026-06-17 feat-security-rate-enforcement +- Started the rate-enforcement slice after `feat-security-admin-acl-enforcement` merged, archived the completed Admin ACL branch notes into `dev/WORKLOG_HISTORY.md`, and refreshed the active worklog for the new branch. +- Updated `composer.lock` after dependency resolution refreshed `guzzlehttp/psr7` to 2.12.0 and `justinrainbow/json-schema` to 6.10.0. +- Recorded rate-enforcement product decisions: exact `/user/login?bypass=1` recovery path, fail-open limiter-storage degradation, one Owner-gated rate-limit mode setting with `off`/`standard`/`strict`/`panic`, and a dedicated rate-limit policy catalogue that keeps bucket budgets/profile scaling separate from semantic action costs. +- Added the rate-limit policy catalogue, profile scaling, Owner-gated Security setting, descriptor-backed Symfony limiter facade, request subscriber, redacted HTML/JSON `429`/probe `400` responses, fail-open diagnostics, Owner ordinary exemption, authenticated-user multiplier, `/api/live/**`/prefetch exclusions, login-success reset, and the dormant verified-provider captcha reset interface. +- Added a test-environment opt-in header for the request subscriber so legacy functional tests that mutate Security settings or share synthetic visitors are not affected by global limiter state; production and development enforcement are unchanged. +- Verification: focused syntax/lint checks for rate-limit, settings, message, translation, response, docs, and worklog files; focused PHPUnit for rate-limit catalogue/enforcer/reset, Security settings registry/form/API/UI, message catalogues, and HTTP enforcement responses; `php bin/console lint:container --env=test --no-debug`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `bin/lint --diff`; full `php bin/phpunit` passed with 1424 tests and 9326 assertions. +- Hardened review-sensitive rate enforcement details: `/cron/run` is no longer Owner-exempt and now uses explicit scheduler intervals (`standard` 1/minute, `strict` 1/15 minutes, `panic` 1/hour), rate-policy descriptors are generated from user-visible action counts plus unique action-cost multipliers with a single-action profile floor, limiter degradation diagnostics report through the Message layer, and shared rendered HTTP error pages set `no-store` centrally. +- Verification: focused PHPUnit for action-cost catalogue, rate-limit catalogue/enforcer/reset, public error pages, rate-limit response controllers, and message catalogues; full `php bin/phpunit` passed with 1431 tests and 9422 assertions. +- Adjusted suspicious-probe profile scaling so strict/panic extend the probe window while the single-action credit floor prevents Symfony limiter consume failures below the probe action cost. +- Extended `render:route` with request-header input and response-header output, then added CLI route-render coverage proving repeated `/cron/run` renders with Owner context and mutable Owner API key receive scheduler `429` with `Retry-After` and `no-store` instead of bypassing through the Owner exemption. +- Added a production-environment guard to `render:route` so the debug renderer fails closed in `APP_ENV=prod` and remains available only for development/test diagnostics. +- Clarified the scheduler rate-limit policy documentation: `/cron/run` uses an operational pre-auth interval guard, and legitimate scheduler `429` responses in strict/panic modes are not treated as abuse or security signals. +- Addressed first Cloud Review rate-limit findings: auth workflow buckets now charge only unsafe submissions, the limiter runs after routing but before Symfony authentication failures, `/build/**` is excluded with generated assets, active-profile descriptors are used for login/captcha resets, and `/user/login?bypass=1` is wired to the dedicated recovery-login buckets. +- Hardened adjacent route-guard coverage so content routes cannot claim technical/static namespaces such as `/assets/**`, `/build/**`, `/_profiler/**`, `/profiler/**`, and `/_wdt/**` while relying on limiter exclusions. +- Addressed follow-up Cloud Review bypass findings: suspicious probes now run before ordinary `/api/live/**` exclusions, failed login/API credentials charge through `LoginFailureEvent`, ordinary buckets run after Symfony authentication so Owner/API-key subjects and authenticated multipliers are available, login/registration/password-reset buckets include HMAC-redacted submitted-account subjects, invalid API prefixes no longer become primary API limiter subjects, and high-impact Admin upload/download paths are classified before broad package/admin buckets. +- Verification: focused syntax checks, focused PHPUnit coverage for request classification, rate-limit enforcer/reset/controller behavior, render-route cron handling, content route guards, and test seed isolation; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1458 tests and 9679 assertions. +- Addressed additional Cloud Review hardening findings: authentication-failure rate checks now include Admin API mutation/upload/download families, successful login resets the same submitted-account/visitor/IP login keys used by enforcement, persisted Symfony limiter IDs include the active descriptor shape so profile changes do not reuse stale fixed-window state, and consume operations use Symfony's configured lock factory. +- Verification: PHP syntax checks for changed rate-limit classes/tests; focused PHPUnit for limiter factory, reset, controller enforcement, enforcer, and request classification coverage passed with 75 tests and 594 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1462 tests and 9717 assertions. +- Addressed the next Cloud Review rate-limit bypass findings: Bearer-bearing `OPTIONS` requests now classify as API authentication attempts instead of anonymous CORS preflights, recovery-login `GET` renders spend the dedicated recovery bucket while avoiding website buckets, Admin API auth failures add IP anchoring, read-only Owner API-key write denials spend the write/admin bucket before the 403 while read-write Owner keys stay ordinary-exempt, and scheduler intervals key on HMAC-redacted submitted scheduler credentials with IP fallback/secondary anchoring. +- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, subject resolution, rate-limit enforcer/controller enforcement, scheduler controller, and read-only API method coverage passed with 100 tests and 698 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1473 tests and 9820 assertions. +- Tightened recovery-login accounting so only `GET /user/login?bypass=1` uses the recovery render bucket; unsafe bypass submissions remain normal login attempts, and Panic mode explicitly keeps one recovery render plus the first login submission within budget. +- Narrowed ordinary rate-limit technical path exclusions to exact path segments so generated/static prefixes such as `/_profiler` and `/_wdt` do not accidentally cover similarly named public routes. +- Added the missing `admin.settings.*` source/runtime translations for Security settings fields and options touched by this branch so the Owner-gated Security settings page renders localized Captcha, rate-limit, audit, signal-retention, and probe-pattern controls instead of raw translation keys. +- Verification: PHP syntax checks for changed request-classifier/rate-limit classes and tests; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/controller behavior, content route guards, and settings coverage passed with 163 tests and 1364 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `php bin/console render:route '/user/login?bypass=1' --env=test --no-debug --include-status --header 'X-Rate-Limit-Testing: 1'`; full `php bin/phpunit` passed with 1489 tests and 9847 assertions. +- Addressed the latest Cloud Review hardening findings: sensitive recovery-login and Admin export/download/diagnostic `GET` requests now classify before spoofable prefetch forgiveness, derived profile floors now keep two costed ordinary actions available while preserving explicit single-action scheduler/probe interval policies, and suspicious-probe blocking runs before API availability, setup redirect, maintenance, live/API exclusion, and ordinary technical path gates. +- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; `git diff --check`; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/reset/factory behavior, and controller enforcement passed with 116 tests and 909 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console debug:event-dispatcher kernel.request --env=test --no-debug` confirmed probe priority 900 before response-producing gates; full `php bin/phpunit` passed with 1496 tests and 9941 assertions. +- Documented the future cache panic-mode direction in the frontend delivery/caching draft: Security `panic` is the intended coordination point for a bounded TTL lock that can serve anonymous public traffic from safe cache entries during DDoS-like events while preserving auth, ACL, probe blocking, audit, and Owner/Admin recovery behavior. +- Addressed the CORS preflight rate-limit bypass finding: configured anonymous API CORS preflights still return cheap `204` responses, but `OPTIONS` requests with an actual `Authorization` header are no longer short-circuited by CORS and can reach Bearer authentication failure plus API/Admin rate-limit accounting. +- Verification: PHP syntax checks for changed CORS/rate-limit tests and subscriber; focused PHPUnit for API CORS, request classification, rate-limit enforcer/request subscriber, and controller enforcement passed with 104 tests and 725 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1498 tests and 9960 assertions. +- Addressed the read-only Owner Bearer preflight edge: unsafe `Access-Control-Request-Method` values now count as API write attempts for both read-only API-key method gating and the rate-limit Owner exemption, so read-only Owner keys spend write/admin buckets and receive the same denial shape before repeated attempts become `429`. +- Verification: PHP syntax checks for changed API/rate-limit classes and tests; focused PHPUnit for API read-only method gating, API endpoint access/permission, API CORS, request classification, rate-limit enforcer, and HTTP enforcement passed with 112 tests and 790 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1503 tests and 10005 assertions. +- Addressed the malformed Bearer preflight and signed-in scheduler credential rotation edges: empty/whitespace Bearer API `OPTIONS` requests now classify like the API authenticator's Bearer scheme support and spend the matching API/Admin authentication-failure bucket, while scheduler interval buckets keep IP secondary anchoring even when a user session is present so rotating invalid query credentials cannot bypass `/cron/run` from the same source. +- Verification: PHP syntax checks for changed classifier/rate-limit classes and tests; focused PHPUnit for request classification, API CORS/read-only preflights, abuse subject resolution, rate-limit enforcer/request subscriber/reset/factory behavior, scheduler controller, and HTTP enforcement passed with 138 tests and 862 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1508 tests and 10037 assertions. +- Addressed the next rate-limit review findings and setup safety concern: the early probe hook now prechecks paths with a DB-free default matcher before invoking the full enforcer, pre-setup suspicious probes return a bare generic `400 no-store` without content/error-page DB lookups, ordinary setup wizard traffic is skipped until `APP_SETUP_COMPLETED`, authentication-failure checks no longer apply Owner ordinary exemptions, and only the final review-step setup apply submission is classified into the setup-apply bucket. +- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; focused PHPUnit for request subscriber, enforcer, classifier, action-cost, HTTP enforcement, and setup redirect behavior passed with 119 tests and 844 assertions; broader Security rate-limit/abuse/API-CORS/read-only/setup focus passed with 169 tests and 1151 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1514 tests and 10065 assertions. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index b69ed763..e496d5e2 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,44 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-16 to 2026-06-17 feat-security-admin-acl-enforcement +- Implemented the Admin ACL enforcement slice: domain-owned feature registry, denied/visible/mutable states, surface inference from feature keys, seeded Owner-configurable defaults, ACL-group override states, Owner-gated `Settings/ACL` matrix, dynamic active-package settings rows, and feature-matrix caching with explicit invalidation. +- Wired Admin ACL feature checks through protected settings fields, Admin navigation/views, package/theme actions, package lifecycle and settings, GeoIP maintenance, operations continuations, scheduler, logs, statistics, users, user reviews, ACL group management, backup/status surfaces, and Admin API handlers while keeping visible-only controls rendered disabled where layout depends on them. +- Hardened the slice through review rounds: separated ACL-group definition permissions from user group membership mutations, rechecked target-domain ACL for live-operation continuations, applied `admin.settings.security` to all Security settings fields including Captcha, gated the concrete Scheduler web controller, and documented policy decisions that pending account-token review actions use `admin.users.review` and trusted registered Scheduler tasks use `admin.scheduler`. +- Closed the branch with updated translations, runtime catalogues, drafts, class map, worklog notes, focused regression coverage, full PHPUnit, JavaScript tests, lint, and container validation. + +### 2026-06-16 feat-security-abuse-foundation +- Implemented the Abuse Foundation slice: passive security-signal model and recording, request intent/action-cost classification, suspicious probe matching, visitor/IP-bucket evidence handling, configurable probe patterns, session/visitor mismatch signals, and database/file-backed Admin log browsing refinements. +- Hardened the slice through review passes: retention-aware signal/log reads, portable database search, source-aware Admin Log filters and pagination, safe path/token sanitization, locale-aware route classification, cache invalidation for probe patterns, and clearer rate-enforcement handoff policy for future limiter/ban branches. +- Closed the branch with full verification: `bin/phpunit`, `bin/jstest`, and `bin/lint` passed, with only intentional Markdown metadata hardbreaks reported by raw Git whitespace checks. + +### 2026-06-15 to 2026-06-16 feat-security-geoip-observability +- Implemented the GeoIP observability slice: provider-neutral resolver boundary, MaxMind/GeoIP2 local database provider, protected Statistics settings, safe provider diagnostics, Statistics/Admin status rendering, explicit setup defaults, manual Operations-backed database downloads, scheduler callable, and access log/statistics enrichment while preserving `n/a` fallbacks. +- Hardened GeoIP secrets, file handling, and portability through review rounds: no real MaxMind credentials in tests, no logged license-key URLs, project-relative `var/geoip2/GeoLite2-City.mmdb` path, Windows/path traversal rejection, compressed TAR validation, unsafe archive-member rejection, symlink/hardlink rejection, atomic replacement with readable permissions, streamed downloads, unsupported non-City database rejection, and bounded location labels for strict SQL platforms. +- Kept the slice narrow after product review: no latitude/longitude, no persistent GeoIP update-history store, no geo-blocking, no provider dropdown/account ID/locale settings, and no one-off Scheduler task ACL gates; task-level Scheduler policy is deferred to the Admin ACL enforcement matrix while direct GeoIP settings/download controls are Owner-only. +- Closed the branch with full verification and review context: full PHPUnit, JavaScript, lint, container, focused GeoIP/API/UI tests, class-map/worklog/draft updates, and Codex Cloud Review follow-ups. + +### 2026-06-15 feat-security-policy-docs +- Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation policy source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. +- Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch plans; then refined captcha TTLs, website burst/sustained budgets, scheduler trigger limits, high-signal probe limits, recovery login bypass behavior, captcha auto-success policy, and Admin/Owner protections. +- Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, auditable exemptions, configuration-surface ownership, privacy/storage ceilings, and follow-up coverage for setup/install, CORS preflight, uploads, archives, exports, diagnostics, trusted proxy identity, browser storage, and HTTP security headers. +- Added Admin-vs-Owner authority policy plus the detailed `feat-security-admin-acl-enforcement` branch plan, including default authority matrix, bounded Owner configurability, concrete domain defaults, enforcement boundaries, and test expectations. + +### 2026-06-15 feat-security-planning +- Created the Security hardening planning package: master branch-tree plan plus detailed `feat-security-*` plans for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. +- Recorded core product decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, scoped `reset()` behavior, database-backed passive signals/auto-bans, Owner recovery protection, GeoIP observability, IconCaptcha asset/accessibility rules, PR-readiness checks, and the inspiration-only `sec-lookup` legacy reference. +- Tightened privacy and architecture guardrails around shared client identity/trusted proxies, injectable clocks, degraded storage, race/idempotency, database-backed security event projection as an open question, and a 30-day maximum for queryable IP-derived data. + +### 2026-06-13 to 2026-06-14 feat-symfony-ux-integration +- Added the Symfony UX/UI foundation: namespace-aware Twig components, shared alert stacks, reusable Stimulus/live-polling controllers, notification center behavior, package live endpoints, package-aware cookie consent, local Mercure tooling, and lazy UX integrations. +- Hardened live/API/cookie/Mercure boundaries through repeated review passes, including exact-before-pattern dispatch, reserved live slugs, GET-only package live endpoints, alert topic scoping, consent cookie signing, protected Mercure env handling, URL/link sink validation, and safe fallback polling. +- Added JavaScript behavior coverage and UI/operation overlay refinements while recording follow-ups for public privacy triggers, live endpoint docs/navigation, captcha seed flows, notification preferences, package callbacks, and future LiveComponent filter slices. + +### 2026-06-12 to 2026-06-13 docs-cleanup +- Refreshed repository guidance and context docs: moved binding project rules into `AGENTS.md`, updated `.codex` environment/tooling notes, refreshed dependency recap for Symfony 8.1-era packages, and restored branch-oriented worklog archival rules. +- Improved local developer tooling documentation and commands: expanded `bin/lint` with diff/staged/changed modes, Markdown parsing, extensionless PHP checks, Git whitespace handling, route rendering through `php bin/console render:route`, and Symfony UX icon reference checks. +- Recorded Symfony UX integration notes, icon locking behavior, cache warmup/UX Translator expectations, production-only AssetMapper guidance, and ext-sodium platform requirements while archiving obsolete `.codex` helper context. + ### 2026-06-07 - Completed the API foundation and hardening slice: stateless Bearer API-key authentication, endpoint definitions/handlers, OpenAPI 3.2 generation, public/private navigation, admin/user/content/package endpoints, CORS, trace headers, feature policy settings, response/error schemas, and Message-layer localized feedback. - Hardened API access and review boundaries around disabled/setup/maintenance responses, package-owned route patterns, read-only method gates, endpoint permissions, API-key parsing, deleted users, ACL denial status, retained-deleted account mutations, content revisions, package slug identity, pagination/filtering/sorting, and public published-content status leakage. diff --git a/dev/draft/0.1.x-ErrorHandlingValidation.md b/dev/draft/0.1.x-ErrorHandlingValidation.md index 303c7f1c..cbec3f73 100644 --- a/dev/draft/0.1.x-ErrorHandlingValidation.md +++ b/dev/draft/0.1.x-ErrorHandlingValidation.md @@ -71,8 +71,9 @@ Hard exceptions are still appropriate for programming errors, impossible states, - **Decision recorded:** Invalid module or theme manifests should disable only the affected package during discovery and should be logged with error details. - **Decision recorded:** Failed operation snapshots are stored only when `APP_DEBUG` is enabled and are automatically purged when the error is resolved or debug mode is disabled. - **Decision recorded:** Error handling includes user-definable custom error pages for common HTTP status codes. -- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable HTTP error presentation. Callers should return its response instead of throwing when they can safely continue the request. +- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable browser HTTP error presentation through `HttpErrorRenderer::resolve()`. Callers should return its response instead of throwing when they can safely continue the request; API JSON errors stay on the API responder boundary. Callers may force a minimal bare HTML response when a block surface should avoid full error-page rendering. - **Decision recorded:** The HTTP error fallback order is `/system/error-pages/{status}` content, then `@frontend/error-pages/{status}.html.twig`, then `@frontend/error-pages/default.html.twig`. +- **Decision recorded:** Before setup completion, the browser HTTP error resolver returns DB-free minimal HTML `no-store` responses for known `4xx`/`5xx` statuses instead of resolving custom system error content. The minimal HTML includes the status code/text, an optional explicit bare-context line, and a Request ID resolved directly through `AccessRequestMetadata`. - **Decision recorded:** `401` for anonymous users renders the frontend login template with `http_error` Twig variables and still returns status `401`; authenticated users receiving `401` continue through the normal system-content/status-template/default error-page fallback. - **Decision recorded:** Workflow issues, logs, output, and validation flows use a shared `App\Core\Message\Message` shape with a log level, machine-readable code, translatable key, parameters, and context. Core-owned message constants live in domain-owned `*MessageCode` and `*MessageKey` catalogues that are aggregated by `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` for tooling and translation checks. Catalogue constant names and values are namespace-bound to their owning scope. Third-party modules and themes may provide their own deterministic package-namespaced codes and keys, but system/core namespaces remain reserved. Domain code may use `MessageException::invalidArgument()` to carry a structured message at hard invariant boundaries without embedding user-facing text in exceptions. - **Decision recorded:** Recoverable runtime, package, theme, hook, asset, and rendering failures should return `WorkflowResult`/`Message` diagnostics or render a controlled error response wherever possible. Hard throws are reserved for programmer errors, invalid value-object construction, setup/CLI aborts, and guard rails that cannot safely continue in-place. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 3dcdb5d0..fe52d943 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -1,7 +1,7 @@ # Security and access control (Feature Draft) > **Status**: Draft -> **Updated**: 2026-06-05 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for authentication, authorization, ACLs, rate limiting, captchas, and secure administrative flows. @@ -10,6 +10,8 @@ - Covers roles, ACLs, rate limiting, captcha integration, secrets, and module-provided security extensions. - Depends on core architecture, plugin modules, and editor workflows. - Keeps security Symfony-native while leaving explicit replacement points for captcha and future authentication extensions. +- Splits the next hardening work into focused branches through the [security hardening implementation plan](0.2.x-SecurityHardeningPlan.md). +- Uses [security policy defaults](security-hardening/policy-defaults.md) as the first source for Security hardening TTLs, threshold defaults, privacy ceilings, and review requirements. ## Outline Security should use Symfony Security as the primary model. Authentication, authorization, CSRF protection, voters, rate limiting, secrets, and route access rules should remain recognizable Symfony concepts. @@ -49,9 +51,20 @@ Captcha should use a global form field integration. When a workflow includes the - Define exactly one global user role per account, with Public, User, Moderator, Author, Publisher, Curator, Manager, Director, Admin, and Owner mapped to numeric access levels `0` through `9` and inherited Symfony roles. - Use CSRF protection for state-changing browser workflows. - Use Symfony Rate Limiter for application-level throttling such as login attempts, contact forms, API usage, import attempts, and captcha failures. -- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha refreshes, captcha failures, API usage, import attempts, and suspicious probes. +- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha failures, API usage, import attempts, and suspicious probes. +- Use the Security policy defaults for first limiter thresholds, auto-ban TTLs, captcha TTLs, and IP-retention ceilings. Implementation branches may tune those defaults only with tests, draft updates, and worklog notes. - Avoid using a single coarse per-IP limiter as the only abuse-control decision. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints should stay cheap, tokenized where necessary, no-store, and safe for live polling, captcha refreshes, or lightweight UI refreshes; clear abuse may still feed passive suspicious-behavior signals. +- Classify Turbo/browser prefetch requests separately from deliberate navigations and submissions. Prefetch should not spend the same global abuse budget as a user-initiated request, and expensive or side-effect-adjacent links may disable Turbo prefetching. +- Route rate-limit decisions through a Studio-owned facade so workflows can assign action costs, reset scoped buckets after clear success, and combine Symfony RateLimiter buckets with cross-action abuse signals without binding controllers to Symfony limiter internals. +- Prefer scoped bucket resets for successful human outcomes before designing partial refunds. For example, a successful login may reset the login-attempt bucket for that subject, and a verified provider-backed captcha challenge may reset the relevant challenge or form bucket when the workflow explicitly allows it. +- Track global cross-action abuse signals across website and API activity so several separately limited actions can still trigger progressive handling when they occur in suspicious sequence. - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. +- Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. +- Prefer visitor-ID-backed temporary bans for continuity and use shorter IP-bucket bans only as a secondary layer against cookie-reset bypasses. IP-derived records must remain queryable for less than 30 days. +- Registered users should receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define an explicit stricter bucket. Active Owner sessions and Owner-owned API keys are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths should be configurable with broad defaults. High-signal probes such as `.env`, VCS metadata, backup/database dumps, common foreign admin panels, and shell probes should return a generic `400` and feed suspicious-signal handling instead of being treated like normal 404 traffic. +- Auto-ban is enabled by default once implemented, but active Admin/Owner session Visitor IDs and IP buckets must not be banned. A recovery login path such as `/user/login?bypass=1` must remain reachable through active Visitor/IP bans, guarded by a dedicated recovery bucket, so a successful login can re-evaluate the subject under authenticated/Admin/Owner policy. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. @@ -64,6 +77,7 @@ Captcha should use a global form field integration. When a workflow includes the - Make captcha usage configurable per workflow, with contact forms, registration forms, and guest comments as the first expected protected workflows. - Allow provider selection through configuration. With only the first-party module installed, the default choices should be `icon_captcha` and `none`. - Treat provider value `none` and missing provider modules as successful captcha validation so configured forms do not break. +- Treat provider value `none` and missing/disabled provider success as graceful workflow success only. It must not reset rate limits, refill budgets, clear bans, or satisfy captcha-based `429` recovery. - Ensure captcha never replaces CSRF, ACL checks, authentication, or business validation. - Use audit logging for high-impact actions such as publish, delete, import apply, module enable, migration run, backup restore, and update. - Audit schema Twig changes, content-query/list field changes, variant availability changes, and localization routing changes. @@ -75,6 +89,11 @@ Captcha should use a global form field integration. When a workflow includes the - Allow users to belong to multiple ACL groups independently of their exact global role; anonymous visitors use Public access level `0`. - Require every active or inactive user account to keep at least the User role, and ensure at least one active Owner account always remains available. - Owners may manage all roles and groups. Admins may enter Admin but cannot manage peer Admin users, Owner users, or groups at or above their own role level. +- Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. +- Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency operational controls. +- Treat this matrix as a code-owned registry plus seeded bounded Owner overrides for descriptor-approved rows. The Owner-gated `Settings/ACL` view may delegate selected Admin denied, visible, or mutable permissions and may add optional ACL-group states after the Admin surface gate is satisfied. Matching group states override the role/default state and may grant or restrict access; multiple matching groups resolve to the highest explicit group state. The matrix must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, bypass the Admin/Editor surface gates, or remove Owner recovery protections. +- Assign ACL matrix rows by the responsibility of the action being performed, not only by the view or menu area where the control is rendered. Editing ACL group definitions uses the ACL-group administration permission, assigning a group to a user account uses the user-management permission, pending account-token review actions use the review permission even from the user-management view, trusted registered Scheduler task run-now uses the Scheduler permission, and continuing a reviewed live operation re-checks the target domain permission. +- Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. - Allow entities and schema versions to override inherited ACL behavior with separate capability fields: min-level plus optional group identifiers. Content uses `view`, `edit`, and `manage`; schemas use `use`, `edit`, and `manage`. @@ -86,6 +105,7 @@ Captcha should use a global form field integration. When a workflow includes the - Require modules to declare permissions and route sensitivity before enabling admin routes or state-changing actions. - Treat protected database-backed configuration values, such as the MaxMind API key, as administrator-only settings and never expose them through logs, diagnostics, public exports, or unprivileged UI. - Ensure missing optional security configuration, such as a GeoIP provider key, degrades gracefully instead of producing hard runtime failures. +- Use GeoIP only as optional operational metadata for logs, statistics, diagnostics, and later security review unless a dedicated geo-blocking policy is explicitly designed. - Treat API authentication separately from browser authentication once the API draft is implemented. ## Testing & Validation @@ -94,6 +114,10 @@ Captcha should use a global form field integration. When a workflow includes the - Test CSRF protection on state-changing forms. - Test rate-limited workflows with configured thresholds. - Test separate rate-limit buckets do not block unrelated workflows. +- Test `/api/live/**` stays outside ordinary rate-limit responses while remaining protected by route-specific tokens or access checks where applicable. +- Test Turbo/browser prefetch classification so speculative requests do not spend the same budget as deliberate requests. +- Test successful outcomes reset only the intended limiter buckets. +- Test cross-action abuse signals and temporary bans with IP, visitor ID, API key, authenticated user, and Owner recovery cases. - Test progressive abuse handling transitions where implemented. - Test captcha provider selection and fallback behavior. - Test the global captcha form field passes validation when provider is `none` or no captcha provider is available. @@ -110,6 +134,7 @@ Captcha should use a global form field integration. When a workflow includes the - Test schema Twig editing and activation permissions. - Test the shared ACL resolver for anonymous, level-based, group-based, inherited, and denied decisions. - Test administrative hierarchy guardrails for editing users, assigning groups, creating groups, updating group minimum roles, deleting groups, self-lockout, last-owner protection, and role/group assignment boundaries. +- Test Admin/Owner action authority for settings, package lifecycle, scheduler controls, backup/restore, imports/exports, diagnostics, security policy, and emergency operational controls as those surfaces become implemented. - Test ACL group impact review and cleanup of deleted identifiers across all known ACL-bearing entities. - Test content-query/list field permissions. - Test ACL-restricted content visibility in public rendering, API output, content-query/list fields, and search results. @@ -124,9 +149,16 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** `APP_SECRET` is generated during setup and stored in `.env.local.php`. - **Decision recorded:** Captcha extensibility belongs in core through `CaptchaProvider` and resolver contracts, while IconCaptcha behavior is tracked in its own feature draft before implementation. - **Decision recorded:** Captcha integrates through a global form field that uses the configured captcha provider resolver. -- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows. +- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows, but this graceful success is not verified human success and must not reset rate limits or satisfy captcha-based `429` recovery. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected workflows to protect with captcha. - **Decision recorded:** Abuse handling should be progressive and use workflow-specific limiters instead of relying on one coarse IP bucket. +- **Decision recorded:** The next security work is split through `feat-security-*` branches described in the security hardening implementation plan. Each branch should be feature-complete for its focused concern rather than accumulating one large security review branch. +- **Decision recorded:** First Security hardening thresholds, TTLs, privacy ceilings, and configuration posture live in the Security policy defaults document and must be updated when an implementation branch changes those policies. +- **Decision recorded:** `/api/live/**` should not receive ordinary rate-limit enforcement. Live endpoints remain low-cost JSON surfaces for polling, captcha refreshes, and UI refreshes; suspicious access may still feed passive abuse signals. +- **Decision recorded:** Rate and abuse checks should pass through a Studio-owned facade. Symfony RateLimiter remains the token bucket/sliding window implementation where practical, while Studio owns request-intent classification, action costs, cross-action signals, scoped resets, and future temporary ban policy. +- **Decision recorded:** Scoped limiter resets are preferred over partial token refunds when a workflow has a clear successful human outcome. +- **Decision recorded:** Turbo/browser prefetch should be classified separately from deliberate navigation and submission traffic so speculative requests do not unfairly drain user-facing buckets. +- **Decision recorded:** Temporary auto-bans are acceptable for clear suspicious behavior when they are TTL-bound, auditable, reviewable, and preserve Owner recovery paths. - **Decision recorded:** Visitor identity should use a first-party, HMAC-protected, rotatable technical visitor cookie. Avoid machine IDs, advertising IDs, cross-site identifiers, and browser/device fingerprinting. When no valid visitor cookie is present, the request uses an `APP_SECRET`-derived fallback visitor ID from source IP and normalized user-agent so cookie-disabled clients do not create a new unique visitor on every request. The response still receives a fresh random signed cookie token. The short-lived identity store keeps cookie hashes and fallback hashes separate: known valid cookie hashes win, then recent fallback hashes win, and newly issued cookies bind to the chosen visitor ID. This intentionally accepts temporary undercounting for same-IP/same-user-agent visitors so the system does not overcount every page view or first-cookie transition. - **Decision recorded:** Session hardening binds authenticated Symfony sessions to the first-party visitor signal. Login requests and legacy sessions without an existing binding initialize the binding; established sessions with a changed or missing visitor signal are treated as likely session duplication and terminated to prevent copied session cookies from staying usable. - **Decision recorded:** Request IDs, visitor IDs, IP buckets, and related metadata may feed rate limiting and suspicious-behavior detection, but they are signals rather than sole proof of identity. Some enforcement may be IP-based and some visitor-based, depending on the abuse pattern. @@ -136,6 +168,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** Require module-declared permissions before module admin routes become active. - **Decision recorded:** Split operational route surfaces into `admin/` for system administration and `editor/` for content authoring, review, publishing, and content-management workflows. `admin/` starts at Admin, while `editor/` starts at Author and should use ACL capability checks so authors, managers, and administrators can access only the authoring functions they are allowed to use. - **Decision recorded:** Backend area checks expose an `AccessRule` boundary so future admin exceptions can add configured ACL groups without changing controller flow. The first router slice keeps admin at the Admin role boundary and keeps Owner as the unrestricted site-owner role. +- **Decision recorded:** Admin and Owner are separate operational authority levels. Admins are delegated operators; Owners control site-wide and recovery-sensitive actions. High-impact Admin features must use an action authority matrix enforced in the service/API/live-operation boundary, not only route prefixes or hidden navigation. Feature/action permissions are grouped by Admin, Editor, and Frontend surfaces; this branch implements the Owner-gated Admin matrix, prepares the shared descriptor model for later Editor use, and reserves Frontend only for explicitly designed future features. - **Decision recorded:** Editing or activating database-backed schema Twig and content-query/list fields is security-sensitive and requires explicit permissions plus audit logging. - **Decision recorded:** Content ACL restrictions may limit visibility to specific ACL groups and must be enforced consistently across rendering, API output, content-query/list fields, search, and import/export previews. - **Decision recorded:** Implement ACL foundations early, including `/admin` access protection and content-record ACL restrictions, to avoid later structural rewrites. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md new file mode 100644 index 00000000..d4812f1a --- /dev/null +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -0,0 +1,348 @@ +# Security hardening implementation plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Plan the security hardening feature split so each branch can be implemented, reviewed, and merged as a focused production-ready slice. + +## Overview + +This plan turns the security and access-control decisions into a reviewable branch sequence. The goal is not to create one large security branch. Each branch should ship one coherent security capability with tests, documentation, worklog notes, and narrow integration points. + +The `feat-security` branch is the shared merge base. Feature branches should use the `feat-security-*` prefix and avoid unrelated cleanup. A branch may add extension points needed by later branches, but it should not implement future behavior merely to prove the extension point. + +Detailed handoff plans live in `dev/draft/security-hardening/`. Each branch plan defines the implementation sequence, interfaces, edge cases, tests, documentation updates, non-goals, and acceptance criteria for one reviewable branch. + +First policy defaults live in [security policy defaults](security-hardening/policy-defaults.md). Implementation branches should treat that document as the source for first TTLs, threshold defaults, privacy ceilings, and review requirements until a branch updates it with tested evidence. + +## Git handling + +Codex may create local commits for Security planning and implementation work when the commit scope is thematically clear and reviewable. Pushes are never implied by these plans and require an explicit user instruction. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be used as implementation inspiration for GeoIP, abuse-signal, rate-limit, auto-ban, captcha, and IconCaptcha work because similar features were already proven there. The current Symfony product decisions in this plan and the detail plans have absolute priority over the historical implementation. Do not copy old logic, storage models, identifiers, templates, assets, secrets, or framework-specific shortcuts directly; use the plugin only to understand behavior patterns, edge cases, and operational lessons. + +## Planning decisions + +- Keep Symfony Security, CSRF, Validator, RateLimiter, Messenger, Lock, and existing project message/audit/log layers as the primary foundations. +- Keep `/api/live/**` outside the normal rate-limit enforcement path. Live JSON endpoints should stay cheap, tokenized where needed, no-store, and suitable for operation polling, captcha refreshes, or lightweight UI refreshes. Suspicious live-endpoint traffic may feed passive abuse signals, but live endpoints should not receive ordinary HTML or versioned-API `429` handling. +- Treat Turbo and browser prefetch requests as lower-confidence interaction signals. Prefetch requests should not spend the same global abuse budget as deliberate navigations or submissions, and high-cost links may disable prefetch through `data-turbo-prefetch="false"`. +- Detect Turbo prefetch defensively through `X-Sec-Purpose: prefetch` and browser speculative loading through `Sec-Purpose: prefetch` where available. Missing or spoofable non-`Sec-*` hints must not bypass security checks. +- Use action-aware limiter costs. A failed login, failed captcha, registration submission, password-reset request, API mutation, suspicious probe, or scheduler trigger may consume different costs from different buckets. +- Prefer complete bucket reset for clear success cases before designing partial refunds. For example, successful password login may reset the login-attempt bucket for that subject. Verified provider-backed captcha success may reset or improve specific challenge/form buckets when that behavior is explicit and safe. +- Keep room for future positive adjustments through a Studio-owned rate/abuse facade, but do not expose Symfony RateLimiter details directly to controllers or packages. +- Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. +- Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. +- Support active punishment such as draining a suspicious subject's relevant buckets when clear bot behavior is detected. +- Add temporary auto-bans with TTL for IP, visitor ID, API key, or combined subjects. Authenticated users should receive softer handling where reasonable, and Owner accounts must never be locked out of all recovery paths. +- Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. +- Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. +- Cover adjacent security surfaces through the abuse/rate policy catalogue instead of local ad hoc checks: setup apply, CORS preflight, high-impact admin operations, package lifecycle, backup/restore, import/export, uploads/archives, diagnostic downloads, and support bundles. +- Separate delegated Admin authority from Owner-only site-control actions through a shared action policy before broadening package, scheduler, backup, settings, diagnostics, update, and security-management workflows. +- Track HTTP security headers as a production-hardening follow-up if they are not implemented inside an existing response or frontend-delivery branch. + +## Branch sequence + +### `feat-security-policy-docs` + +Record the product decisions from this plan in the relevant drafts and manuals before implementation starts. + +Detailed plan: [policy-docs](security-hardening/policy-docs.md). +Policy defaults: [security policy defaults](security-hardening/policy-defaults.md). + +Scope: + +- Update security, captcha, logging/statistics, API/live, and scheduler notes where their behavior is affected. +- Record first implementation defaults for retention, limiter thresholds, auto-ban TTLs, captcha TTLs, privacy ceilings, and configuration posture. +- Document the branch plan, expected verification shape, and deferred decisions. +- Do not add runtime code. + +Acceptance: + +- Future branches can cite one planning draft instead of re-litigating the same scope. +- Open decisions are explicit and assigned to the branch where they must be resolved. +- First implementation branches have a single policy-defaults reference for initial TTLs and thresholds. + +### `feat-security-geoip-observability` + +Make GeoIP useful for logs, statistics, and security diagnostics without using it for enforcement yet. + +Detailed plan: [geoip-observability](security-hardening/geoip-observability.md). + +Scope: + +- Add or complete a MaxMind/GeoIP2 provider behind the existing GeoIP resolver boundary. +- Store provider configuration as protected administrator-only configuration. +- Keep `NullGeoIpResolver` as the safe fallback when configuration or local databases are missing. +- Add a scheduler-ready GeoIP update task definition and leave it inactive by default until an administrator configures provider credentials and update policy. +- Surface safe GeoIP status in Admin diagnostics. + +Non-goals: + +- No geo-blocking. +- No permanent allow/deny decisions based on country or region. + +Acceptance: + +- Logs and statistics receive normalized GeoIP fields when available and normalized empty fields when unavailable. +- Provider secrets never appear in logs, exports, fixtures, screenshots, or diagnostics. + +### `feat-security-abuse-foundation` + +Introduce the Studio-owned abuse and rate facade without enforcing broad bans. + +Detailed plan: [abuse-foundation](security-hardening/abuse-foundation.md). + +Scope: + +- Add subject resolution for IP, visitor ID, authenticated user, API key, and safe combined keys. +- Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. +- Include additional intents for setup apply, CORS preflight, package/admin operations, upload/archive validation, export/download, backup/restore, self-update, and diagnostics/support-bundle generation where those routes already exist or are introduced by later branches. +- Add a central action cost catalogue with separate website and API families. +- Add database-backed log lookup projections for message, audit, and access logs while keeping 30-day rotating file logs as the durable raw operational fallback. Projections store minimized structured fields only; they do not duplicate full raw log lines. +- Add database-backed passive suspicious-signal recording with TTL cleanup metadata. These records remain observational in this branch and become enforcement inputs only in later branches. +- Move Admin/API log browsing to the database projection, split the Admin Log Viewer into source tabs, keep broad free-text search over hidden identifiers/context, show only meaningful filters per source, and hide `DEBUG`/`INFO` by default through a multi-level filter where levels are meaningful. Message projections keep level data, security signals keep severity, and current access/audit projections omit level fields because they do not carry multiple meaningful levels. +- Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. +- Add audit/message events for suspicious signals with redacted context. + +Non-goals: + +- No automatic ban yet. +- No provider-specific captcha behavior. + +Acceptance: + +- Controllers and future packages can ask one service for classification/cost decisions instead of calling Symfony RateLimiter directly. +- Prefetch requests are detectable and classified separately from deliberate navigation. + +### `feat-security-admin-acl-enforcement` + +Introduce one shared authority policy for Admin-vs-Owner action decisions before broadening high-impact Admin features. + +Detailed plan: [admin-acl-enforcement](security-hardening/admin-acl-enforcement.md). + +Scope: + +- Inventory existing Admin UI/API/live-operation/scheduler surfaces and assign stable action identifiers. +- Encode the first static authority matrix for delegated Admin actions, Owner-only actions, and deny-by-default unknown actions. +- Enforce the matrix in service/API/live-operation boundaries, not only in navigation or controllers. +- Preserve existing user-management guardrails for peer roles, ACL group assignment, self-lockout, and last-Owner protection. +- Keep protected values write-only or status-only unless a dedicated reveal flow adds re-authentication and audit. + +Non-goals: + +- No unbounded or package-marketplace-style Admin permission editor; this slice ships only the bounded Owner-gated `Settings/ACL` matrix for registered feature/action descriptors. +- No package marketplace permission model. + +Acceptance: + +- High-impact Admin workflows can distinguish "Admin may view", "Admin may mutate", and "Owner-only". +- Later package, scheduler, backup, settings, diagnostics, update, and security-management branches can consume one policy instead of inventing local role checks. + +### `feat-security-rate-enforcement` + +Attach concrete Symfony RateLimiter buckets through the Studio facade. + +Detailed plan: [rate-enforcement](security-hardening/rate-enforcement.md). + +Scope: + +- Add named buckets for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure flows. Branches that introduce a later workflow must attach it to the existing policy catalogue instead of inventing a parallel limiter. +- Treat captcha refreshes served through `/api/live/**` as passive abuse signals instead of ordinary rejecting rate-limit buckets. +- Use costed `consume(n)` calls for action-aware spending. +- Use `reset()` for clear successful outcomes such as successful login or verified provider-backed captcha where the reset is scoped and safe. +- Return stable HTML or JSON `429` responses depending on request family. +- Keep `/api/live/**` excluded from ordinary rate-limit responses. +- Implement and test `no-store` for rate-limit, recovery, and error responses touched in this slice, and carry the broader production HTTP security-header follow-up into a dedicated response-hardening/frontend-delivery slice if CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, and documented route exceptions are still deferred. + +Non-goals: + +- No auto-ban. +- No partial token refund API unless a concrete workflow needs it after `reset()` is evaluated. + +Acceptance: + +- Different workflows can be limited independently and also contribute to a global budget. +- Successful human completion can clear the relevant local bucket when the product policy allows it. + +### `feat-security-auto-ban` + +Add temporary enforcement for sustained suspicious behavior. + +Detailed plan: [auto-ban](security-hardening/auto-ban.md). + +Scope: + +- Add TTL-based ban records for IP, visitor ID, API key, and combined subjects. +- Add ban reasons, expiry, source signals, actor context, and audit entries. +- Apply softer thresholds or bypasses for authenticated users where appropriate. +- Enforce Owner safety so at least one active Owner retains a documented recovery path. +- Provide Admin review and manual unban tools. + +Non-goals: + +- No country-level blocking. +- No permanent invisible deny list. + +Acceptance: + +- Clear bot/probe behavior can be temporarily blocked without blocking all Owner recovery. +- Operators can understand why a subject is blocked and when the block expires. + +### `feat-security-captcha-contract` + +Add the generic captcha extension model. + +Detailed plan: [captcha-contract](security-hardening/captcha-contract.md). + +Scope: + +- Define `CaptchaProvider` and resolver contracts. +- Add workflow-level provider selection. +- Add a global captcha form field. +- Treat `none`, missing providers, and disabled providers as successful validation unless a later provider-required policy explicitly changes that workflow. +- Distinguish graceful captcha auto-success from verified provider-backed challenge success. Only verified provider-backed success may reset/improve buckets or satisfy captcha-based `429` recovery. + +Non-goals: + +- No IconCaptcha provider implementation. +- No hard dependency on package assets. + +Acceptance: + +- Public workflows can include captcha without knowing the provider. +- Captcha remains additive and never replaces CSRF, authentication, ACL, rate limiting, or domain validation. + +### `feat-security-icon-captcha` + +Implement the first-party IconCaptcha provider as a focused provider branch. + +Detailed plan: [icon-captcha](security-hardening/icon-captcha.md). + +Scope: + +- Ship provider-owned assets, templates, JavaScript, translations, services, and validation. +- Use deterministic server-side challenge derivation with one-shot challenge IDs and the bounded TTL from the Security policy defaults. +- Add refresh behavior through `/api/live/**` or another lightweight JSON route. Refresh abuse should feed passive signals, but ordinary live refreshes should not return normal rate-limit `429` responses. +- Keep UI accessible and layout-stable. + +Non-goals: + +- No core-specific IconCaptcha checks in contact, registration, or password forms. +- No copied legacy code, secrets, or hard-coded asset paths from older projects. + +Acceptance: + +- The provider can be enabled, disabled, or replaced without breaking workflows that use the generic captcha field. +- Challenge payloads do not expose secrets, generated answer material, or reusable state. + +### `feat-security-mailer-account-delivery` + +Replace the message-log-only account delivery path with production mail delivery. + +Detailed plan: [mailer-account-delivery](security-hardening/mailer-account-delivery.md). + +Scope: + +- Add provider-based mail-flow registration if it is not already present. +- Add localized Markdown templates, HTML/plain-text rendering, placeholder replacement, and Symfony Messenger queueing. +- Add a conservative transport guard. +- Keep the message-log action-link delivery only as an explicit debug aid with administrative warnings. + +Non-goals: + +- No newsletter or CRM integration. + +Acceptance: + +- Invitation, registration, password reset, password-change review, account closure, and APP_SECRET recovery flows no longer depend on reading clear action URLs from the message log in production. + +### `feat-security-remember-me` + +Add persistent login as an explicit security feature. + +Detailed plan: [remember-me](security-hardening/remember-me.md). + +Scope: + +- Use Symfony remember-me concepts with a server-side persistent token model. +- Store only opaque selector/token material in the browser. +- Bind automatic login to the current visitor signal. +- Revoke tokens on logout, password change, account status changes, APP_SECRET emergency handling, and suspicious reuse. +- Use a seven-day trust window unless implementation evidence supports changing it. +- Include a minimal account-facing token review/revocation surface so persistent login is operable without database access. + +Non-goals: + +- No long-lived identity cookie without server-side revocation. + +Acceptance: + +- Automatic login is auditable, revocable, visitor-bound, and does not bypass account status or role checks. + +## Cross-branch review rules + +- Every branch must include focused tests for allowed, denied, degraded, and redacted behavior. +- Every branch must update this plan or the owning feature draft when the implementation changes product scope. +- Security-sensitive state changes must emit audit or message entries with safe context. +- Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. +- Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. +- Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. +- Security identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. Visitor-ID generation may use raw forwarding-header values only as untrusted differentiation entropy and never as Security subject, GeoIP, ban, or signal evidence. +- TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. +- Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. +- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. +- Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, package boundaries, docs, translations, and verification output for that branch. + +## Security PR-readiness checklist + +- [ ] Security/privacy considerations, public entry points, sessions, secrets, and browser storage reviewed. +- [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed. +- [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed. +- [ ] Project-rules, architecture, naming, and documentation drift reviewed; see issue `#57` for the broader drift-audit expectations. +- [ ] Follow-up tasks captured in `dev/WORKLOG.md`. +- [ ] Translations and user-facing copy updated or explicitly confirmed unchanged. +- [ ] `bin/phpunit`: [RESULT] +- [ ] `bin/jstest`: [RESULT] +- [ ] `bin/lint`: [RESULT] +- [ ] Other checks not already covered by the full suites above: [RESULT] + +## Fixed implementation defaults + +- Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. +- Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. +- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to active Admin or Owner sessions must not be banned. +- IP-based enforcement is secondary, laxer than Visitor-ID enforcement, and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. +- Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. +- Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. +- Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. +- Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. +- Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. +- Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. +- A recovery login path such as `/user/login?bypass=1` must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. +- Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. +- Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. +- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. +- IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. +- IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. +- Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. +- Remember-me includes a minimal profile/security UI for listing active persistent tokens and revoking individual tokens or all other tokens. + +## Remaining calibration points + +- Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. +- Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. +- Database-backed lookup projections for message, audit, access, and passive security signals are the preferred Admin/API read model for Security review and abuse correlation. File logs remain the durable raw source and operator fallback. +- Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. + +## References + +- [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security policy defaults](security-hardening/policy-defaults.md) +- [IconCaptcha integration](0.4.x-IconCaptcha.md) +- [Contact, mail, and logging](0.4.x-ContactMailLogging.md) +- [API layer](0.4.x-ApiLayer.md) +- [Scheduler](0.4.x-Scheduler.md) +- [Action log and audit snippets](../manual/action-log-audit-snippets.md) diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index f8de64bd..187bbce4 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -10,6 +10,7 @@ - Covers REST conventions, authentication, serialization, permissions, versioning, and module-provided endpoints. - Depends on the content model, security, and import/export drafts. - Starts with content interaction, a reusable endpoint registry, and package-ready extension contracts. +- Uses the Security hardening policy defaults for API rate-limit and `/api/live/**` abuse-signal policy. ## Outline The first API should expose content interaction, not internal process control. External clients should be able to read content and, where explicitly allowed, create or update content through content-oriented operations. Content API responses should preserve the canonical slug hierarchy, language, generic variant, version, and schema field identifiers where applicable. Internal content UIDs, Doctrine identifiers, audit metadata, ACL group details, and editor-only state should not be exposed by default. API access requires user-owned API tokens generated, revoked, and reset by users. Administrative internals such as package lifecycle, update flows, backup/restore, release packaging, and general command execution should stay outside the initial API. @@ -27,7 +28,7 @@ The API branch should implement the platform foundation and the first content re - Use REST-style HTTP endpoints under `/api/v1` as the first public API style. GraphQL can be revisited only when clients need cross-resource graph queries that REST cannot express cleanly. - Use OpenAPI documentation for the public contract. `NelmioApiDocBundle` is an acceptable dependency if it keeps controller/DTO documentation smaller and easier to maintain than a custom documentation generator. - Keep API Platform out of the first implementation unless a later review shows its generated-resource model fits Studio's domain-owned ACL and serialization boundaries without exposing entities. -- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and `OPTIONS` requests only. Mutating methods such as `POST`, `PUT`, `PATCH`, and `DELETE` require an active `read_write` key and still need domain authorization. +- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and safe `OPTIONS` requests only. Bearer preflights use `Access-Control-Request-Method` for this method gate, so `OPTIONS` requests that ask to perform `POST`, `PUT`, `PATCH`, or `DELETE` require an active `read_write` key and still need domain authorization. - Use the API key owner as the authenticated user context. The API must not define a parallel role or group model. A read-write key cannot create, update, publish, delete, or otherwise mutate a resource unless the owning user could do so according to the target domain's ACL rules. - Allow endpoint definitions to opt into anonymous public read access with `allow_public`. The default must be private/authenticated. Public anonymous access is read-only and uses `AccessActor::anonymous()`; invalid Bearer keys still fail authentication instead of falling back to anonymous access. - Return deterministic JSON `503 Service Unavailable` responses for `/api/v1/**` when setup is not complete, maintenance mode is active for non-admin API callers, or Doctrine/DBAL reports database unavailability. The API must not fall through to setup redirects, HTML error pages, or non-deterministic exception output. @@ -79,6 +80,8 @@ The API branch should implement the platform foundation and the first content re - Keep content route identity based on canonical slug hierarchy by default. Internal UIDs may be used by repositories and domain services, but they should not be part of the default public API contract. - Reserve `/api/live/**` for application-owned live JSON flows such as captcha seeds, polling, and operation status checks. This branch is intentionally not part of the stable external versioned API contract. - Keep `/api/live/**` reachable during `APP_MAINTENANCE` so setup, admin, and operational live-log polling can continue while the public site is unavailable. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints are application-owned lightweight JSON flows; clear abuse may feed passive security signals, but live polling, captcha refreshes, and UI refreshes should not receive the normal website/API `429` response path. +- Apply versioned API read/write rate-limit defaults from the Security policy defaults through the Security hardening rate-enforcement branch. - Use `/api/live/operations/{operationId}?token=&cursor=` for ActionLog polling fallback responses. Payloads should include `operation_id`, `status`, `cursor`, optional `cursor_max`, `entries`, and `next_poll_ms`. - Treat live-operation cursors as monotonic numeric positions emitted by the log producer, not as stable durable identifiers. - Defer public content DTO and serializer contracts to the API feature branch so entity exposure, pagination, error payloads, and versioning rules are decided together. @@ -174,11 +177,11 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** API keys are stored as `prefix`, `hmac_hash`, and `encrypted_key`. The prefix supports display/diagnostics, the HMAC supports lookup, and the encrypted payload supports password-gated reveal flows. Use `APP_SECRET` as the first encryption/HMAC root secret. - **Decision recorded:** API key encryption/HMAC behavior should move to the shared `APP_SECRET`-rooted, context-labeled, versioned secret protector once that foundation exists. - **Decision recorded:** API key lifecycle states are `read_write`, `read_only`, and `revoked`. Status labels and simple permission semantics live on the API key status enum so later UI, CLI, and API handlers can reuse the same rules. -- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. +- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and safe `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. Bearer preflights are evaluated against `Access-Control-Request-Method`, not only the transport-level `OPTIONS` method, so read-only keys cannot preflight-probe write/admin surfaces indefinitely. - **Decision recorded:** Public anonymous reads are endpoint-definition opt-ins through `allow_public`. Missing Bearer credentials and unrelated non-Bearer authorization schemes may continue as anonymous only for these endpoints, while invalid Bearer credentials remain authentication failures. - **Decision recorded:** `/api/v1/**` availability failures are API responses, not setup redirects or generic HTML failures. Incomplete setup, maintenance mode, and Doctrine/DBAL failures return stable Message-layer `503` JSON payloads with `Retry-After`; maintenance bypass is evaluated after Bearer authentication so only admin-level API keys bypass it. - **Decision recorded:** API availability is configuration-controlled through `api.enabled`. When disabled, `/api/v1/**` returns stable Message-layer `503` JSON, while frontend API-key management is hidden for non-owner users. Owners keep key-management access for operational integrations such as scheduler and cron setup. CORS is configured separately through API settings and should remain closed unless explicit origins are configured. -- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful preflight responses; wildcard origins must be configured explicitly. +- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful anonymous preflight responses; wildcard origins must be configured explicitly. `OPTIONS` requests with an actual `Authorization` header are not short-circuited by CORS and are classified by `Access-Control-Request-Method` for rate-limit accounting, so credentialed preflight probes cannot bypass API write/admin budgets. Bearer remains the only API authentication scheme; unrelated schemes may continue anonymously only on endpoint-defined public reads outside credentialed preflights. - **Decision recorded:** API requests remain part of operational access logging but are separated in anonymized statistics through the `api` surface plus explicit `api_requests` and `page_requests` aggregate counters. Inbound `X-Correlation-ID` and valid `X-Request-ID` headers are logged as external correlation IDs while the system keeps its own generated request ID for internal joins. Versioned `/api/v1/**` responses expose the internal `X-Request-ID` response header for support/debugging and echo a validated inbound correlation header as `X-Correlation-ID` when present; both headers are documented in OpenAPI and exposed to configured CORS browser clients. - **Decision recorded:** The API should not introduce fine-grained token scopes in the first implementation. Role and group scopes come from the owning user, while key status can only reduce write capability. - **Decision recorded:** Revoked API keys remain audit-relevant after their original user account is purged. Deleted-user cleanup reassigns retained revoked keys to a stable system deleted-user account, and future API-audit review of revoked-key usage should trigger a warning mail to the original affected user when an address is still known. @@ -192,6 +195,7 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** OpenAPI documents should track the current stable OpenAPI Specification unless client tooling compatibility forces a downgrade. The first generator emits OpenAPI `3.2.0`, uses the root `.manifest` `APP_NAME`, `APP_DESCRIPTION`, and `APP_LICENSE` as stable product/API metadata, exposes `$self`, names the `/api/v1` server, emits native OpenAPI 3.2 tag metadata with hierarchy hints, keeps shell/domain scope visible in tags without adding route hierarchy, and keeps user-defined `site.title` reserved for optional instance metadata instead of API contract branding. - **Decision recorded:** Use `/api/live/**` for internal live JSON endpoints that are fetched by system or public UI JavaScript and should not consume top-level content routes. - **Decision recorded:** `/api/live/**` remains outside the stable external API contract. External integrations should use versioned routes such as `/api/v1/**`; live routes are application-owned internal JSON flows. +- **Decision recorded:** `/api/live/**` remains outside ordinary rate-limit enforcement so operation polling, captcha refreshes, and lightweight UI flows stay cheap and resilient. Route-specific tokens, access checks, TTLs, no-store headers, and passive abuse signals remain available where appropriate. - **Decision recorded:** The global maintenance gate bypasses `/api/**`; `/api/v1/**` applies its own API maintenance gate after Bearer authentication, while `/api/live/**` remains reachable for operation status and continuation flows. - **Decision recorded:** ActionLog polling under `/api/live/operations/**` uses a numeric `cursor` query parameter plus optional `cursor_max` progress metadata and a per-run token instead of route authentication. - **Decision recorded:** Defer `ApiResponseContextEvent` until real API response builders exist; it should extend safe diagnostics or debug context, not bypass serialization, permission, or redaction rules. diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 6b9a0ef9..cf3e759b 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -1,7 +1,7 @@ # Contact, mail, and logging (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-25 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for contact forms, mail delivery, logging, statistics, and GeoIP-aware operational insights. @@ -10,11 +10,12 @@ - Covers logging, statistics, GeoIP enrichment, privacy considerations, and operational visibility. - Depends on error handling, security, and configuration decisions. - Provides practical public interaction and operational insight without turning analytics into a surveillance feature. +- Uses the Security policy defaults for IP-derived data retention, security event projection posture, and abuse-correlation privacy ceilings. ## Outline The CMS should provide a reliable contact form and mail delivery foundation for project websites. Contact workflows should be protected by validation, CSRF where applicable, rate limiting, optional captcha, and safe error messages that do not leak delivery internals. -Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. +Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. GeoIP belongs in the security hardening plan as an observability feature before it is ever used for enforcement. The first implementation should prefer Symfony Mailer, Monolog, Rate Limiter, Validator, and explicit storage decisions. Module-provided integrations can add newsletter systems, CRM handoff, or alternative logging sinks later. Statistics may evaluate rotated logs over longer time ranges, but rotated logs should anonymize IP addresses while preserving acceptable GeoIP-derived location data. @@ -30,7 +31,9 @@ Audit logging should start with a simple proposed event set and remain easy to a - Use translated user-facing messages. - Do not expose mail transport errors directly to users. - Log delivery failures with safe context. -- Prefer filesystem-backed structured logs over database-backed operational logs for access, error, and security events. +- Keep filesystem-backed structured logs as the durable raw operational fallback for access, message, and audit events. +- Write database-backed lookup projections for message, audit, and access logs in parallel to the file channels so Admin UI, Admin API, and later Security features can query logs without scanning rotated files. +- Store passive abuse/security signals in `security_signal_event` as a separate domain event stream with policy-bounded retention. - Use stable structured log records that can be emitted as JSONL or converted to JSONL for UI filtering, scripted analysis, and retention jobs. - Start with separate channels for `access`, `error`, and `security`; keep anonymized statistics output separate from raw request-derived logs. - Define a mail template structure that themes may style but not silently break. @@ -38,14 +41,17 @@ Audit logging should start with a simple proposed event set and remain easy to a - Use GeoIP through a replaceable adapter or optional service boundary. - Use the installed MaxMind/GeoIP2 package as the first GeoIP provider implementation. - Treat GeoIP as a provider-backed service with local database storage, scheduled update support, and clear failure handling. +- Use GeoIP for logs, statistics, diagnostics, and later security review only. Geo-blocking or country-based deny policies require a separate explicit security decision. - Configure the MaxMind API key through a protected admin configuration UI and store it in database-backed configuration with access restricted to authorized administrators. - If no MaxMind API key is configured, disable GeoIP lookup, GeoIP database updates, and Geo-blocking without throwing hard errors. - Use `N/A`, `null`, or an equivalent normalized empty value for GeoIP log/statistic fields when GeoIP is disabled or unavailable. - Never write the MaxMind API key to logs, exported diagnostics, screenshots, fixtures, or public configuration. - Define retention controls before storing request-derived statistics. - Keep raw access logs with retraceable IP addresses for at most 30 days by default. +- Keep raw IP addresses, IP buckets, and stable IP-derived hashes queryable for at most 30 days across file logs, database projections, exports, diagnostics, and backup/restore workflows. - Anonymize IP addresses during rotation when logs are retained beyond the raw retention window, or write a parallel anonymized statistics log from the start. - Use log rotation for longer-running statistics. Rotated logs should anonymize IP addresses while retaining non-sensitive aggregate or GeoIP location data where allowed. +- Use the internal first-party visitor ID for longer-term statistics and correlation instead of extending IP retention. - Keep access/security event logs separate from aggregated statistics where practical. - Use a separate `audit` channel for high-impact administrator actions, starting with authentication events, backend maintenance/package lifecycle events, and settings saves that log changed keys but not submitted values. Keep audit logging configurable through Security settings with an enabled production default and selectable event categories. - Draft first audit-worthy events: global configuration changes, protected configuration changes, provider selection changes, theme lifecycle actions, module lifecycle actions, schema changes, import apply, backup restore, user/ACL changes, API token changes, and high-impact maintenance actions. @@ -64,6 +70,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test mail dispatch with Symfony test transports. - Test logs do not include secrets or full sensitive payloads. - Test rotated logs anonymize IP addresses while preserving allowed aggregate/GeoIP data. +- Test raw IP, IP bucket, and stable IP-derived hash retention does not exceed 30 days in file logs, database projections, exports, diagnostics, and cleanup jobs. - Test GeoIP adapter behavior with missing or invalid data. - Test missing MaxMind API key disables GeoIP lookup and Geo-blocking without hard errors. - Test GeoIP-disabled logs use normalized empty location values such as `N/A`. @@ -71,6 +78,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test GeoIP update failure handling once scheduled updates are implemented. - Test suspicious-event aggregation for probe paths, repeated 404s, and rate-limit hits. - Test access/security logs remain separable from anonymized aggregate statistics. +- Test any future database-backed security event projection against the same redaction, retention, and disabled-feature fallback rules as the file logs. - Test audit-worthy events are emitted with actor, action, target, timestamp, and safe redaction where implemented. - Run translation comparison after user-facing copy changes. @@ -78,14 +86,15 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Implement contact forms with Symfony Forms, Validator, Mailer, Rate Limiter, and optional captcha. - **Decision recorded:** Contact form storage is configurable: mail-only by default, with optional stored submissions. - **Decision recorded:** Treat GeoIP as optional and replaceable, with MaxMind/GeoIP2 as the first provider implementation because it is already installed. +- **Decision recorded:** GeoIP should be completed as an observability slice before any abuse-enforcement or blocking behavior depends on it. - **Decision recorded:** GeoIP should use a provider boundary with local database storage, scheduled update support, and safe failure behavior. - **Decision recorded:** The initial GeoIP boundary is `GeoIpResolverInterface` with a `NullGeoIpResolver` that returns normalized `n/a` fields. Access logging and statistics depend on the interface now, while MaxMind/local database lookup remains a later provider implementation. - **Decision recorded:** The MaxMind API key is configured through protected admin configuration and stored in database-backed configuration with restricted access. - **Decision recorded:** Missing MaxMind API key disables GeoIP lookup, GeoIP database updates, and Geo-blocking gracefully. Logs should continue with normalized empty GeoIP values such as `N/A`. - **Decision recorded:** Keep statistics operational and privacy-conscious. -- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. -- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context for UI filtering. -- **Implemented baseline:** Admin Logs browsing is split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser` so future export/API/support tooling can reuse smaller pieces. +- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request; normalized forwarding-header candidates may be mixed into that fallback only as untrusted differentiation entropy and never as Security identity, GeoIP input, ban key, or signal evidence. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent/forwarding-entropy fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. +- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. +- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves structured message, audit, access, and security-signal browsing to `DatabaseLogBrowser`, then exposes them through `AdminLogBrowser` alongside the Symfony application log as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. - **Decision recorded:** Access statistics run as a separate database-backed model in parallel to raw `access` files. Each request may write a short-lived raw access-log entry plus one `access_statistic_event` row with an internally generated request id, first-party cookie-derived anonymized visitor id, method, requested path, resolved route, surface, status, duration, referrer host, preferred language, content metadata, coarse browser family, device type, bot classification, and GeoIP placeholder fields. Raw access logs keep the full user-agent, IP/proxy hints, and an optional safe inbound `correlation_id` for operational review, but IP addresses, user-agents, inbound correlation ids, and raw visitor-cookie tokens are not stored in the statistics table. `X-Request-ID` and `X-Correlation-ID` are never reused as the internal request id. Request id, visitor id, requested path, and resolved route are exposed to Twig error pages as a visitor-shareable debug reference. The latest Admin Logs snapshot is still written below `var/statistics/{environment}/access/latest.json` as a cache/output boundary so a later scheduler or richer statistics module can replace the producer without changing the Admin Logs UI. - **Decision recorded:** The first Admin Logs statistics view supports fixed windows (`1h`, `24h`, `7d`, `30d`, `all`) over stored anonymized statistic events. Long-term compaction into daily/monthly aggregates remains deferred until the statistics feature grows beyond this foundation slice. @@ -94,3 +103,5 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** First audit-worthy events should include configuration, protected setting, provider, package lifecycle, schema, import, backup restore, user/ACL, API token, and high-impact maintenance changes. The first implemented events are login success, login failure, logout, backend maintenance action starts/completions, package ZIP verification starts, and package lifecycle action starts/completions. - **Decision recorded:** Defer a generic operations-message event until after the logger is implemented; start with explicit recorder/service boundaries and keep audit/access/security logs separate from the live ActionLog overlay. - **Decision recorded:** Built-in raw log channels use 30-day rotating files by default. Long-term statistics stay separate and may move to database-backed aggregates. +- **Decision recorded:** Security features write parallel database-backed lookup projections in addition to the 30-day rotating file logs. File logs remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. The DB projection is the primary Admin/API read path, duplicates only redacted/minimized fields instead of full raw log lines, keeps level/severity only where it supports meaningful filtering, purges by bounded retention after successful writes, uses UUID detail identifiers, and degrades without weakening enforcement or hiding file-log diagnostics. +- **Decision recorded:** IP addresses, IP buckets, and stable IP-derived hashes are short-retention security data and must remain queryable for at most 30 days. Longer-term statistics and security correlation should use the internal first-party visitor ID, authenticated user IDs where applicable, API key fingerprints where applicable, or aggregated anonymized dimensions. Backups, exports, diagnostics bundles, and database projections must not silently extend IP retention beyond the raw-log window. diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 24ed1741..50cb2389 100644 --- a/dev/draft/0.4.x-FrontendDeliveryCaching.md +++ b/dev/draft/0.4.x-FrontendDeliveryCaching.md @@ -1,7 +1,7 @@ # Frontend delivery and caching (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-17 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -18,6 +18,8 @@ The first implementation does not need a complex static-site generator, but it s Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, filesystem cache by default, AssetMapper hashed assets, and optional adapters later. Admin tools should show enough cache and delivery status that operators can recover from stale output without shell access. +The Security rate-limit `panic` profile is also intended as the future switch point for an operational cache panic mode. During DDoS-like or extreme anonymous traffic events, the delivery layer may set a short-lived TTL lock that forces anonymous public traffic onto cache-backed delivery only, while the Security layer applies the strictest rate limits. This mode must protect public read delivery without bypassing authentication, ACL checks, CSRF, probe blocking, audit, or Owner/Admin recovery paths. + ## Technical Specifications - Define public delivery services separate from editor/admin write services. - Use content route resolver output, theme resolver output, schema rendering, media URLs, and resolver context through a controlled read path. @@ -30,6 +32,8 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - Keep snapshot-like export/read artifacts optional and adapter-backed until a concrete performance need requires them. - Include public delivery behavior in backup/restore and self-update compatibility checks when generated artifacts or metadata are involved. - Keep private or ACL-restricted content out of public cache entries unless the cache key and delivery path are permission-aware. +- Design a cache panic mode that can be activated manually or by a later abuse/operations signal through a bounded TTL lock. While active, anonymous public requests should be served from already-safe public cache entries where possible, cache misses should fail predictably or use an explicitly allowed lightweight fallback, and authenticated/Admin/Owner paths must keep their normal security and recovery behavior. +- Coordinate cache panic mode with the Security `panic` rate-limit profile instead of creating a separate public knob with conflicting semantics. ## Testing & Validation - Test public route rendering uses published content only. @@ -38,11 +42,15 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - Test ACL-restricted content is not leaked through public caches. - Test rebuild/warmup/prune commands. - Test admin maintenance actions for cache and delivery diagnostics once UI exists. +- Test cache panic mode activation, TTL expiry, anonymous cache-hit behavior, cache-miss fallback behavior, and authenticated/Admin/Owner bypass boundaries once implemented. - Run asset build commands after frontend delivery asset changes. ## Implementation Notes - **Decision recorded:** Public delivery should use a controlled read path separate from mutable editor workflows. - **Decision recorded:** Symfony-native cache and HTTP headers are the first delivery foundation; external cache/CDN integrations remain optional. - **Decision recorded:** Publish and lifecycle events must define cache invalidation or rebuild behavior. +- **Decision recorded:** The Security `panic` profile is the intended coordination point for a future cache panic mode: a bounded TTL lock may force anonymous public traffic to cache-backed delivery during DDoS-like events while strict rate limits remain active. +- **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache and Admin ACL feature/override/group matrix caches, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. +- **Open:** Define cache panic trigger sources, TTL bounds, lock storage, anonymous cache-miss behavior, operator diagnostics, and the exact coupling to the Security rate-limit mode before implementation. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index 8cc59c27..52d4d256 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -1,13 +1,14 @@ # IconCaptcha integration (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for an optional IconCaptcha provider module built on the generic captcha extension contract. ## Overview - Defines the planned IconCaptcha implementation as a replaceable provider module. - Depends on security, setup, error handling, public form workflows, and plugin module extensibility. +- Belongs to the broader security hardening cut, but should be implemented in its own provider branch after the generic captcha contract lands. - Keeps the CMS core focused on a generic captcha contract while allowing IconCaptcha to ship its own services, templates, assets, translations, and validation rules. - Uses the old Grav `sec-lookup` implementation only as inspiration. No old code, secrets, or hard-coded asset paths should be copied into the Symfony project. @@ -20,15 +21,22 @@ The core captcha integration should expose a global form field. When a workflow The provider module should own its icon and emoji assets. These assets should live inside the module package rather than in the application core so IconCaptcha remains removable and replaceable. The core should only know that the selected provider can render a challenge, validate a submitted answer, and report recoverable validation failures. +The asset set must be chosen deliberately before implementation. Prefer permissive open-source-compatible licenses such as MIT, Apache-2.0, CC0, or similarly permissive terms, and record provenance and license notes with the provider package. Unclear asset provenance blocks the provider branch until the asset is replaced or the license is confirmed. + +Inline-rendered graphics and symbol SVGs must be bot-resistant. Do not expose answer-bearing names through file names, SVG IDs, CSS classes, `data-*` attributes, translation keys, titles, descriptions, hidden text, or ARIA labels. Use opaque challenge-local identifiers for rendered options and neutral control labels that describe interaction state without naming the target symbol. If neutral labels are not sufficient for assistive technology, implement a separate accessible fallback flow instead of leaking the answer through labels. + +The preferred accessible fallback is a quiz-style IconCaptcha mode. Instead of naming the visual icon choices through ARIA, the provider can render a spoken question/task and multiple neutral answer options. The submitted answer is then validated through the same provider challenge model as the visual captcha: one challenge ID, bounded configured TTL, one-shot invalidation, context binding, refresh behavior, and passive abuse signals. Quiz prompts and answer options must come from vetted provider-owned pools, avoid stable answer-bearing DOM metadata, and use opaque option IDs just like visual icon choices. + The challenge should be deterministic from server-side state, but unpredictable to the client. A challenge ID, timestamp, request context, and application/module secret can derive the icon pool, target icon, and button order. The server can then recompute the expected answer during validation without persisting the whole challenge payload. The challenge ID must still be marked as used after validation to prevent replay. The frontend should keep the interaction lightweight and accessible: one target icon, a fixed button grid, hidden form fields for challenge metadata, a refresh control, and clear error feedback from the normal form validation layer. A two-slot challenge approach is preferred for smooth refresh behavior: switch immediately to a standby challenge, then refill the hidden slot asynchronously. -IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. +IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. Rate-limit resets, budget recovery, or captcha-based `429` recovery require verified success from an active provider-backed challenge; provider `none`, missing-provider, or disabled-provider auto-success must not be treated as human proof. ## Technical Specifications - Implement IconCaptcha as a first-party package with `captcha-provider` scope, for example `packages/icon-captcha/`. - Store provider assets in the module package, for example `assets/icons/` and `assets/emoji/`. +- Prefer MIT, Apache-2.0, CC0, or similarly permissive asset licenses, and commit package-local provenance/license notes for every bundled icon or graphic set. - Use package-owned templates, styles, JavaScript, translations, and PHP services. - Keep the package compatible with the module manifest rules from the plugin modules draft. - Register IconCaptcha through the generic captcha provider contract, for example a tagged provider service. @@ -46,20 +54,23 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Bind challenge derivation to server-side secret, challenge ID, timestamp, session identifier where available, user agent, and route or workflow key. - Keep the public challenge payload limited to data needed for rendering and validation submission. - Mark challenge IDs as one-shot values in a Symfony cache pool after validation, regardless of success or failure. -- Expire challenges after a short configurable TTL. +- Expire challenges after the bounded TTL defined in the Security policy defaults. - Use cache pools or another Symfony-native storage mechanism for used challenge IDs and short-lived challenge metadata. - Sanitize SVG assets before rendering inline SVG in buttons, or serve them through a trusted static asset path with a fixed allowlist. -- Keep icon identifiers stable and non-sensitive. +- Keep internal icon identifiers stable and non-sensitive, but never render answer-bearing names into public DOM, asset URLs, CSS classes, JavaScript state, translation keys, or ARIA labels. +- Generate challenge-local opaque option IDs so a bot cannot infer the answer from semantic asset names or DOM metadata. - Use fixed-size UI elements so the challenge does not shift the form layout. -- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and non-visual labels through translations where needed. +- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and neutral non-visual labels through translations where needed. Labels must not name the target symbol or correct answer. +- Provide a documented accessible quiz fallback if neutral labels make the visual challenge unsuitable for assistive technology users. +- Keep quiz questions, answer options, and prompt identifiers provider-owned and reviewable, but do not render stable internal prompt keys or correct-answer identifiers into public markup. - Provide a refresh endpoint or provider action that returns a fresh challenge without caching. -- Protect refresh endpoints from aggressive abuse with a route-specific limiter, but avoid charging normal human refreshes too heavily. +- Keep refresh endpoints lightweight. If refreshes are served through `/api/live/**`, aggressive abuse should feed passive security signals instead of normal rate-limit `429` responses. - Refresh challenges after browser back-forward cache restores. - Use Symfony Validator constraints, form-level validation, or a shared captcha form field type for user-facing errors. - Send recoverable captcha errors through the normal validation and error-handling flow. - Treat honeypot failures as suspicious signals that may be silent, while normal missing/expired/wrong captcha input should be recoverable for humans. -- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for captcha refreshes, form posts, captcha failures, and suspicious probe traffic. -- Refund or soften form-post rate-limit costs after successful captcha validation so human users are not unnecessarily penalized. +- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for form posts, captcha failures, and suspicious probe traffic, while `/api/live/**` challenge refreshes remain passive abuse signals. +- Reset or soften the relevant scoped limiter buckets after verified provider-backed captcha validation when the workflow policy allows it, so human users are not unnecessarily penalized. Provider `none`, missing-provider, or disabled-provider auto-success never resets buckets. - Avoid a single coarse per-IP bucket as the only rate-limit mechanism. - Consider progressive response behavior: allow, throttle, require captcha, then block only for strong signals. - Log provider failures with safe context such as workflow, route, failure code, and anonymized request identifiers where appropriate. @@ -137,7 +148,8 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test successful and failed captcha validation paths. - Test recoverable validation feedback on public forms. - Test rate-limited captcha failure behavior where applicable. -- Test successful captcha validation refunds or softens form-post rate-limit costs. +- Test verified captcha validation resets or softens relevant scoped limiter buckets. +- Test provider `none`, missing-provider, and disabled-provider auto-success never resets or softens limiter buckets. - Test that disabling IconCaptcha or selecting another provider does not break workflows using the generic contract. - Test provider value `none` validates successfully. - Test missing or disabled provider modules validate successfully unless a later provider-required policy is introduced. @@ -147,28 +159,35 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test used challenge IDs cannot be replayed. - Test expired challenges fail cleanly. - Test context-bound challenges fail when submitted in an invalid workflow or route context. -- Test refresh endpoint responses are uncached and rate-limited separately from form posts. +- Test refresh endpoint responses are uncached and feed passive abuse signals without normal `/api/live/**` rate-limit rejection. - Test browser back-forward cache recovery refreshes stale challenges. - Test SVG/icon asset loading, allowlisting, and sanitization behavior. - Test keyboard interaction and accessible button state where practical. +- Test rendered DOM, inline SVG, ARIA labels, asset paths, serialized challenge payloads, and frontend state for answer-bearing names or reusable answer material. +- Test accessible quiz fallback success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance if quiz mode ships in the branch. +- Verify asset license/provenance notes for every bundled graphic and symbol set. - Test translation keys for visible provider labels, buttons, and validation errors. - Test that provider failure logs do not contain secrets, seeds, raw challenge internals, or disallowed personal data. ## Implementation Notes - **Decision recorded:** Do not implement IconCaptcha as part of the first security foundation; implement the generic captcha extension contract first. +- **Decision recorded:** IconCaptcha is part of the overall security hardening feature cut, but the provider implementation should live in a dedicated branch after the generic captcha contract branch. - **Decision recorded:** Implement IconCaptcha as an optional provider module, not as a hard-coded core feature. - **Decision recorded:** Captcha integrates through a global form field that delegates to the configured provider. - **Decision recorded:** Provider selection is configurable. With only the first-party provider installed, choices are `icon_captcha` and `none`. - **Decision recorded:** Provider value `none`, missing providers, and disabled provider modules validate successfully to avoid breaking workflows. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected protected workflows. - **Decision recorded:** Store IconCaptcha SVG and emoji assets inside the IconCaptcha module package when implementation begins. +- **Decision recorded:** Prefer permissive open-source-compatible asset licenses such as MIT, Apache-2.0, and CC0, with provenance/license notes committed in the provider package. +- **Decision recorded:** Inline-rendered graphics, symbol SVGs, DOM metadata, and ARIA labels must not reveal answer-bearing names; use opaque option identifiers and neutral labels. +- **Decision recorded:** If neutral ARIA labels are insufficient, prefer a provider-owned quiz challenge with spoken question/task text and multiple answer options over leaking visual answers through labels. - **Decision recorded:** Use the old Grav `sec-lookup` IconCaptcha only as inspiration; do not copy old code or secrets. -- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. +- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a 15-minute TTL. The longer TTL is intended to support longer form completion time and relies on one-shot invalidation, context binding, captcha-failure buckets, refresh abuse signals, and answer-leak tests to prevent brute-force guessing. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. - **Decision recorded:** Avoid a single coarse per-IP rate limiter; use workflow-specific limiters and progressive abuse handling. -- **Decision recorded:** Successful captcha validation should refund or soften form-post rate-limit costs for human users. +- **Decision recorded:** Successful provider-backed captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. Provider `none`, missing-provider, or disabled-provider auto-success does not count as human proof and must not reset buckets. - **Decision recorded:** Keep provider-specific templates theme-compatible but package-owned. - **Required decision:** Define the exact generic `CaptchaProvider` interface before implementation. - **Required decision:** Define the provider secret storage location and rotation behavior. -- **Required decision:** Define the exact icon asset set, asset license notes, and SVG sanitization policy. +- **Required decision:** Define the exact icon asset set and SVG sanitization policy before implementation. Asset license/provenance notes are required branch output, not optional follow-up. - **Deferred:** Exact challenge payload shape, frontend controller implementation, and UI styling belong to the implementation phase. diff --git a/dev/draft/README.md b/dev/draft/README.md index a08dbb25..51b994d0 100644 --- a/dev/draft/README.md +++ b/dev/draft/README.md @@ -1,7 +1,7 @@ # Project Outline and Feature Drafts > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Description of planned features and technical specification drafts for use as guidance alongside implementation. @@ -47,6 +47,9 @@ The feature drafts should be created in dependency order. Start with architectur ### 0.2.x security and extension baseline drafts - [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security hardening implementation plan](0.2.x-SecurityHardeningPlan.md) +- [Security policy defaults](security-hardening/policy-defaults.md) +- [Security Admin ACL enforcement plan](security-hardening/admin-acl-enforcement.md) - [Admin interface and setup UI](0.2.x-AdminInterfaceSetupUi.md) - [Package modules and providers](0.2.x-PluginModules.md) - [Event hooks and buses](0.2.x-EventHooksBuses.md) diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md new file mode 100644 index 00000000..bb019cb9 --- /dev/null +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -0,0 +1,137 @@ +# Abuse foundation branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-abuse-foundation` implementation plan. + +## Goal + +Introduce Studio-owned request classification, subject resolution, action costs, and passive suspicious-signal recording before any broad enforcement or auto-ban behavior is enabled. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- [Security policy defaults](policy-defaults.md). +- Existing visitor identity, access logging, audit logging, API-key authentication, Scheduler API authentication, and `/api/live/**` route boundaries. +- Symfony Request data and Turbo/browser prefetch headers. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for suspicious-request categories, passive-signal examples, and diagnostics language. Current subject-resolution, privacy, database-portability, and no-enforcement decisions in this branch have priority. Do not copy legacy logic or framework-specific request handling directly. + +## Implementation sequence + +1. Add an abuse namespace with value objects for subject, request family, request intent, action cost, and passive signal. +2. Add subject resolution for IP bucket, visitor ID, authenticated user UID, API key UID/prefix, and safe combined subject keys through one reviewed client-identity resolver. +3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, CORS preflight, exact `/cron/run` scheduler trigger, login, registration, password reset, exact setup review apply, package/admin operation, upload/archive validation, export/download, import, public form submits, and suspicious probe. +4. Add a central action-cost catalogue with website and API families. Costs are symbolic defaults, not limiter calls yet. +5. Add database-backed message, audit, and access log projections in parallel to the existing 30-day rotating file logs. +6. Move Admin/API log browsing from file scanning to the database projection, with one tab/source for message, audit, access, and security-signal events. +7. Add database-backed passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. +8. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. + +## Public interfaces and data decisions + +- Controllers and future packages call the Studio-owned `AbuseRequestInspector` facade instead of Symfony RateLimiter directly. The facade combines `AbuseSubjectResolver`, `RequestIntentClassifier`, and `ActionCostCatalogue`; later branches may add enforcement around this boundary without moving classification logic into controllers. +- Existing Monolog file channels remain the raw operational fallback with 30-day retention; database log projection tables are lookup/read-model copies for Admin UI, Admin API, and later abuse correlation. +- The first projection uses one table per log family: `message_log_entry`, `audit_log_entry`, and `access_log_entry`; passive security signals use the domain event table `security_signal_event`. +- Projection writes must happen after the normal file-log payload has been normalized/redacted and must never store raw secrets, credentials, API keys, visitor-cookie material, captcha answers, unredacted tokenized URLs, or duplicated full raw log lines. +- Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. +- Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. Read paths must also bound selected time windows by the configured retention so quiet sites or recently lowered retention settings cannot expose older projected rows until the next write. +- Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. +- Admin Logs and `/api/v1/admin/logs/**` read through `AdminLogBrowser`: structured message, audit, access, and security-signal sources come from database projections, while the Symfony application log remains a deliberate file-backed source because it is not projected into the database. The file-backed source is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. +- This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. +- Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. +- The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. +- JSON context search must stay portable across SQLite, MariaDB/MySQL, and PostgreSQL. Database-backed log browsing casts JSON context columns to text for broad case-insensitive free-text matching instead of applying string operators directly to JSON-typed columns or materializing the full result set in PHP. +- Log browsing must use explicit bounded page sizes only. The former `all` option is intentionally treated as a 500-row page with normal pagination so large logs cannot silently exhaust memory/time or hide rows behind a misleading one-page view. The effective page must be clamped before fetching rows so UI/API metadata and row contents stay consistent after filters reduce the total. +- The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. +- Security identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and security signals, rate-limit subjects, bans, GeoIP, and audit decisions must not trust raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to reduce accidental visitor merging when the same resolved IP presents different `X-Forwarded-For` chains. Those raw header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. +- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. This is a deliberate trade-off: spoofable forwarding entropy may change a cookie-less fallback Visitor-ID, but it must not change the stable IP-bucket HMAC derived from Symfony's resolved client IP. Later rate-limit and auto-ban enforcement must evaluate Visitor-ID and IP-bucket evidence together, with IP thresholds kept laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still catching clients that rotate cookies or forwarding entropy. +- Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. +- Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. +- Subject resolution emits visitor, IP-bucket, authenticated-user, API-key, safe API-key-prefix, and combined subjects. IP buckets and combined IP subjects are HMAC-derived and never expose raw IP addresses; invalid Bearer tokens may contribute only a validated public prefix, never submitted secret material. +- Probe-path detection is configurable through a simple editable pattern-list setting, not a raw JSON field. The UI should present one regular expression per line and may accept quoted CSV imports for convenience; unquoted newline entries must be preserved as-is so commas inside regex syntax such as `{4,6}` remain valid. Empty or invalid lists fall back to protected defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. +- The normalized probe-pattern list may use a small Symfony-native cache to keep the passive subscriber out of the request-time configuration hot path. Security settings saves must invalidate that cache, and the future unified caching strategy should re-evaluate whether this feature-local cache should move into a shared namespace/invalidation model. +- Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and anonymous CORS preflight receive no ordinary enforcement cost in this branch; credentialed API preflights are classified by their requested method, while suspicious probes, exact setup review apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. +- Public-facing unsafe requests that are not classified as a more specific workflow, for example future contact alternatives, comments, forum posts, package-provided public forms, or other user-submitted public content actions, must fall back to the dedicated `website_form`/`FormSubmit` bucket instead of the cheap navigation bucket. +- Contact forms, captcha challenges, and package-owned public workflows must not be inferred from invented or path-only slugs such as `/contact` or `/captcha/refresh`. Public content may legitimately use those slugs; later feature branches must opt into special intents through real route names, explicit workflow metadata, or provider-owned `/api/live/**` endpoints. +- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. It writes Visitor-ID and IP-bucket HMAC context where available, never raw IP or forwarding-header values, and does not alter the response. +- `SessionVisitorBindingSubscriber` also records the already-enforced session/visitor mismatch as a high-risk passive signal before terminating the session. This does not solve copied session plus copied visitor-cookie risk scoring by itself; that deeper scoring remains a later Security/remember-me concern. +- First implementation uses the portable `security_signal_event` table for short-lived passive signals. Suggested fields are normalized subject type/key, request family, intent, reason code, confidence, weight/count, timestamps, expiry timestamp, safe context, and optional audit reference. +- Passive-signal rows are observational only in this branch. The rate and auto-ban branches decide how to consume them for enforcement. +- Keep passive signals separate from raw file logs and from the message/audit/access projections. Later branches may consume `security_signal_event`, but this branch does not enforce from it. +- Security signals use one shared retention setting, bounded to 1-30 days. Signal rows may include an IP-bucket HMAC for review/correlation, but never raw IP addresses or raw forwarding-header values, and the entire row must stop being visible once the shared expiry is reached. +- TTL and expiry use Symfony's injectable clock/time boundary for deterministic tests. +- Security-signal list and detail reads must filter expired rows by `expires_at` as well as the selected time window, so short-retention signals stop being visible even on quiet sites where no later write has triggered purge cleanup. +- Classification must expose enough request-family, intent, subject, Admin/Owner context, `/api/live/**`, and recovery-login metadata for later branches to follow the Security policy enforcement order without re-reading controllers. +- Probe-path configuration uses anchored, normalized patterns and must be tested against normal app/package/media/editor routes to avoid false positives. +- High-impact operation intents must exist even when their first implementation only records passive signals: setup apply, settings mutation, user/ACL mutation, package lifecycle, backup/restore, import apply, export/download, self-update, scheduler run-now, diagnostics/support bundles, and upload/archive validation. +- Admin-family unsafe requests must be classified before broad public keyword rules such as `password` or `reset`, so Admin user password-reset actions and package `reset-fault` lifecycle actions are assigned to Admin/ACL/package buckets instead of public password-reset buckets. +- Classification should include the resolved Admin/Owner authority outcome for high-impact operations so rate/ban diagnostics can distinguish a denied delegated Admin action from anonymous/API abuse. +- CORS preflight classification must distinguish allowed preflights from invalid origin/method/header combinations so the API layer can stay cheap for valid browser clients while still recording suspicious probing. + +## Edge cases + +- Missing visitor cookie uses the existing fallback visitor identity. +- Invalid Bearer API keys should still classify as API activity without trusting the key as an authenticated subject. +- Authenticated Owner requests still classify normally; Owner lockout protection is enforced in later branches. +- High-signal probe paths are suspicious even when the route does not exist or is only a honeypot; later enforcement should return a generic `400` without revealing route existence. +- Prefetch for state-changing methods is suspicious; normal GET prefetch remains low-confidence. +- Setup/install requests happen before an Owner session exists, so classification must not depend on authenticated recovery context for pre-setup protection. +- During setup and pre-database states, file logging remains available but database projections and signal persistence must be skipped before any DBAL call. +- Upload, package, import, backup, and restore paths must not be classified as high-signal probes solely because their filenames resemble archive/database defaults; failed validation results should emit separate upload/archive signals. +- Expired passive signals must not affect later enforcement once rate/ban branches start consuming the store. +- Passive-signal storage failure must not change request outcome in this foundation branch. Safe diagnostics may be added later if they do not create logging loops or setup/database readiness problems. +- Passive signal recording must be best-effort and must not change request outcome. +- Database log projection storage failure must not break the request or the raw file log write. The UI may show fewer projected rows while file logs remain the operational fallback. +- Cleanup must remove or anonymize expired IP-derived signal keys before any Admin export, support bundle, or statistics projection can expose them. + +## Tests and validation + +- Test subject resolution for anonymous, visitor-cookie, authenticated user, valid API key, invalid API key, and scheduler trigger. +- Test intent classification for browser, prefetch, API read/write/preflight, `/api/live/**`, login, registration, password reset, exact setup review apply, exact `/cron/run` scheduler trigger, privileged admin operations, upload/archive validation, export/download, and suspicious probes. +- Test configurable probe-path defaults, newline and quoted-CSV pattern parsing, invalid-pattern fallback, comma-bearing regex syntax, cached config reads, and high-signal probe classification. +- Test suspicious probe rules do not collide with legitimate upload, package, import, backup, restore, media, and editor routes. +- Test probe-pattern normalization and false-positive avoidance for ordinary application routes. +- Test redaction in passive signal messages. +- Test session/visitor mismatch signal recording without changing the existing forced logout and audit behavior. +- Test database log projection writes for message, audit, and access logs without bypassing the existing file-log path. +- Test database projection and signal recorder no-op before touching DBAL while setup/database readiness is false. +- Test projection retention purge-after-write behavior and the 30-day maximum for configurable lookup retention. +- Test Admin/API log browsing reads database projections, uses UUID detail links, exposes source tabs, keeps broad free-text matching for hidden identifiers/context, shows only meaningful filters per tab, omits raw-line storage, omits access/audit level filters, and hides `DEBUG`/`INFO` by default for level-aware sources unless selected. +- Test passive-signal persistence, aggregation by normalized subject/intent/reason, expiry filtering, and cleanup command/task behavior. +- Test shared security-signal retention stays below 30 days, applies consistently to Visitor-ID and IP-bucket context, and filters expired rows from list and detail reads even when no later write has purged them yet. +- Test client-identity behavior by asserting the foundation uses Symfony's resolved request IP for security subjects and does not trust raw forwarding headers for signals, bans, GeoIP, or audit decisions; do not introduce app-managed trusted-proxy configuration in this slice. +- Test storage-failure degradation. +- Test no limiter or ban enforcement occurs in this branch. + +## Documentation and tracking + +- Update Security draft with facade and classification names if they become stable public extension points. +- Update class map for the facade, resolver, classifier, catalogue, and value objects when they are added. +- Update class map for the database log browser/projector, retention policy, and passive-signal recorder if they are added. +- Record default cost catalogue decisions in the worklog. +- Update Security policy defaults if implementation evidence changes signal retention, subject composition, or suspicious-intent weighting. +- Record whether the branch keeps only the passive-signal store or also introduces/reuses a broader security event projection. +- Carry a follow-up into `feat-security-admin-acl-enforcement` for Owner/ACL-controlled visibility and mutation of security signals, IP-bearing access projections, exports, cleanup operations, and future signal review actions. +- Carry a follow-up into the Editor/Content slice: when an editor sets or changes a content route/slug that would match a configured suspicious probe path, show a non-blocking warning before saving so legitimate content is not accidentally placed under a high-signal probe namespace. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No `429` responses. +- No rate limiter bucket consumption. +- No auto-ban or captcha provider logic. + +## Acceptance criteria + +- Later branches can enforce limits and bans through one facade. +- Passive signal output is useful for review without affecting user traffic. diff --git a/dev/draft/security-hardening/admin-acl-enforcement.md b/dev/draft/security-hardening/admin-acl-enforcement.md new file mode 100644 index 00000000..bfb5dd4e --- /dev/null +++ b/dev/draft/security-hardening/admin-acl-enforcement.md @@ -0,0 +1,195 @@ +# Admin ACL enforcement branch plan + +> **Status**: Draft +> **Updated**: 2026-06-16 +> **Owner**: Core +> **Purpose:** Define the `feat-security-admin-acl-enforcement` implementation plan. + +## Goal + +Introduce a shared Admin action authority policy that separates delegated Admin capabilities from Owner-only site-control actions across Admin UI, API handlers, live operations, scheduler/admin controls, and service-layer workflows, then expose the resulting matrix through an Owner-gated `Settings/ACL` view. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +This branch should make it obvious which Admin features are operational delegation and which are site-control powers. The first implementation should ship safe code-owned defaults plus a bounded Owner-only configuration surface for the permissions explicitly marked configurable. + +Descriptors are intentionally domain-owned. Core provides the first lightweight registry/provider boundary so relevant domains can register thematic features and default states without every element or action becoming a new permission. UI controls, backend actions, API handlers, and other callers should attach a simple stable feature key only where granular gating is required. Generic infrastructure such as Live Operations stays ungated by default; the domain-specific caller that starts a sensitive operation enforces the feature key before queueing or confirming work. + +Feature keys follow operational responsibility rather than whichever view happens to render the control. A Settings, Operations, or User view may therefore invoke a different domain feature when the action mutates that domain. ACL group definition management belongs to `admin.users.acl`; assigning those groups to a user account belongs to `admin.users`; pending account-token review actions belong to `admin.users.review` even when the button is rendered from the user-management view; confirming a queued package continuation from Operations must still require the relevant package feature; and trusted registered Scheduler tasks are governed by `admin.scheduler` rather than by every feature touched by the task implementation. + +The first implementation caches the Admin ACL feature registry, configured overrides, and ACL-group availability through the same small Symfony cache pattern used by suspicious-probe matching: service-local memory, `cache.app` when available, a short 300-second TTL, safe fallback on cache failures, and explicit invalidation after matrix saves, ACL-group mutations, and package lifecycle or registry changes that affect dynamic package-settings rows. This is a feature-local performance guard for the matrix and permission checks, not the final cross-domain cache architecture. + +Owner-facing ACL settings should expose a bounded configuration matrix with one row per protected feature/action: + +| Column | Purpose | +| --- | --- | +| Feature | Stable machine-readable feature identifier, for example `admin.settings.statistics.geoip`, `admin.packages`, or `admin.settings.packages.{package_slug}`. | +| Surface | `admin`, `editor`, or `frontend`, used for grouping and for the non-bypassable surface gate. | +| Mode | Whether the permission controls hidden, read-only, or mutate behavior. | +| Required ACL | Default or configured `AccessRule`, expressed as a minimum access level plus optional ACL groups. | +| Configurable | Whether Owners may change the required ACL in the ACL settings UI. Non-configurable rows stay visible read-only for transparency. | + +The same matrix must feed Admin UI visibility, Editor UI visibility where applicable, API handlers, live operations, scheduler/admin triggers, and service-layer mutation checks. Navigation remains only a projection of the policy; backend enforcement stays authoritative. + +The Admin surface gate remains the Admin access level. Optional ACL groups may define explicit per-feature states after the surface gate is satisfied. If a matching group state exists, the group state overrides the role/default state; with several matching groups, the highest explicit group state wins. This allows groups to grant more access than the role/default state or deliberately restrict a user below the role/default state. Groups must not let a user bypass the Admin or Editor surface gate itself. For Admin ACL granularity, configurable rows may delegate selected denied/visible/mutable permissions to `ROLE_ADMIN` users. Editor ACL granularity should reuse the same descriptor and evaluation model later, but it will cover the Author, Publisher, Curator, Manager, Director, and Admin tiers. Frontend ACL granularity is not designed in this branch and should only be prepared as a future surface, not preimplemented for nonexistent features. + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing Symfony role hierarchy and `AccessLevel` model. +- Existing `AdminUserAccessPolicy` guardrails for peer roles, group assignment, self-lockout, and last-Owner protection. +- Existing backend access guard/controller context, Admin controllers, API access guard, live-operation starter/provider boundaries, and settings/package/scheduler foundations. +- [Security policy defaults](policy-defaults.md). + +## Implementation sequence + +1. Inventory current Admin surfaces, including settings, users/groups, package/theme management, scheduler, operations, logs/audit, backups, diagnostics, API management, and future security settings. +2. Define stable Admin feature identifiers grouped by domain, using the surface prefix as the grouping source (`admin.*`, `editor.*`, `frontend.*`). +3. Add an Admin action catalogue with metadata: identifier, domain, title/description translation keys, default minimum role or ACL group rule, sensitivity, mutation/read flag, configurable flag, audit category, and optional confirmation requirement. +4. Add a shared Admin action authority policy service that evaluates actor access level, action identifier, target context, target subject, account status, and optional workflow metadata. +5. Encode the first static default matrix in code: delegated Admin read/mutate actions, Owner-only actions, and denied/unknown actions. +6. Add the bounded Owner-only configuration descriptor and persistence model for configurable rows, including validation for allowed role range, optional ACL-group grants, corruption fallback, and audit. +7. Build the compact Owner-gated `Settings/ACL` matrix grouped by surface and feature area, with hidden/read-only/mutate state visible per row and disabled controls for non-configurable rules. +8. Wire enforcement at service/API/live-operation boundaries for implemented high-impact Admin workflows, starting with the existing user-management policy as the model and avoiding duplicated controller-only checks. +9. Update Admin navigation/read models to hide or disable forbidden actions using the same policy, while keeping backend enforcement authoritative. +10. Add audit/message context for denied high-impact actions without leaking protected values or action internals. +11. Add extension points for future package-owned Admin actions only after core action identifiers and collision rules are stable. + +## Implemented first feature keys + +| Feature key | Default | Configurable | Scope | +| --- | --- | --- | --- | +| `admin.settings.security` | Denied | No | Security settings area. | +| `admin.settings.logging` | Visible | Yes | Log retention settings. | +| `admin.settings.statistics` | Mutable | Yes | Statistics settings and statistics view. | +| `admin.settings.statistics.geoip` | Visible | Yes | GeoIP fields and update action, parent-gated by statistics. | +| `admin.settings.api` | Denied | Yes | API settings area. | +| `admin.settings.scheduler` | Visible | Yes | Scheduler settings area. | +| `admin.logs` | Visible | Yes | Admin log review area; Audit and Security Signal sources require Mutable access. | +| `admin.packages` | Visible | Yes | Package and theme management area, with mutating lifecycle/install/discovery disabled unless mutable. | +| `admin.backup_restore` | Visible | No | Backup/restore area; restore remains mutating. | +| `admin.packages.self_update` | Denied | No | System package self-update transparency row; no current self-update mutation route exists in this slice. | +| `admin.support` | Denied | No | Support bundle transparency row. | +| `admin.operations` | Visible | Yes | Operations view. | +| `admin.actions.maintenance` | Mutable | Yes | Cache clear and asset rebuild actions. | +| `admin.scheduler` | Visible | Yes | Scheduler operational area. | +| `admin.settings.packages` | Visible | Yes | Core package settings area. | +| `admin.settings.packages.{package_slug}` | Mutable | Yes | Active package-owned settings page, registered dynamically and removed from ACL config during package purge. | +| `admin.users` | Mutable | Yes | User administration. | +| `admin.users.acl` | Mutable | Yes | User ACL-group administration. | +| `admin.users.review` | Mutable | Yes | User review queues. | + +## Default authority matrix + +The first matrix should use conservative defaults. "View" means the actor may open a redacted screen or summary. "Mutate" means the actor may trigger the workflow after its own CSRF, confirmation, domain validation, and audit checks also pass. + +| Area | Admin default | Owner default | Notes | +| --- | --- | --- | --- | +| Dashboard/system status | View | View | Admin-visible status must be redacted and avoid secrets, raw env dumps, cookies, and request payloads. | +| General site settings | View and mutate bounded site-specific values | View and mutate | Examples: site title, footer text, default language, home path, simple dashboard preferences. | +| User management | View and mutate non-owner/non-peer-admin users within existing role/group guardrails | View and mutate all users within last-Owner guardrails | Existing `AdminUserAccessPolicy` stays authoritative for peer-role, group, self-lockout, and last-Owner rules. | +| ACL groups | View and mutate groups below actor role | View and mutate all groups within system constraints | Group impact review remains mandatory for broad updates/deletes. | +| Registration/user-flow settings | View and mutate low-risk workflow settings | View and mutate | TTLs and notification addresses are Admin-mutable only if they do not affect Owner recovery, security policy, or protected secrets. | +| Mail settings | View and mutate non-secret sender settings | View and mutate, including protected transport status/config where implemented | Mail transport secrets remain protected/write-only. Production delivery guards remain enforced. | +| Security settings | View redacted status only | View and mutate | Captcha provider selection may be Admin-mutable only if it cannot disable required protection or verified recovery policy. Auto-ban disablement, privacy ceilings, recovery protections, and rate/security policy bounds are Owner-only. | +| Access/audit/security logs | View redacted summaries | View redacted summaries and broader review tools | Raw secrets, raw tokens, full request payloads, and IP-derived data beyond retention are never exposed. Audit and Security Signal sources require mutable `admin.logs` access. Full diagnostic/export actions are Owner-only. | +| Statistics and GeoIP status | View summaries | View and mutate GeoIP enablement, database path, license key, and update task | MaxMind license material is protected/write-only. GeoIP cannot become blocking policy in this slice. | +| API settings | View status and own/user-token surfaces where already allowed | View and mutate global API settings | Enabling public API/CORS expansion, wildcard-like origins, or broad anonymous access is Owner-only. | +| Package/theme overview | View installed/available status | View and mutate | Installing, activating, deactivating, updating, purging, and running package lifecycle actions are Owner-only by default. | +| Package settings | View and mutate simple non-sensitive package settings if the package declares them Admin-safe | View and mutate all package settings within package policy | Package settings that alter routes, permissions, external credentials, data access, or runtime code are Owner-only. | +| Scheduler | View status and run trusted registered tasks when allowed | View and mutate | Enabling scheduler web trigger, GET-token fallback, package ActionQueue tasks, or destructive tasks is Owner-only. Trusted core tasks run under `admin.scheduler`; package-provided ActionQueue tasks still require the package ActionQueue setting and explicit activation. | +| Operations/action logs | View redacted operation summaries | View redacted summaries and emergency controls | Clearing stale locks may be Admin-safe; emergency stop/kill, retrying destructive operations, and continuation of Owner-only workflows are Owner-only. | +| Cache/asset rebuild | Mutate when non-destructive and recoverable | Mutate | Rebuilds remain audited and use the live-operation shell. | +| Backup/export/download | View redacted status | View and mutate | Backup creation may later be Admin-mutable if it produces protected storage-only artifacts; full download/export/restore stays Owner-only by default. | +| Import/apply | View previews where editor/admin permissions allow | View and mutate Owner-sensitive imports | Applying imports that change configuration, packages, ACLs, users, or system data is Owner-only. Content imports may belong to editor/content ACL policy. | +| Self-update/release | No access by default | View and mutate | Update checks that only show available versions may become Admin-viewable; apply/update/rollback remains Owner-only. | +| Diagnostics/support bundles | View redacted status | Generate/download redacted bundles | Bundle generation/download is Owner-only unless a later policy creates a strictly redacted Admin-safe bundle. | +| Setup/recovery/emergency controls | No runtime access after setup | Owner-only or CLI/manual recovery | Setup routes must not become alternate Admin entry points after setup completion. | + +## Configurability policy + +- The first implementation should keep descriptors code-owned and test-backed, while storing only bounded Owner overrides for rows marked configurable. +- The Owner-only settings UI may relax or tighten selected Admin capabilities only through bounded descriptors. Each configurable feature/action must define default `AccessRule`, allowed role/group range, optional ACL-group behavior, whether it may be disabled, audit behavior, affected routes/API/live operations, and safe rollback. +- The Owner UI should display the matrix as `Feature`, `Surface`, `Mode`, `Required ACL`, and `Configurable`. Non-configurable rows remain visible for transparency, but their controls remain disabled with explanatory copy. +- Feature flags and permissions must be grouped by surface: Admin, Editor, and Frontend. +- Admin ACL granularity may delegate configurable denied, visible, or mutable permissions to `ROLE_ADMIN` users through seeded Owner-controlled overrides. +- Editor ACL granularity should reuse the same descriptor model later with several role tiers: Author, Publisher, Curator, Manager, Director, and Admin. +- Frontend ACL granularity is only a reserved surface for special future features such as frontend inline editing. Do not add permission logic for nonexistent Frontend features in this branch. +- Optional ACL groups can explicitly override a configurable feature after the surface gate is satisfied. Group states may grant or restrict relative to the role/default state; with multiple matching groups, the highest explicit group state wins. +- Some actions are not ordinary configurable settings: last-Owner protection, Owner recovery, protected secret redaction, privacy ceilings, raw-token exposure, `APP_SECRET` emergency handling, and unknown-action deny-by-default. +- Owner configuration may delegate additional read or mutation actions to Admins, but it must not allow Admins to grant themselves Owner role, change Owner-only recovery/security boundaries, reveal secrets without a dedicated reveal flow, or bypass domain confirmations/audit. +- Configured changes to Admin action authority must be audited with actor, old/new policy summary, affected action identifiers, and redacted context. +- If configuration is missing, corrupt, or references an unknown action identifier, runtime must fall back to safe code defaults and record diagnostics. + +## Public interfaces and data decisions + +- Admin action identifiers are stable, English, machine-readable strings and are not localized. +- Feature/action descriptors should expose enough metadata for the `Settings/ACL` matrix UI without making every action database-configurable by default: stable feature key, default access rule, configurability flag, domain, sensitivity, and affected public entry points. +- The first registry is code-owned and test-backed. Configurable defaults are seeded through `acl.admin.features`, while non-configurable rows remain hardcoded in the registry for transparency. Database-stored Admin ACL overrides are limited to descriptor-approved rows and must fall back safely to seeded or registry defaults. +- `Settings/ACL` saves record a redacted old/new feature summary in audit context. Internal audit helper keys are not included in the public `setting_keys` list. +- Registry definitions, configured overrides, and available ACL groups are cache-backed with explicit reset hooks. When the unified cache strategy exists, these keys should move into the shared namespace/diagnostics/invalidation model if that reduces operational ambiguity. +- `Admin` is a delegated operations role. `Owner` remains the site-control role. +- Owner-only defaults include protected secrets, Security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, full-data exports/downloads, self-update/release actions, destructive data/package purge, peer Admin changes, Owner changes, and emergency global operational controls. +- Delegated Admin defaults include normal dashboards, redacted diagnostics, package/theme overviews, scheduler status, non-secret settings, user review queues, operational summaries, non-owner user management, ACL groups below the actor role level, bounded non-secret settings, and non-destructive cache/asset rebuilds where workflow policy allows them. +- Navigation visibility is not enforcement. Controllers, API handlers, live-operation starters, scheduler/admin triggers, and service-layer workflows must enforce the same policy before mutation or sensitive reveal. +- Protected values remain write-only or status-only even for Owners unless a dedicated reveal flow adds re-authentication, audit, and redaction tests. +- Package-owned Admin actions must use package-scoped identifiers and must not override system action identifiers. +- Denied decisions should return structured messages compatible with browser, API JSON, and live-operation feedback. +- The action catalogue should be discoverable for Admin navigation/read models, tests, and future documentation generation. + +## Edge cases + +- Existing user-management guardrails must stay intact and may be adapted behind the shared policy only when behavior remains equivalent or tighter. +- Owner ordinary-rate-limit exemption does not imply Owner bypass of action authorization, confirmations, audit, CSRF, account status, API-key revocation, or protected-value redaction. +- Admins may need read access to redacted diagnostics without write access to the setting or secret that produced the diagnostic. +- A denied delegated Admin action should produce a stable forbidden response/message, not fall through to "not found" unless hiding existence is an explicit policy for that resource. +- Live operations must check authority before queueing and again before continuation descriptors start a follow-up operation. +- API handlers must enforce the matrix using the API key owner's role and account status, not the key prefix or token label. +- Browser and API callers must apply the same state meaning: visible-only features may render existing controls disabled or return review/read models, while confirmed mutations and sensitive reads require mutable access. +- Scheduler run-now uses the Scheduler feature for trusted registered tasks because delegating Scheduler mutation means delegating operation of that configured task list. This differs from Operations continuations, where arbitrary queued continuation descriptors must re-check their target-domain feature before starting follow-up work. +- Scheduler web-trigger controls still need separate settings because status viewing, manual run, trigger enablement, and GET-token fallback are different risk levels. +- Backup/export/download actions must distinguish redacted summaries from full-data artifacts. +- If an action identifier is unknown, deny by default and record safe diagnostics. +- Configurable policy must not create a state where no Owner can recover or where Admins can silently promote themselves to Owner-equivalent authority. +- Downgrading an Owner session to Admin authority through configuration is invalid; role hierarchy remains the foundation. + +## Tests and validation + +- Test the static matrix for Admin, Owner, lower-role users, anonymous users, inactive/deleted users, and API-key actors. +- Test existing user/ACL management behavior remains at least as strict as `AdminUserAccessPolicy`. +- Test every row of the default authority matrix where the underlying surface exists, using at least one allowed Admin case, one denied Admin case, and one Owner case per domain. +- Test Admin can view allowed summaries but cannot mutate Owner-only package, scheduler, security, backup, update, or protected-secret surfaces. +- Test Owner can perform Owner-only actions when the workflow's own confirmation, CSRF, status, and domain validation pass. +- Test navigation/read-model visibility and backend enforcement use the same policy. +- Test Admin API handlers use the API key owner's authority. +- Test live-operation queueing and continuation re-check authority. +- Test denied actions produce stable redacted messages and audit context. +- Test package-scoped action identifier validation rejects collisions with system actions. +- Test missing/corrupt/unknown configuration fallback, Owner-only mutation of the matrix, audit of matrix changes, optional ACL-group OR grants after surface gating, non-configurable read-only rows, and rejection of unsafe delegation. +- Run focused controller/API/live-operation tests and `lint:container` when services are added. + +## Documentation and tracking + +- Update the Security and Admin UI drafts with the final action identifier naming convention and first matrix. +- Update any affected feature draft when a domain action is classified as Admin-safe or Owner-only. +- Update Security policy defaults if the Admin/Owner split changes. +- Update the class map for the authority policy, action catalogue, voters/subscribers, controllers, API handlers, and live-operation integration points. +- Record implemented action identifiers, denied-by-default behavior, and verification commands in the worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No unbounded or package-marketplace-style permission editor. +- No package permission marketplace or manifest permission model. +- No replacement for content/editor ACL rules. +- No weakening of existing Owner recovery and last-Owner protections. +- No Frontend ACL implementation beyond reserving the surface shape for future explicitly designed features. + +## Acceptance criteria + +- Admin and Owner authority are enforced through one shared policy for implemented Admin actions. +- Existing user-management restrictions remain intact. +- High-impact Admin workflows can be reviewed without guessing whether `ROLE_ADMIN` was intended to be enough. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md new file mode 100644 index 00000000..5a1b0e5d --- /dev/null +++ b/dev/draft/security-hardening/auto-ban.md @@ -0,0 +1,108 @@ +# Auto-ban branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-auto-ban` implementation plan. + +## Goal + +Add TTL-based temporary bans for sustained suspicious anonymous/IP/visitor/API behavior, while keeping authenticated handling softer and preserving Owner recovery access. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-abuse-foundation`. +- `feat-security-rate-enforcement`. +- [Security policy defaults](policy-defaults.md). +- Existing Admin, audit, message, user-role, visitor identity, and API-key foundations. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for temporary-block workflows, ban-review ergonomics, and false-positive lessons. Current database-backed TTL records, authenticated soft handling, Owner recovery protection, and Admin audit requirements have priority. Do not copy legacy logic, thresholds, or persistence directly. + +## Implementation sequence + +1. Add database-backed ban records with subject type, normalized subject key, reason code, source signal summary, status, created/expiry timestamps, actor context where available, and manual unban metadata. +2. Add cleanup for expired bans through command and scheduler-ready task with a separate review-retention window for recently expired records. +3. Add ban-decision checks to the abuse facade after request classification and before expensive workflow handling. +4. Enforce by default for anonymous/IP/visitor/API probe abuse. +5. Apply softer authenticated handling: throttle, captcha, or warning state before hard block unless account compromise signals are explicit. +6. Add Owner safety checks so at least one active Owner retains login and recovery paths. +7. Add compact Admin review/manual unban surface with audit entries. + +## Public interfaces and data decisions + +- First implementation uses database-backed TTL records; cache may be added later as an optimization. +- Auto-ban is enabled by default, with bounded configuration to disable it when the auto-ban branch introduces Security settings. +- Ban subject types are IP bucket, visitor ID, API key, combined anonymous subject, and optional authenticated user only for explicit compromise cases. +- Ban reasons use stable message/code catalogues. +- Ban responses use HTML or JSON according to request family and never expose raw signal internals. +- Suggested record fields are subject type/key, reason code, source signal digest, status, created at, expires at, lifted at, lifted by, lift reason, actor context hash, last matched at, match count, and audit reference. +- Initial TTL defaults come from the Security policy defaults and must stay test-backed: short anonymous/probe bans first, longer repeat bans only after repeated signals within the review window, and no permanent bans. +- Prefer Visitor-ID-backed bans for continuity. Add IP-bucket bans as a shorter secondary layer to reduce cookie-reset bypasses, and keep every IP-derived ban TTL below 30 days. +- Ban keys come only from the shared subject/client-identity resolver. Raw IP strings, raw API keys, and raw forwarding headers must never be stored as ban keys. +- Expiry and cleanup use an injectable clock/time boundary. +- Ban decisions follow the Security policy enforcement order so Admin/Owner context and recovery-login rendering are resolved before visitor/IP bans can deny access. +- Active temporary ban responses default to generic `403` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. +- Auto-ban enablement, TTLs, and escalation windows should use named bounded policy descriptors. Disabling auto-ban must not disable passive signal recording, audit, manual review, or recovery protections. +- Configurable escalation/review windows must not exceed the retention of the signals or log projections used to justify a ban. When evidence retention is shorter than a requested ban-decision window, validation must reject or clamp the setting and surface a clear diagnostic so bans are never based on unavailable historical evidence. +- Invalid CORS/API probing, repeated failed setup apply attempts, upload/archive abuse, and repeated diagnostic/export probing may feed auto-ban decisions for anonymous or API subjects when the underlying signals are high confidence. + +## Edge cases + +- Expired bans must not block while cleanup is pending. +- Visitor IDs and IP buckets that resolve to an active Admin or Owner session must not be banned. +- API keys owned by an active Owner must not be banned or rate-limited by ordinary application buckets. +- A recovery login route, for example `/user/login?bypass=1`, must render the normal login form even when the current Visitor ID or IP bucket is banned, then re-evaluate the ban after successful credential login under authenticated policies. +- Owner accounts must not be locked out by IP/visitor bans without an alternate documented recovery path. +- Shared IPs can be blocked only for clear anonymous abuse and should not permanently deny authenticated users. +- Setup/install abuse happens before Owner identity may exist. Auto-ban must avoid turning setup into an unrecoverable installer lockout; allow documented manual/CLI recovery where no authenticated recovery path exists yet. +- Invalid API keys may be banned by key fingerprint/prefix where safe, but raw submitted keys are never stored. +- IP-derived bans must expire and be cleaned up before the 30-day IP retention limit; expired IP bans must not remain searchable as historical Admin records with recoverable IP material. +- Manual unban must take effect immediately even if passive signals that created the ban still exist. +- Concurrent ban creation, expiry cleanup, and manual unban must be idempotent and auditable. +- Ban-store degradation must not create an invisible permanent block or lock out Owner recovery. + +## Tests and validation + +- Test active, expired, manually revoked, and cleanup states. +- Test anonymous enforcement and softer authenticated behavior. +- Test Owner recovery protection. +- Test active Admin/Owner session ban protection, Owner API-key protection, and recovery-login re-evaluation. +- Test that recovery-login bypass does not bypass CSRF, credential validation, the dedicated recovery-login bucket, or audit logging. +- Test HTML/JSON ban responses and redaction. +- Test ban response status, retry metadata, cache headers, and route-existence redaction. +- Test Admin manual unban writes audit entries. +- Test repeat-ban TTL escalation stays bounded and does not create permanent bans. +- Test escalation/review-window validation against the retention limits of the underlying signal, IP-derived, and projected-log evidence. +- Test disabling auto-ban preserves passive signals, diagnostics, and recovery behavior. +- Test IP-derived ban TTL validation rejects or clamps values at 30 days and cleanup removes expired IP-derived records from review/export surfaces. +- Test trusted-proxy/client-identity behavior, ban-store degradation, and concurrent create/unban/cleanup behavior. +- Test migration applies on SQLite. + +## Documentation and tracking + +- Update Security draft with final subject types, statuses, and Owner protections. +- Update Security policy defaults if implementation evidence changes ban TTLs, maximums, subject types, or authenticated/Owner handling. +- Update Admin/security diagnostics notes for review UI. +- Update class map for entity, repository, decision service, cleanup command/task, and Admin routes. +- Record threshold and false-positive assumptions in worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No permanent invisible deny list. +- No GeoIP/country blocking. +- No machine-learning risk scoring. + +## Acceptance criteria + +- Clear bot/probe behavior can be blocked temporarily and reviewed. +- Operators can understand and reverse bans. +- Owner recovery remains available. diff --git a/dev/draft/security-hardening/captcha-contract.md b/dev/draft/security-hardening/captcha-contract.md new file mode 100644 index 00000000..fade4b9c --- /dev/null +++ b/dev/draft/security-hardening/captcha-contract.md @@ -0,0 +1,85 @@ +# Captcha contract branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-captcha-contract` implementation plan. + +## Goal + +Add the generic captcha provider contract, resolver, workflow configuration, and global form integration without implementing IconCaptcha itself. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Abuse facade from `feat-security-abuse-foundation`. +- Rate reset hooks from `feat-security-rate-enforcement`. +- [Security policy defaults](policy-defaults.md). +- Existing Symfony Form, Validator, Translation, package contribution, and settings foundations. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for captcha workflow expectations and human-recovery signals. Current provider-contract, graceful `none` behavior, Symfony Form integration, and package-facing extension rules have priority. Do not copy legacy logic or provider coupling directly. + +## Implementation sequence + +1. Define captcha provider and result contracts for render, validate, provider key, label key, and failure reason. +2. Add a resolver that selects a provider by workflow configuration and returns `none` behavior when disabled or unavailable. +3. Add workflow keys for first expected consumers: registration, contact, guest comments, and future login step-up. +4. Add a global captcha form field that delegates rendering/validation to the resolver. +5. Add validation mapping for recoverable failures and suspicious failures. +6. Add success/failure hooks to the abuse facade so verified provider success can reset scoped buckets and failure can record signals. + +## Public interfaces and data decisions + +- Provider key `none` always validates successfully. +- Missing or disabled provider validates successfully unless a future provider-required policy is explicitly configured for a workflow. +- Successful validation from `none`, a missing provider, or a disabled provider is graceful workflow success, not verified human success. It must not trigger rate-limit resets, ban relief, budget refill, or `429` recovery behavior. +- Captcha result exposes only stable failure codes and safe context. +- Captcha result must distinguish graceful unavailable-provider success from verified challenge success. +- Provider contracts are package-facing extension points and must be documented. +- Provider-required behavior is not enabled in the first contract branch; workflow policy may declare the shape for later enforcement, but default runtime behavior remains graceful success for unavailable providers. +- Provider selection and workflow mapping should be represented as audited configuration descriptors. Reset/recovery eligibility is not an ordinary setting: only verified provider-backed success may trigger scoped rate-limit reset or captcha-based recovery hooks. + +## Edge cases + +- Captcha must not create anonymous sessions by default. +- Captcha validation never replaces CSRF, authentication, ACL, rate limiting, or domain validation. +- Captcha-on-`429` recovery is available only when an active provider can render and validate a real challenge. +- Provider render failures should degrade according to workflow policy and report safe diagnostics. +- Multi-language validation messages use deterministic translation keys. + +## Tests and validation + +- Test `none`, missing provider, disabled provider, success, recoverable failure, and suspicious failure. +- Test `none`, missing provider, and disabled provider do not call reset/refill/recovery hooks. +- Test verified provider success is the only captcha result that may call scoped reset hooks. +- Test form integration does not break workflows with no provider. +- Test abuse hooks are called with safe context. +- Test provider registration rejects duplicate provider keys. +- Test configuration descriptor behavior for provider `none`, missing provider, disabled provider, and provider-required policy declarations where introduced. +- Test translation catalogue synchronization for user-facing errors. + +## Documentation and tracking + +- Update Security and IconCaptcha drafts with final contract names. +- Update Security policy defaults if provider-required workflow behavior or captcha success reset policy changes. +- Update package developer guidance for provider registration. +- Update class map for provider interface, resolver, form type, and validation services. +- Record provider-required policy as deferred design context; do not enforce it in this branch. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No IconCaptcha assets, JavaScript, challenge generation, or refresh endpoint. +- No third-party captcha provider. + +## Acceptance criteria + +- Workflows can add captcha once and remain provider-agnostic. +- Future provider branches can implement only the contract without touching core forms. diff --git a/dev/draft/security-hardening/geoip-observability.md b/dev/draft/security-hardening/geoip-observability.md new file mode 100644 index 00000000..faa80ec3 --- /dev/null +++ b/dev/draft/security-hardening/geoip-observability.md @@ -0,0 +1,98 @@ +# GeoIP observability branch plan + +> **Status**: Draft +> **Updated**: 2026-06-16 +> **Owner**: Core +> **Purpose:** Define the `feat-security-geoip-observability` implementation plan. + +## Goal + +Make GeoIP useful for access logs, statistics, diagnostics, and later security review without using it for blocking decisions. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing `GeoIpResolverInterface` and `NullGeoIpResolver` decisions from the logging/statistics draft. +- Existing protected settings, scheduler, message, audit, access-log, and statistics foundations. +- MaxMind/GeoIP2 package already listed as the first provider choice. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for GeoIP provider behavior, update diagnostics, fallback handling, and operator-facing status ideas. Current Symfony product decisions, protected-settings rules, resolver boundaries, and redaction requirements have priority. Do not copy legacy logic or storage shape directly. + +## Implementation sequence + +0. Establish the provider-neutral foundation: `GeoIpProviderInterface`, `GeoIpProviderStatus`, a delegating `GeoIpResolver`, and a null provider that keeps current log/statistic placeholders as the default output. +1. Add a MaxMind-backed resolver behind the existing GeoIP resolver interface. +2. Add protected administrator-only Statistics settings for GeoIP enablement, the local database path, and the MaxMind license key. +3. Keep `NullGeoIpResolver` active whenever the provider is disabled, unconfigured, missing a local database, or unable to read data. +4. Add a scheduler-ready update task definition for GeoIP database refresh; keep it inactive by default until an administrator enables the task and stores a MaxMind license key. +5. Add safe Admin diagnostics for provider status, database edition/build date when available, and disabled/unconfigured/unavailable state. Do not add a persistent GeoIP update-history store; Scheduler task runs already record scheduled update success/failure, and live Operations already provide immediate feedback for manual downloads. +6. Wire access logs and statistics to consume normalized provider output only through the resolver interface and the shared client-identity resolver. + +## Public interfaces and data decisions + +- GeoIP output uses normalized `n/a` fields for city, state/region, country, and continent. Latitude/longitude storage is intentionally not planned because current logs/statistics and security review flows do not need coordinates. +- The foundation keeps `n/a` placeholders as the stable default for access logs and statistics whenever lookup input is missing, providers are disabled/unconfigured/unavailable, or a provider throws. +- Providers expose only safe status fields: provider key, coarse status, database edition/build date, and redacted failure code. No raw paths, IP inputs, license/account data, update-history state, or full exception messages belong in provider status. +- Lookup input uses the shared client-identity resolver and Symfony trusted-proxy configuration; raw forwarding headers are never parsed directly by the provider. +- Provider secrets are protected config values and never rendered outside authorized Admin settings. +- Scheduler task identifiers use stable system-owned names and do not expose provider credentials. The MaxMind database update task is a trusted callable scheduled daily by default and remains inactive until an operator activates it in Scheduler. +- Task-level Scheduler ACL enforcement is intentionally deferred to the Admin ACL enforcement branch and its shared feature/action matrix. This GeoIP slice keeps the direct Statistics download action Owner-only but does not introduce one-off Scheduler task gates. +- Update success/failure reporting stays with the existing Operation, Scheduler, and Message layers. A separate persistent GeoIP update-history table or settings blob is intentionally not planned for this branch because it would duplicate Scheduler run history and live Operation feedback. +- No public API response adds GeoIP data in this branch. +- GeoIP enablement, database path/status, and the MaxMind license key are protected/audited Statistics configuration surfaces; license material remains secret-only. Disabled, unconfigured, expired, or failed providers must fall back to `NullGeoIpResolver`. +- GeoIP settings and the manual database-update action should declare stable access metadata for the later ACL matrix. Initial defaults are Owner-only and non-configurable in this slice; a later Admin ACL enforcement branch may surface them in an Owner-only matrix with `Feature`, `Required ACL`, and `Configurable` columns. +- The first MaxMind implementation uses the installed `geoip2/geoip2` package against a configured local `.mmdb` database. Request-time lookups must not download databases or require outbound network access. +- The first production settings surface intentionally avoids a provider dropdown, Account ID field, and explicit GeoIP locale field until the product has a concrete need for them. MaxMind Reader locales are derived from `localization.default_language` with `en` as stable fallback. +- New setups explicitly seed GeoIP as disabled, keep the MaxMind license key empty and sensitive, and use `var/geoip2/GeoLite2-City.mmdb` as the default project-relative database path. +- License key configuration is sensitive. Empty sensitive form submissions preserve existing stored values, API/settings read models return redacted display values, and PHPUnit coverage must use fakes or dummy strings rather than real MaxMind credentials. +- The default local database path is `var/geoip2/GeoLite2-City.mmdb`. Admin-triggered downloads and scheduler downloads must write through a temporary workspace and atomically replace the configured target where the platform supports atomic rename. +- The Statistics settings page may link operators to the official MaxMind GeoLite signup page for a free license key. A saved key reveals the database download action; missing keys make the scheduler callable fail with a translated Message-layer diagnostic so normal scheduler failure policy can disable repeatedly failing tasks. +- Because MaxMind authenticates the database download through a license-key query parameter, the download client must not use a logger/profiler-wrapped HTTP client service and must never include the request URL in Operation, Scheduler, Message, audit, or access-log context. Shared log redaction treats `license_key` as sensitive defense in depth. + +## Edge cases + +- Missing MaxMind key, unreadable database, expired database, failed download, unsupported IP, private/local IP, and lookup exceptions all degrade to normalized empty fields. +- Diagnostics must not include raw license keys, request IP lists, full provider exceptions, or filesystem paths that expose secrets. +- GeoIP failures must not block the user request that triggered logging/statistics. +- GeoIP update/download failures must leave the previous usable database in place when possible and record only redacted diagnostics. + +## Tests and validation + +- Unit-test resolver success, null fallback, private/invalid IP handling, and exception fallback. +- Test protected settings visibility and redaction. +- Test access-log/statistics enrichment with provider data and with disabled/missing provider. +- Test trusted-proxy/client-identity behavior for lookup input. +- Test scheduler task definition and missing-key failure message behavior. +- Test that the task remains inactive until explicitly activated by an operator and that missing credentials produce clear failure context. +- Test protected configuration redaction and null fallback for disabled, missing, invalid, and expired provider states. +- Test the Statistics settings status rendering so operators can see disabled/unconfigured/ready state without exposing secrets. +- Run focused container lint when services/config are added. + +## Documentation and tracking + +- Update Contact/Mail/Logging notes with provider status and Admin diagnostics behavior. +- Update Scheduler notes if a task definition is added. +- Update class map for resolver, task, settings, and diagnostics entry points. +- Add worklog verification notes for redaction and fallback tests. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No geo-blocking. +- No country allow/deny lists. +- No abuse scoring based on GeoIP. +- No latitude/longitude storage. +- No separate persistent GeoIP update-history storage. + +## Acceptance criteria + +- Operators can see whether GeoIP is disabled, unconfigured, unavailable, or ready, and can see safe database edition/build-date data when the local database can be opened. +- Logs/statistics gain GeoIP fields when available and continue cleanly when unavailable. +- No secret or sensitive provider detail leaks through logs, diagnostics, tests, or exports. diff --git a/dev/draft/security-hardening/icon-captcha.md b/dev/draft/security-hardening/icon-captcha.md new file mode 100644 index 00000000..ea9ae0d2 --- /dev/null +++ b/dev/draft/security-hardening/icon-captcha.md @@ -0,0 +1,99 @@ +# IconCaptcha branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-icon-captcha` implementation plan. + +## Goal + +Implement the first-party IconCaptcha provider as a replaceable package-owned provider behind the generic captcha contract. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-captcha-contract`. +- [Security policy defaults](policy-defaults.md). +- Package lifecycle, AssetMapper/Tailwind, translation aggregation, `/api/live/**`, and abuse passive signal foundations. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for IconCaptcha challenge flow, refresh behavior, accessibility pitfalls, and abuse-signal ideas. Current provider-owned package boundaries, deterministic one-shot challenge policy, cache/TTL decisions, `/api/live/**` behavior, and product decisions in this plan have priority. Do not copy legacy logic, assets, templates, secrets, identifiers, or framework-specific shortcuts directly. + +## Implementation sequence + +1. Add the first-party provider package skeleton with captcha-provider scope, package-owned services, templates, assets, translations, and JavaScript. +2. Select or create a suitable asset set before challenge implementation. Prefer open-source-compatible licenses such as MIT, Apache-2.0, CC0, or similarly permissive licenses, and record asset provenance/license notes in the provider package. +3. Implement deterministic challenge generation from provider secret, challenge ID, timestamp, workflow key, route context, user agent, and optional existing session/visitor signal. +4. Store one-shot challenge IDs and short-lived challenge metadata in a dedicated Symfony cache pool where practical, falling back to `cache.app` if the project has no dedicated pool yet. +5. Implement validation for missing, expired, reused, invalid choice, wrong choice, context mismatch, asset error, and provider unavailable. +6. Add lightweight refresh through `/api/live/**` or a provider-owned JSON route with no ordinary rate-limit rejection; record passive abuse signals for aggressive refreshes. +7. Add accessible, layout-stable UI with fixed button grid, translated labels, keyboard support, and back-forward-cache refresh handling. + +## Public interfaces and data decisions + +- Provider key is `icon_captcha`. +- Public challenge payload contains only challenge ID, timestamp, render metadata, and button identifiers needed for display. +- Provider secret is generated/configured outside manifests and public assets. +- Default challenge TTL is 15 minutes, and validation invalidates the challenge after every attempt, successful or failed. +- Challenge expiry and one-shot state use an injectable clock/time boundary where practical for deterministic tests. +- SVG/icons must be allowlisted or sanitized before inline rendering. +- Inline-rendered graphics and symbol SVGs must not expose answer-bearing names through file names, element IDs, CSS classes, `data-*` attributes, titles, descriptions, or translation keys. Use opaque challenge-local identifiers and randomized or non-semantic button identifiers. +- Accessibility labels must describe the control purpose without revealing the visual answer. Prefer neutral labels such as option numbers and state/status text over labels that name the target icon or symbol. If this makes the visual challenge insufficient for assistive technology, document and implement a separate accessible fallback flow instead of leaking the answer through ARIA. +- The preferred accessible fallback is a provider-owned quiz challenge using a spoken question/task and multiple answer options. The quiz mode must share the same challenge ID, TTL, one-shot invalidation, context binding, failure codes, refresh handling, and passive abuse signals as the visual IconCaptcha mode. +- Challenge TTL may become bounded configuration, but starts at 15 minutes. The recommended range is 10-30 minutes; longer TTLs require explicit policy review plus brute-force, one-shot, refresh, and replay tests. +- Provider secrets are secret/protected configuration only and must never be stored in package metadata, public assets, serialized challenge payloads, cache payloads, logs, or diagnostics. + +## Edge cases + +- Challenge reuse fails after validation regardless of success or failure. +- Expired challenges fail recoverably. +- Context mismatch is suspicious but should not reveal internals. +- Disabled provider falls back according to the generic resolver policy. +- Asset loading failures produce safe diagnostics and recoverable user feedback where possible. +- Asset license gaps or unclear provenance block the provider branch until the asset is replaced or the license is documented as acceptable. +- Browser inspection should not reveal the correct answer through DOM order, source file names, SVG IDs, ARIA labels, visible hidden text, or static asset URLs. +- Quiz-mode questions and answer options must be generated from vetted provider-owned prompt pools and opaque option IDs so the correct answer is not inferable from stable DOM metadata or static translation keys. +- Concurrent double-submit validation must remain one-shot: one attempt wins, later attempts fail recoverably or suspiciously according to the failure model. +- The 15-minute TTL must not permit brute-force attempts. A submitted challenge is consumed after the first validation attempt, captcha failures feed the scoped failure bucket, and aggressive refresh behavior records passive abuse signals. + +## Tests and validation + +- Test challenge generation determinism and answer validation. +- Test every failure model. +- Test one-shot replay prevention and TTL expiry. +- Test cache-pool fallback and secret absence from cached/public challenge payloads. +- Test configured TTL bounds if challenge TTL becomes configurable. +- Test refresh no-store behavior and passive signal recording. +- Test package asset/template/translation registration. +- Test keyboard/accessibility behavior where practical with JS tests. +- Test that rendered DOM, inline SVG, ARIA labels, asset paths, and serialized challenge payloads do not expose answer-bearing names or reusable answer material. +- Test accessible quiz-mode success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance. +- Test concurrent double-submit/replay behavior against the cache store. +- Verify asset licenses/provenance and record the result in branch documentation or package metadata. + +## Documentation and tracking + +- Update IconCaptcha draft with final payload/storage choices. +- Update Security policy defaults if challenge TTL, quiz fallback policy, refresh handling, or captcha reset behavior changes. +- Update package developer guidance if provider package layout adds a reusable pattern. +- Update class map for provider, challenge services, controller/live endpoint, assets, and templates. +- Record asset licensing, provenance, sanitization, and bot-resistance notes. +- Document whether the first implementation ships neutral-label-only visual mode, quiz fallback, or both, and record the accessibility/security trade-off. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No workflow-specific IconCaptcha code in contact, registration, or password forms. +- No copied legacy implementation, secrets, or hard-coded old asset paths. +- No answer-bearing asset, DOM, CSS, JavaScript, translation, or ARIA names that make the challenge solvable by simple static heuristics. + +## Acceptance criteria + +- IconCaptcha can be enabled, disabled, or replaced without changing workflow forms. +- Challenge payloads and logs do not expose secrets or reusable answer material. diff --git a/dev/draft/security-hardening/mailer-account-delivery.md b/dev/draft/security-hardening/mailer-account-delivery.md new file mode 100644 index 00000000..832fb940 --- /dev/null +++ b/dev/draft/security-hardening/mailer-account-delivery.md @@ -0,0 +1,74 @@ +# Mailer account delivery branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-mailer-account-delivery` implementation plan. + +## Goal + +Replace production reliance on message-log action URLs with real Symfony Mailer/Messenger delivery for account and recovery flows. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing account-link delivery boundary, mail-flow registry, locale resolver, message logging, Messenger, and account-token flows. +- Security settings/admin settings foundation. + +## Implementation sequence + +1. Replace the hard-coded mail-flow registry with provider-backed registration while preserving current built-in account flow keys. +2. Add localized Markdown template storage/editing for built-in account flows. +3. Render Markdown to HTML and plain text with placeholder replacement from allowed parameter keys only. +4. Queue mail delivery through Messenger with a conservative transport guard and safe failure reporting. +5. Keep message-log action-link delivery as an explicit debug aid only, gated by debug configuration and strong Admin warning. +6. Update account, registration, password reset, security review, closure, restore, and APP_SECRET recovery flows to use the real delivery service in production. + +## Public interfaces and data decisions + +- Mail flow/template keys remain stable machine identifiers. +- Allowed replacement keys are defined by the mail-flow registry/provider metadata. +- Clear action URLs may exist in queued mail payloads but must not be duplicated into log context. +- Missing localized templates fall back to default language and record safe warnings. +- The first transport guard allows one queued account-flow message per user action, requires configured sender/transport before production delivery, and relies on Messenger retry/backoff instead of controller-level loops. +- Built-in account flows must remain registered by provider metadata even if no third-party provider exists yet. + +## Edge cases + +- Mail transport unavailable should produce recoverable UI/message feedback without exposing SMTP internals. +- Queue failures should not leave account tokens silently unreachable; callers receive a generic delivery failure. +- Debug log delivery must be unavailable or strongly warned in production-like environments. +- APP_SECRET recovery owner links need safe partial-failure reporting. +- Duplicate delivery attempts for the same token/action should be idempotent or clearly audited so operators can distinguish retries from new security events. + +## Tests and validation + +- Test registry provider completeness and duplicate key rejection. +- Test template fallback, placeholder replacement, HTML/plain rendering, and unsupported placeholders. +- Test Messenger queue payload redaction. +- Test account flows use real delivery when configured and debug delivery only when allowed. +- Test transport guard behavior and safe failure messages. +- Test duplicate/retry behavior for token-bearing account messages. + +## Documentation and tracking + +- Update Mailer Delivery Contract with final provider and template behavior. +- Update Security draft where the debug stub is replaced/gated. +- Update class map for registry providers, renderer, queued message/handler, and settings UI. +- Update user/admin docs if Mail settings become visible. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No newsletter, CRM, or campaign tooling. +- No third-party transactional mail provider package. + +## Acceptance criteria + +- Production account operations no longer require reading action URLs from message logs. +- Mail failures are visible, recoverable, and redacted. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md new file mode 100644 index 00000000..ac8a5a8e --- /dev/null +++ b/dev/draft/security-hardening/policy-defaults.md @@ -0,0 +1,264 @@ +# Security policy defaults + +> **Status**: Draft +> **Updated**: 2026-06-17 +> **Owner**: Core +> **Purpose:** Define first implementation defaults for Security hardening branches before runtime work begins. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Goal + +This document records the first testable Security policy defaults for the `feat-security-*` branch tree. These defaults should be implemented as named constants or configuration values in the owning branch, covered by behavior tests, and updated here whenever implementation evidence changes the policy. + +The defaults are not an Admin UI requirement. Admin-configurable policy can be added later where the owning branch explicitly introduces settings, validation, documentation, and safe bounds. + +## Policy Precedence + +- Current product decisions in the Security hardening plan and this policy file take priority over historical `sec-lookup` behavior. +- Repository privacy rules take priority over convenience: IP-derived data remains short-retention data even when it would be useful for longer investigations. +- Runtime code should use one shared subject/client-identity resolver, one injectable clock/time boundary, and one abuse/rate facade instead of reimplementing policy in controllers. +- Any policy that would block authentication, Owner recovery, scheduler access, or Admin diagnostics must include an explicit recovery path and tests. + +## Identity And Privacy + +- Raw IP addresses, IP buckets, and stable IP-derived hashes may be queryable for at most 30 days across file logs, database projections, diagnostics, exports, support bundles, and backups. +- Longer-term correlation uses internal visitor ID, authenticated user UID, API key fingerprint/prefix, or aggregate dimensions. +- Visitor-ID-backed policy is preferred for continuity. IP-backed policy is a short-lived secondary layer to reduce cookie-reset bypasses and shared-host abuse. +- Raw credentials, raw API keys, raw visitor-cookie tokens, session IDs, full user agents, and captcha answer material must not be stored in policy records. +- GeoIP values are operational metadata. They may support diagnostics and aggregate statistics, but they do not create allow/deny decisions in this policy slice. +- Browser storage may hold only transient UI state, such as operation overlay resume data. It must not hold raw credentials, API keys, captcha answers, remember-me token material, CSRF secrets beyond Symfony's intended browser-side double-submit flow, or live-operation polling tokens longer than the underlying operation TTL. + +## Retention Defaults + +- Raw access/security file logs: 30 days by default. +- Queryable IP-derived records in database projections or passive-signal stores: maximum 30 days. +- IP-derived auto-ban records: maximum 7 days, even though the privacy ceiling is 30 days. +- Visitor-ID auto-ban records: maximum 30 days unless a later policy explicitly defines longer visitor retention and user-facing privacy copy. +- Passive suspicious signals: default 7 days for visitor/user/API subjects; default 24 hours for IP-only subjects; maximum 30 days for any IP-derived subject. +- Captcha challenge state: 15 minutes, one-shot invalidation after every validation attempt. +- Remember-me trust window: seven days. +- Account invitation/registration links: 24 hours by default. Password-reset links: one hour. + +## Enforcement Order + +Runtime enforcement must use one deterministic order so the same request is not handled differently by unrelated branches: + +1. Resolve trusted client identity, visitor identity, request family, request intent, and safe pre-auth subject keys; resolve authenticated session/user and API key context before ordinary rate-limit decisions. +2. Apply static asset, generated asset, setup/maintenance, and `/api/live/**` classification before ordinary website/API rate decisions. +3. Resolve active Admin/Owner context before ordinary ban and rate checks so recovery protections and ordinary rate-limit exemptions can be evaluated safely. +4. Allow the recovery-login bypass path to render the normal login form before active visitor/IP bans or exhausted ordinary website buckets block it, while still applying the dedicated recovery-login bucket. +5. Classify high-signal probes early and return the generic probe response without revealing route existence. +6. Check active bans except where Admin/Owner protection or the recovery-login rendering rule applies. +7. Consume rate buckets in a stable order: workflow-specific bucket, request-family/global bucket, then suspicious/abuse bucket where applicable. +8. When multiple buckets fail, report the most user-actionable policy to the client and keep internal bucket names in diagnostics only. +9. Run the guarded workflow only after the decision is allowed. +10. Apply scoped resets only after clear success, such as successful credential login or verified provider-backed captcha success. +11. Record audit events and passive signals with redacted context regardless of whether the request was allowed or blocked. + +Owner/Admin protection does not bypass authentication validity, account status, role checks, ACL decisions, CSRF validation, API-key revocation, or explicit workflow authorization. Exempted requests should still record diagnostics so unusual administrative traffic remains reviewable. + +## Admin And Owner Authority + +`Admin` is a delegated operations role. `Owner` is the site-control role. Admins may enter the Admin area, but high-impact actions need an explicit action policy instead of inheriting blanket mutation rights from the `/admin` route prefix. + +Default authority policy: + +- Admins may view normal Admin dashboards, package/theme overviews, scheduler status, redacted log/audit/security diagnostics, non-secret settings, user review queues, and operational summaries. +- Admins may mutate non-owner user accounts, ACL groups below their own role level, pending account-token review actions, password-reset link creation, bounded non-secret settings, cache/asset rebuilds, and trusted registered scheduler run-now actions when the owning workflow allows it. +- Owners are required for Owner/Admin account promotion or demotion, peer Admin changes, last-Owner-sensitive actions, protected secret configuration, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, package install/activate/purge/update, backup restore, backup/download/export of full system data, self-update/release actions, destructive package/data purge, and emergency operational controls that can affect global runtime state. +- Admins may perform manual unban or abuse review for ordinary anonymous/user subjects, but Owner/Admin subject relief, disabling auto-ban, weakening recovery protections, or changing privacy ceilings remains Owner-only. +- Protected values remain write-only or status-only even for Owners unless a workflow explicitly implements a reveal flow with re-authentication, audit, and redaction rules. +- Permission-aware navigation is not the security boundary. Controllers, API handlers, live-operation starters, scheduler triggers, and service-layer workflows must all call the same action policy before mutating or revealing high-impact data. Responsibility decides the feature row: pending account-token review actions use the review permission even when rendered from user management, while direct user creation/editing/group membership uses the user-management permission. + +ACL rules are assigned by operational responsibility, not necessarily by the current view, menu, or route area that exposes a control. For example, editing ACL group definitions belongs to `admin.users.acl`, while adding or removing groups on a user account mutates that user account and belongs to `admin.users`. Review actions stay under their review permission even when rendered from a broader user-management view, so approving or rejecting pending registrations belongs to `admin.users.review`. Likewise, confirming a reviewed live operation must re-check the target operation's domain feature, such as `admin.packages`, instead of relying only on the Operations view permission. + +The first implementation should introduce a route/action authority matrix or policy service for existing Admin surfaces instead of relying on ad hoc controller checks. Existing user-management guardrails remain the model for peer-role and last-Owner protection, but they are not sufficient for package, scheduler, backup, settings, diagnostics, and update workflows. + +## Rate-Limit Defaults + +These are first implementation defaults. Branches may adjust them only with tests and a worklog note explaining the review reason. + +Rate-limit implementation must keep action costs separate from bucket budgets. The action-cost catalogue assigns stable semantic costs to request intents, while a dedicated rate-limit policy catalogue owns bucket descriptors, capacities, windows, TTL/retry metadata, reset eligibility, diagnostics labels, and profile scaling. This keeps later tuning centralized and allows future config-backed thresholds to attach at the policy-catalogue boundary without changing classifiers, subscribers, or controllers. + +Descriptor capacities are implementation credit budgets generated from the action counts shown in the policy table. When a bucket family has one unique action-cost value, the rate-policy catalogue multiplies the documented action count by that cost so Symfony `consume(n)` never asks a limiter to consume more tokens than the bucket can hold. Strict and panic profile scaling keeps a per-bucket minimum for two legitimate costed requests, so even the strongest profile still allows normal one-shot workflows without degrading open. Suspicious probes and scheduler triggers are explicit single-action exceptions because their profile behavior is interval-based. + +The first Admin-facing rate setting is one Owner-gated Security setting with four modes: + +- `off`: central facade gate allows requests without calling limiter storage. Authentication, authorization, CSRF, suspicious-probe `400` handling, passive abuse signals, audit, and diagnostics remain active. +- `standard`: default policy values from the bucket catalogue. +- `strict`: derived from `standard` by fixed multipliers that reduce capacity and/or extend windows/retry floors for elevated pressure. +- `panic`: derived from `standard` by stronger fixed multipliers for temporary emergency pressure. + +`strict` and `panic` must be calculated from the standard bucket descriptors instead of duplicating every bucket threshold. The derived values must be covered by tests so the code shows exactly how much capacity and retry behavior changes in each mode. `Retry-After` should use Symfony limiter metadata where available; descriptor-provided retry values act only as documented floors or special-case overrides such as recovery login. + +| Policy | Default | Subject | Success reset | +| --- | --- | --- | --- | +| Login failures | 5 failed attempts per 15 minutes | HMAC-redacted submitted username/email plus Visitor ID and IP bucket | Successful credential login resets only the login-attempt bucket for the same submitted-account/visitor/IP subjects | +| Recovery login bypass | 2 recovery-login requests per minute, 10 per hour, retry after 30 minutes once exhausted | HMAC-redacted submitted username/email plus Visitor ID and IP bucket | Successful credential login re-evaluates active bans/limits under authenticated policy | +| Registration submissions | 3 submissions per hour and 10 per day | HMAC-redacted submitted email or invitation token plus Visitor ID and IP bucket | No automatic global reset | +| Password-reset requests | 3 requests per hour and 10 per day | HMAC-redacted submitted email or reset token plus Visitor ID and IP bucket | No automatic global reset | +| Contact form submissions | 3 submissions per 10 minutes and 20 per day | Visitor ID; IP bucket as secondary signal | No automatic global reset | +| Captcha failures | 5 failures per 10 minutes | Challenge subject plus visitor ID | Verified provider-backed captcha may reset the scoped challenge/form bucket only | +| Website deliberate burst | 30 deliberate browser route requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | +| Website deliberate sustained | 300 deliberate browser route requests per 30 minutes | Visitor ID; IP bucket as secondary signal | No success reset | +| Turbo/browser prefetch observation | 120 safe prefetch `GET` requests per minute and 600 per 30 minutes | Visitor ID; IP bucket as secondary signal | No ordinary rejection by itself; records lower-confidence passive signals | +| Versioned API read | 600 safe requests per minute | Verified API key fingerprint after authentication, otherwise Visitor ID/IP fallback | No success reset | +| Versioned API write | 60 mutating requests per minute | Verified API key fingerprint after authentication, otherwise Visitor ID/IP fallback; submitted API-key prefixes are not primary limiter subjects | No success reset | +| Public anonymous API read | 120 safe requests per minute | Visitor ID; IP bucket as secondary signal | No success reset | +| Scheduler trigger | Standard: 1 trigger per minute; Strict: 1 trigger per 15 minutes; Panic: 1 trigger per hour | HMAC-redacted submitted scheduler credential, with IP bucket fallback/secondary subject before controller authentication, including when a user session is present | No success reset | +| Suspicious probes | Standard: 1 high-signal probe per 10 minutes; Strict: 1 per 15 minutes; Panic: 1 per 20 minutes | Visitor ID plus IP bucket | No success reset; return generic `400`; may drain suspicious buckets | + +Website global buckets count application/browser route handling, not static assets, generated assets, or `/api/live/**` polling. The first implementation should enforce both deliberate website buckets: the burst bucket catches very fast click/submit loops, while the sustained bucket catches automated crawling that stays just below the per-minute limit. + +`/api/live/**` remains outside ordinary rate-limit rejection. High-signal probe paths below `/api/live/**` still return the generic suspicious-probe `400`; normal live polling and captcha refreshes should not receive the normal website/API `429` path. + +Turbo/browser prefetch for safe `GET` requests should not spend the same budget as deliberate navigation. Use a dedicated prefetch observation bucket or lower-confidence passive signal weighting; do not let spoofable prefetch headers bypass authentication, authorization, CSRF, domain validation, recovery-login bypass buckets, or Admin export/download/diagnostic buckets. Expensive or side-effect-adjacent links should disable prefetch rather than relying on rate-limit forgiveness. + +Scheduler trigger limits must support a normal once-per-minute external cron in `standard`. `strict` and `panic` intentionally control the allowed external trigger interval instead of using the ordinary profile multiplier logic: `strict` allows one trigger per 15 minutes, and `panic` allows one trigger per hour. The scheduled tasks still use internal due-state logic, run locks, and task policies, so legitimate scheduler calls are expected and should not be treated as abuse by themselves. Scheduler interval `429` responses are operational feedback for the caller and must not create passive security signals or extra abuse diagnostics by themselves; the configured caller already logs the response and can adjust its interval. The interval bucket applies only to the exact `/cron/run` route and must use the submitted scheduler credential after HMAC redaction, with IP bucket fallback/secondary anchoring, because `/cron/run` authenticates inside the controller after the pre-controller interval guard. Other reserved `/cron/*` paths must not spend the scheduler interval bucket. Scheduler IP secondary anchoring remains active even when a browser user session is present, so rotating invalid query credentials cannot create fresh interval buckets from the same source. + +Registered authenticated users receive higher limits than anonymous visitors where the workflow is not already account-specific. The first default is a 2x multiplier for deliberate website navigation and public-read style API usage after the request resolves to an active authenticated user. Login, registration, password-reset, captcha, scheduler, and suspicious-probe policies keep their explicit workflow limits. + +Owner-owned API keys and Visitor-ID/IP subjects that resolve to an active Owner session are exempt from ordinary rate-limit rejection, except for the scheduler trigger surface where a mutable Owner API key is the expected credential and the configured scheduler interval must still be enforced. A mutating API request made with a read-only Owner API key is also not ordinary allowed Owner traffic; it must spend the API write/admin bucket before the read-only denial is returned. Credentialed `OPTIONS` preflights use `Access-Control-Request-Method` for this decision, so read-only Owner keys cannot bypass write/admin buckets by probing unsafe routes through transport-level `OPTIONS`. Owner traffic may still record diagnostics and passive signals, but the request path must preserve Owner recovery and administrative operation access outside those explicit exceptions. + +Limiter storage degradation is fail-open by policy. If limiter storage, locking, or consume/reset operations fail, the facade should allow the request, emit safe Message-layer diagnostics where possible, and avoid creating an invisible Owner, login, setup, API, or scheduler lockout. + +Symfony limiter storage keys must be isolated by the active descriptor shape, including profile-derived capacity/window values, so changing between `standard`, `strict`, and `panic` does not reuse stale fixed-window state. Cache-backed limiter consumption should use the configured Symfony lock factory so concurrent failed credentials or API requests cannot race through the same remaining budget. + +Multi-bucket requests must not partially spend earlier buckets when a later bucket rejects the request. The facade should pre-check all planned descriptor/subject candidates, then commit only when every candidate still has capacity. This preserves account-scoped workflow protection without letting a visitor that is already blocked by local or global website budgets poison other users' shared submitted-account buckets. This policy deliberately avoids a cross-bucket transaction manager: concurrent requests can race between pre-check and commit, but Symfony per-key locking bounds that race to a timing-dependent request, and subsequent requests see the exhausted bucket during pre-check. That residual race is accepted as simpler and reviewable because it does not provide a practical way to repeatedly drain unrelated account buckets. + +## Probe Path Policy + +- Probe paths are configurable as an editable pattern list, not as raw JSON. The default UI should use one regular expression per line and may accept quoted CSV imports; unquoted newline entries must be preserved as-is so commas inside regex syntax remain valid. The shipped defaults cover high-signal requests such as `.env`, `.git`, backup archives, database dumps, common admin panels from other software, shell upload probes, and known scanner paths. +- High-signal probes are never treated as normal website navigation. The default response is a generic `400 Bad Request` without revealing whether the path exists, and the event records a suspicious probe signal. Probe blocking must run before response-producing availability, setup, maintenance, live/API, and ordinary technical-exclusion gates so exposed install or disabled-feature surfaces cannot bypass the hardened response. +- One high-signal probe per subject per 10 minutes is the first threshold. Strict and panic extend that window while keeping a single-probe credit floor so profile scaling cannot produce an unusable capacity below the suspicious-probe action cost. Further probes may drain suspicious buckets and feed auto-ban decisions when auto-ban is enabled. +- Honeypot probe paths should remain restrictive. They may share the same generic `400` response and signal path even when they do not map to real routes. +- Probe-path configuration should use anchored, normalized path patterns with tests that prove common application routes, package routes, media routes, and editor routes are not accidentally captured. +- Probe-path configuration changes should be auditable once Security settings exist. +- Editor/Content route editing should warn, without blocking the save, when a proposed route or slug would match a configured suspicious probe path. This keeps legitimate content possible while making accidental collisions visible before publication. + +## Response Semantics + +- Rate-limit exhaustion returns `429 Too Many Requests` with `Retry-After` when a reliable retry time exists. +- Active temporary bans return a generic `403 Forbidden` by default, also with `Retry-After` when the ban expiry is known. The response must not expose raw reason internals, subject keys, IP data, or bucket names. +- High-signal probes return generic `400 Bad Request` and must not reveal whether a probed path, file, or package exists. Probe handling should run before package loaders and other response-producing request gates, then force a minimal `400 Invalid Request` HTML response for browser probes while leaving the passive response-time signal recorder able to persist the security signal. +- Browser responses use the shared HTML error/recovery renderer. Versioned API, scheduler, and JSON-request responses use the stable JSON error shape for their request family. +- Security block, recovery, captcha, login, and bypass responses are `no-store` by default. Shared rendered HTTP error pages also set `no-store` centrally so customized system error content cannot be cached accidentally. +- `/api/live/**` should return cheap JSON, token/access checks where needed, `no-store`, and passive signals; it should not enter ordinary website/API `429` rendering. + +## Additional Security Surface Coverage + +The codebase and other feature drafts expose several security-relevant surfaces beyond login, captcha, API, scheduler, and probes. The first Security branches should cover them through classification, cost catalogues, diagnostics, or explicit deferred follow-ups rather than inventing separate local policies later. + +- Setup/install mode is its own request family. Before setup completion, rate limiting must not touch Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for ordinary setup wizard traffic. The exact final review-step apply submission (`POST /setup/review` with `_setup_action=apply`) is the only setup request that may reach the setup-apply limiter before setup completion; it may resolve the mode through DB-ready/default-backed Config fallback and use cache/lock limiter storage. Wizard navigation, language, site, database-test, admin, and backtracking posts must not spend the setup-apply bucket. Static default suspicious-probe matching may still return a DB-free minimal HTML `400 no-store` response before setup completion, and setup-apply `429` responses before completion must also stay DB-free and `no-store`. Shared browser rendering for all known `4xx`/`5xx` statuses must return minimal HTML `no-store` responses before setup completion instead of resolving custom system error content. After setup completion, setup routes must not become public alternative admin entry points. Setup ActionLog/live-operation payloads must stay tokenized, `no-store`, and redacted. +- CORS preflight and API metadata requests are API-family traffic, not write attempts. Successful allowed anonymous `OPTIONS` preflights should be cheap and must not spend mutating API budget; invalid origin/method/header combinations may record passive signals. `OPTIONS` requests that carry any non-empty `Authorization` header are credentialed preflights and must be classified by `Access-Control-Request-Method` for rate limits. Invalid Bearer credentials must spend the matching API read/write/admin authentication-failure bucket and must never fall back to anonymous public reads; unrelated non-Bearer schemes remain anonymous only for endpoint-defined public reads on non-preflight requests. +- Technical roots such as `/api`, `/api/live`, `/cron`, `/setup`, generated assets, the profiler, and the toolbar are raw prefixless path scopes: they remain locale-aware through the resolved request locale, but URL locale prefixes are not accepted as aliases for these routes. A localized content or UI path that looks like `/de/api/...` or `/de/cron/run` must not inherit API, scheduler, setup, static-asset, or JSON-response behavior unless an actual technical route is registered there. +- High-impact authenticated/admin workflows need explicit intents and authority decisions even when Owner requests are exempt from ordinary rate-limit rejection: settings mutations, user/ACL changes, package install/activate/purge, backup restore, import apply, export/download, cache or asset rebuild, self-update, scheduler run-now, and diagnostic/support-bundle generation. Trusted registered Scheduler tasks are authorized by the Scheduler feature; live-operation continuations remain authorized by their target-domain feature before follow-up work starts. +- Upload and archive handling, including media, package ZIPs, import bundles, backups, and restore artifacts, should not be treated as suspicious probe traffic by path alone. Failed extension, MIME, size, path traversal, nested archive, and manifest-validation checks should feed passive signals with redacted context. +- Public-facing unsafe form submissions that are not covered by a more specific workflow remain their own `website_form` bucket. This includes future package-owned public forms such as comments, forum posts, ratings, or similar user-generated content actions. +- Log views, diagnostic downloads, exports, backups, and support bundles must be permission-aware, `no-store`, redacted, and retention-aware. They must not expose raw IP data beyond the 30-day ceiling or raw tokens/secrets through downloadable output. +- Security-signal visibility, IP-bearing access-log visibility, signal cleanup/mutation, and future review actions need explicit Owner/ACL policy in `feat-security-admin-acl-enforcement` instead of relying indefinitely on broad Admin-area access. +- Trusted proxy handling is a deployment/webserver boundary, not an app-level Security settings feature. Security identity, GeoIP, IP-bucket policy, access logs, API diagnostics, and auto-ban decisions must use Symfony's resolved request client IP and must not trust raw forwarding headers directly. Operators configure trusted reverse proxies through webserver/Symfony deployment config, for example `mod_remoteip` or equivalent server-level handling. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to avoid merging unrelated browsers behind the same resolved IP when their `X-Forwarded-For` chains differ. Raw forwarding-header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. Because clients can spoof those headers, enforcement must not rely on the fallback Visitor-ID alone for anonymous cookie-less abuse; it must evaluate the stable IP-bucket HMAC alongside Visitor-ID evidence. +- Visitor ID remains the preferred browser continuity key. Different browsers behind the same untrusted proxy should still receive separate visitor subjects. IP bans/blocks remain allowed as a secondary cookie-reset bypass defense, but their thresholds should be laxer than Visitor-ID thresholds so shared or untrusted-network IPs have a lower false-positive risk. +- HTTP security headers are an adjacent production-hardening follow-up. Before production readiness, define and test the response policy for CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and any route-specific exceptions needed by the editor, package assets, or external integrations. +- Configurable enforcement windows, thresholds, escalation windows, and review horizons must respect the retention of the underlying evidence. A limiter, auto-ban, or review policy may not evaluate signals, projected logs, IP-derived buckets, or other evidence beyond the configured retention window for that data. If an operator configures an enforcement window longer than the available retained evidence, the implementation must reject, clamp, or clearly diagnose the mismatch instead of pretending older evidence can still be considered. + +## Auto-Ban Defaults + +- Auto-ban is enabled by default and can be disabled through Security policy/settings once the auto-ban branch introduces bounded configuration. +- Visitor-ID bans are the preferred continuity mechanism: + - first temporary ban: 1 hour; + - repeated ban within 24 hours: 24 hours; + - severe repeated anonymous abuse: up to 7 days; + - maximum: 30 days unless a later policy extends visitor retention. +- IP-bucket bans are secondary and shorter: + - first temporary IP ban: 15 minutes; + - repeated IP ban within 24 hours: 6 hours; + - severe repeated IP abuse: up to 24 hours; + - maximum: 7 days. +- IP-ban/block thresholds should stay laxer than Visitor-ID thresholds because one resolved IP may represent multiple users behind shared hosting, NAT, or an untrusted proxy. Use Visitor-ID evidence first where available, and treat IP-only evidence as a secondary escalation signal unless the signal is severe. +- API-key bans use key fingerprint/prefix only: + - invalid-key probe ban: 15 minutes; + - repeated invalid-key probe ban: 1 hour; + - compromised or revoked-key replay review may escalate to 24 hours. +- Authenticated users start with higher limits and softer handling such as throttling, captcha, warnings, or session/token review unless explicit compromise signals justify a hard block. +- Visitor IDs and IP buckets that resolve to an active Admin or Owner session must not be banned. +- API keys owned by an active Owner and Visitor-ID/IP subjects that resolve to an active Owner session must not be rate-limited by ordinary application buckets. +- Owner accounts must retain at least one documented recovery path. A policy that could deny all Owners is invalid. +- Provide a recovery login path such as `GET /user/login?bypass=1` that renders the normal login form even when the current Visitor ID or IP bucket is banned or ordinary website buckets are exhausted. The bypass flag only bypasses ban/rate checks that would prevent rendering the login form; it does not bypass CSRF, credential validation, login-failure accounting, the dedicated recovery-login bucket, audit logging, or post-login policy re-evaluation. Unsafe login submissions with `bypass=1` remain normal login attempts. +- The dedicated recovery-login bucket is intentionally small but not lockout-like: 2 recovery-login requests per minute, 10 per hour, and a 30-minute retry window after exhaustion. +- Manual unban takes effect immediately and must be audited. + +## Captcha Defaults + +- `none`, missing provider, and disabled provider validate successfully until a workflow explicitly introduces provider-required policy. +- `none`, missing-provider, and disabled-provider success means "do not block the workflow because captcha is unavailable"; it is not verified human challenge success. +- IconCaptcha challenge TTL is 15 minutes so humans can complete longer forms without unnecessary expiry. +- Every validation attempt consumes the challenge ID, successful or failed. +- The longer challenge TTL is safe only when repeated guesses against the same challenge are impossible. Keep one-shot invalidation, context binding, tight captcha-failure buckets, refresh abuse signals, and answer-leak tests in place. +- Captcha success may reset only the scoped challenge/form bucket when the workflow policy allows it and when a real provider validated a real challenge. +- Provider `none`, missing-provider, or disabled-provider auto-success must never reset rate-limit buckets, refill budgets, clear bans, or satisfy a captcha-based `429` recovery step. +- Rendering captcha on a `429` recovery/error page is allowed only when the workflow has an active captcha provider that can validate a real challenge. Without such a provider, the response must fall back to ordinary retry-after behavior. +- Visual IconCaptcha must not expose answer-bearing names through DOM, SVG, asset paths, translation keys, hidden text, or ARIA labels. +- If neutral labels are not sufficient for assistive technology, the preferred fallback is a provider-owned quiz challenge that shares the same one-shot, TTL, context-binding, refresh, and abuse-signal rules. + +## Logging And Projection Policy + +- Rotating file logs remain the durable raw operational source. +- Database-backed message, audit, and access lookup projections are the Admin/API read model for query-heavy review and abuse correlation. +- Passive security signals are stored separately as `security_signal_event` rows with explicit expiry and remain observational until later enforcement branches consume them. +- Session/visitor mismatches that already terminate an authenticated session are high-risk passive signals. They may feed later rate-limit, auto-ban, account-review, or recovery diagnostics, but copied session plus copied visitor-cookie risk scoring still needs additional policy in the later Security/remember-me work. +- The projection must duplicate only minimized/redacted structured fields, never full raw log lines, keep IP-derived data within the 30-day limit, purge expired rows after successful writes, and degrade without weakening enforcement or hiding file-log diagnostics. +- Level/severity fields are stored only where they support meaningful filtering: message projections keep level, security signals keep severity, and current access/audit projections omit level fields. +- Backups, exports, diagnostics, and support bundles must not silently extend IP retention. + +## Configuration Posture + +- First implementations may ship policy defaults as code-level constants or configuration values with tests. +- Admin-configurable settings require bounded validation, safe defaults, documentation, and tests for disabled/missing settings. +- Security policy bounds must prevent accidental lockout and privacy drift. Configuration must not allow log projection or shared security-signal retention above 30 days, IP-ban TTLs above the documented maximum, disabling Owner recovery, disabling the recovery-login bypass without an equivalent path, or treating captcha `none` auto-success as verified human success. +- More permissive settings for public entry points should require an explicit policy update, not only a local configuration change. +- More restrictive settings that affect login, account recovery, scheduler operation, captcha, mail delivery, or Owner/Admin access need tests for recovery behavior and false-positive handling. +- User-facing copy is required whenever a configurable policy affects public behavior, recovery, captcha, mail delivery, remember-me, account access, or data retention. + +## Configuration Surface Defaults + +These are first soft decisions for which values should stay fixed, become protected environment/config values, or become audited Admin settings later. A branch may choose code constants for its first implementation, but it should keep the target surface in mind so later configuration does not require redesign. + +| Area | First implementation surface | Later configurable? | Boundaries | +| --- | --- | --- | --- | +| Enforcement order, Owner recovery, Admin/Owner lockout protection | Code-level policy and tests | No ordinary Admin setting | Requires a policy update and explicit recovery tests to change | +| IP privacy ceiling and raw-secret redaction | Code-level policy and tests | No increase allowed | IP-derived data max 30 days; raw credentials, API keys, visitor tokens, session IDs, captcha answers, and full user agents are never policy records | +| Raw file-log retention | Existing log configuration or code default | Yes, bounded | Default 30 days; IP-bearing logs must not become queryable beyond 30 days through archives, projections, exports, or support bundles | +| Database log projections and security signals | Config-backed defaults in Abuse Foundation | Yes, bounded | Message/audit/access projections default to 30 days; all passive security signals default to 7 days through one shared signal-retention setting; all IP-derived/queryable projection data remains capped at 30 days | +| GeoIP enablement, database path, license key, and update task | Protected config/Admin setting with null fallback | Yes, protected and audited | License key never public; disabled/unconfigured state uses `NullGeoIpResolver`; no geo-blocking | +| GeoIP license key | Secret/protected setting | Yes, protected only | Never rendered, exported, logged, or included in diagnostics | +| Probe-path defaults | Code defaults plus config descriptor | Yes, audited | Defaults remain broad; patterns are anchored/normalized and tested against false positives | +| Auto-ban enabled flag | Code default `on` | Yes, bounded | Disabling requires diagnostics; cannot disable Owner recovery, audit, or passive signal recording by accident | +| Auto-ban TTLs and escalation windows | Code/config defaults | Yes, bounded | No permanent bans; IP-ban TTL stays below the documented max and IP retention ceiling | +| Rate-limit mode | Owner-gated Security setting with `off`, `standard`, `strict`, and `panic` | Yes, bounded to those modes first | `off` bypasses limiter consume calls only; suspicious probes, passive signals, auth, ACL, CSRF, audit, and diagnostics stay active | +| Rate-limit thresholds and windows | Dedicated code-level policy catalogue plus derived profile scaling | Yes, later at the catalogue boundary | Lower values that affect login, scheduler, captcha, or recovery require false-positive/recovery tests; higher public-entry values require policy review; future config-backed tuning should attach at the catalogue boundary | +| Setup apply/finalization bucket | Named code/config default | Yes, bounded | Must avoid installer lockout; stricter values need documented CLI/manual recovery | +| High-impact admin action costs | Action-cost catalogue constants | Possibly later | Authorization, confirmation, audit, and redaction stay mandatory; Owner ordinary-rate exemption does not bypass workflow safety | +| Admin/Owner action authority matrix | Code-owned registry plus seeded `acl.admin.features` defaults | Owner-only bounded `Settings/ACL` overrides for descriptor-approved rows | Navigation is not enforcement; Owner-only actions require service/API/live-operation checks; unsafe delegation remains invalid; ACL groups may explicitly grant or restrict specific permissions only after the relevant surface gate is satisfied | +| Authenticated-user multiplier | Code/config default | Yes, bounded | Applies only to ordinary navigation/public-read usage, not explicit workflow buckets | +| Owner ordinary-rate-limit exemption | Code-level policy and tests | No ordinary Admin setting | Does not bypass authentication, authorization, API-key revocation, CSRF, audit, or diagnostics | +| Recovery-login bypass path and bucket | Code/config default | Path and thresholds may be bounded later | Must keep an equivalent Owner/Admin recovery path; bypass never skips credential, CSRF, audit, or failure accounting | +| Captcha provider selection and workflow map | Contract-level configuration | Yes, audited | Provider `none`/missing/disabled remains graceful success only, not verified human success | +| Captcha challenge TTL | Code/config default | Yes, bounded | Default 15 minutes; recommended range 10-30 minutes; longer values need policy review and brute-force/refresh tests | +| Captcha reset/recovery eligibility | Code-level policy and tests | No ordinary Admin setting | Only verified provider-backed success may reset scoped buckets or satisfy captcha-based recovery | +| IconCaptcha provider secret | Secret/protected setting | Yes, protected only | Never stored in package metadata, public assets, cache payloads, logs, or diagnostics | +| Remember-me trust window | Code/config default | Possibly later | Default seven days; longer windows require explicit token-rotation, revocation, visitor-binding, and privacy review | +| Scheduler trigger thresholds | Named code/config defaults | Yes, bounded | Must support minutely external cron; task due-state and locks remain authoritative | +| Mailer transport and debug log delivery | Environment/protected config | Yes, protected | Production must not depend on message-log action URLs; debug log delivery remains debug-gated | +| HTTP security-header policy | Code/config defaults | Possibly later | Defaults must be production-safe; route exceptions need tests and documentation | +| Trusted proxy/client identity | Symfony/deployment config plus resolver tests | Yes, deployment-level only | Raw forwarding headers are never trusted outside configured proxies | + +Config descriptors should include the setting key, unit, default, minimum, maximum, disabled behavior, source priority, audit behavior, and safe diagnostics shape. Invalid security configuration should fail early in development/test and degrade to the safest documented behavior in production only when that degradation cannot lock out Owners or hide a security failure. + +## Review Requirements + +- Every branch that implements one of these policies must update this file when thresholds, TTLs, subject types, or retention rules change. +- Every branch must complete the Security PR-readiness checklist in the master hardening plan from the actual branch diff. +- Follow-up decisions that remain after implementation must be recorded in `dev/WORKLOG.md`. diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md new file mode 100644 index 00000000..09a722a7 --- /dev/null +++ b/dev/draft/security-hardening/policy-docs.md @@ -0,0 +1,72 @@ +# Security policy documentation branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the documentation-only baseline for the `feat-security-policy-docs` branch. + +## Goal + +Align all security planning documents before runtime implementation begins. This branch creates the durable review reference for later `feat-security-*` work and keeps the active worklog focused on Security. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing security, API, captcha, scheduler, mailer, and logging drafts. +- Existing worklog archive convention in `dev/WORKLOG_HISTORY.md`. + +## Implementation sequence + +1. Expand the master security hardening plan with links to every detailed branch plan. +2. Add one detail file for each `feat-security-*` branch under `dev/draft/security-hardening/`. +3. Add a policy-defaults reference for first implementation TTLs, rate thresholds, retention ceilings, auto-ban defaults, captcha defaults, logging projection posture, and configuration rules. +4. Align related drafts only where product decisions changed: `/api/live/**` rate-limit exclusion, GeoIP as observability first, IconCaptcha as a dedicated branch, scoped limiter resets, Turbo/browser prefetch classification, database-backed auto-bans, and IP-retention privacy limits. +5. Move non-Security active branch logs from `dev/WORKLOG.md` to compact sections in `dev/WORKLOG_HISTORY.md`. +6. Keep global roadmap and global To-Do items in the active worklog. + +## Public interfaces and data decisions + +- No runtime interfaces, routes, entities, configuration, services, commands, migrations, or translations are added in this branch. +- Documentation establishes fixed defaults for later branches: database-backed passive-signal and auto-ban TTL records, anonymous-first enforcement, lower-confidence prefetch signals, scoped `reset()` before partial refunds, ordinary rate-limit exclusion for `/api/live/**`, IconCaptcha challenge cache/TTL behavior, account-mail transport guard expectations, minimal remember-me token management UI, and privacy-first IP retention ceilings. +- `policy-defaults.md` is the first implementation source for thresholds and TTLs until an owning branch updates it with tested evidence. +- The planning baseline also records adjacent coverage for setup/install, CORS preflight, high-impact admin operations, Admin-vs-Owner authority through a dedicated Admin ACL enforcement branch, uploads/archives, exports/downloads, diagnostic bundles, trusted-proxy identity, browser storage, and deferred HTTP security-header policy. + +## Edge cases + +- Do not archive the active `feat-security-planning` branch log. +- Preserve deferred follow-ups from old logs in compact archive entries when they are still relevant. +- Do not rewrite unrelated historical entries already archived. + +## Tests and validation + +- Run `bin/lint` for changed Markdown files. +- Run `git diff --check`; Markdown metadata hardbreaks may appear in raw Git output, but project lint is authoritative for Markdown. +- Confirm `dev/WORKLOG.md` has no non-Security active branch sections. +- Confirm every branch detail file links back to the master plan and the master plan links to every detail file. + +## Documentation and tracking + +- Update `dev/draft/README.md` if new draft paths need discoverability. +- Update `dev/WORKLOG.md` with concise planning notes only. +- Update `dev/WORKLOG_HISTORY.md` with compact archived branch summaries. +- Add or maintain the Security PR-readiness checklist in the master hardening plan when review expectations change. +- Link `policy-defaults.md` from the master plan and relevant draft indexes. + +## Non-goals + +- No runtime behavior. +- No threshold tuning. +- No branch implementation beyond planning and archive cleanup. +- No new open product questions unless the answer blocks a later branch from being implemented safely. + +## Acceptance criteria + +- A future implementer can start any `feat-security-*` branch from its detail plan without inventing product policy. +- Remaining calibration points are explicitly framed as implementation defaults to be committed and tested in the owning branch, not as unresolved product direction. +- The first thresholds and TTLs are discoverable in one policy-defaults document. +- The active worklog is short enough to serve as review notes for Security planning. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md new file mode 100644 index 00000000..1d7b16ff --- /dev/null +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -0,0 +1,133 @@ +# Rate enforcement branch plan + +> **Status**: Draft +> **Updated**: 2026-06-17 +> **Owner**: Core +> **Purpose:** Define the `feat-security-rate-enforcement` implementation plan. + +## Goal + +Wire Symfony RateLimiter through the Studio abuse facade so known workflows get action-aware limits, global budgets, scoped resets, and stable HTML/JSON `429` responses. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- `feat-security-abuse-foundation`. +- [Security policy defaults](policy-defaults.md). +- Existing form, API, scheduler, login, account-token, message, and error-rendering foundations. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for rate-limit pressure patterns, bucket naming ideas, and human-recovery behavior. Current Symfony RateLimiter integration, Studio facade boundaries, `/api/live/**` exclusion, and scoped `reset()` policy have priority. Do not copy legacy logic or thresholds directly. + +## Implementation sequence + +1. Add a small rate-limit policy catalogue that owns bucket descriptors, profile scaling, retry metadata, reset eligibility, and diagnostics labels separately from request intent classification. +2. Configure named Symfony limiters or build descriptor-derived Symfony limiter factories for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, setup apply, captcha failure, and any already-present import/high-impact admin flows. +3. Add a rate decision service that maps classified intents and subjects to one or more limiter consumes. +4. Use costed `consume(n)` calls based on the action-cost catalogue. +5. Add scoped `reset()` calls after successful password login and verified provider-backed captcha validation where the workflow explicitly allows it. +6. Add stable `429` rendering: HTML through the shared error renderer for browser workflows and JSON through API responders for versioned API/scheduler flows. +7. Explicitly exclude `/api/live/**` from ordinary rate-limit rejection while preserving passive signal recording. + +## Public interfaces and data decisions + +- Rate-limit policy remains application-owned; packages may request classification later but do not define raw Symfony limiter names. +- Response metadata includes retry timing where Symfony provides it, without exposing internal bucket identifiers. +- Config names use stable system/security namespaces; thresholds are defaults that can become Admin settings later. +- Action costs remain semantic and profile-independent. The action-cost catalogue maps request intent to bucket family and credit cost; the rate-limit policy catalogue maps bucket families to capacity, window, TTL/retry metadata, reset eligibility, and diagnostics. +- Bucket descriptors live in a small dedicated PHP catalogue class so later config-backed threshold tuning can attach at one boundary without changing classifiers, subscribers, or controllers. +- Descriptor limits are stored as credit budgets generated from user-visible action counts. For example, a three-submission registration policy with a five-credit registration cost is represented as a 15-credit bucket so Symfony `consume(n)` never exceeds the bucket capacity and accidentally degrades open. This automatic multiplication is used only for bucket families with one unique action cost. Strict and panic scaling keep a two-action credit floor for ordinary buckets so at least two legitimate costed requests fit in every derived profile window. Suspicious probes and scheduler triggers intentionally keep their explicit single-action floors. +- The first Admin setting for rate limiting is a single Owner-gated Security setting with four modes: `off`, `standard`, `strict`, and `panic`. `standard` is the default. `strict` and `panic` derive from the standard bucket descriptors with fixed multipliers instead of duplicating every threshold by hand. +- `off` is handled by one central facade gate that returns an allowed decision without calling Symfony limiter storage. It does not disable authentication, authorization, CSRF, passive abuse signals, suspicious-probe `400` handling, audit, or diagnostics. +- Registration and password-reset success do not reset global buckets by default. +- The branch must commit initial threshold defaults from the Security policy defaults as named configuration/constants with behavior tests. Later branches may tune those defaults only with matching draft/worklog notes. +- Captcha-triggered limiter resets or `429` recovery require verified provider-backed challenge success. Provider `none`, missing-provider, and disabled-provider auto-success must not reset or refill any bucket. +- Captcha failure gets a dedicated bucket descriptor and the rate facade must expose a scoped reset interface that future verified captcha providers can call. The branch must not add dead captcha routes, providers, or unreachable workflow wiring before the captcha contract/provider branches exist. +- Website global policy uses separate deliberate burst and sustained buckets. Turbo/browser prefetch uses a separate lower-confidence observation path so speculative `GET` requests do not exhaust user-facing navigation budgets. Sensitive safe `GET` workflows such as recovery-login bypass renders and Admin export/download/diagnostic reads are classified before prefetch forgiveness, so spoofable prefetch headers cannot skip their dedicated buckets. +- Deliberate browser burst and sustained protection are explicit bucket descriptors derived from understandable product values. The technical Symfony limiter configuration may be generated or mapped from those descriptors, but review should happen against the catalogue values. +- Scheduler trigger policy must allow normal once-per-minute external cron calls in `standard`, then enforce one trigger per 15 minutes in `strict` and one trigger per hour in `panic`; task due-state logic, locks, and task policies decide whether work actually runs. This is an operational pre-auth interval guard for the exact `/cron/run` route, not an abuse/security signal source for legitimate configured cron callers. Other reserved `/cron/*` paths must not spend the scheduler interval bucket. Submitted scheduler credentials are HMAC-redacted and the scheduler interval keeps IP secondary anchoring even when a browser user session is also present, so rotating invalid query credentials cannot bypass the interval from the same source. +- API, scheduler, setup, generated-asset, profiler, and toolbar roots are raw prefixless technical route scopes: they remain locale-aware through the resolved request locale, but URL locale prefixes are not accepted as aliases for these routes. URL locale-prefix stripping is allowed only for explicit locale-prefix UI/account scopes. A localized lookalike such as `/de/cron/run` or `/de/api/v1/status` is browser/content traffic unless a real technical route is registered there, and must not spend scheduler/API buckets or receive scheduler/API JSON error shapes. +- Authenticated users receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define its own explicit bucket. Owner-owned API keys and subjects tied to an active Owner session are exempt from ordinary rate-limit rejection, except `/cron/run`, where the mutable Owner API key must still spend the scheduler bucket. Mutating API requests made with a read-only Owner API key, including credentialed `OPTIONS` preflights whose `Access-Control-Request-Method` is unsafe, must spend the write/admin bucket before the read-only denial is returned. +- Scheduler `429` responses are expected operational feedback when the external caller runs more frequently than the selected profile allows. They should not create passive security signals or extra abuse diagnostics by themselves; the scheduler caller already observes the response and can adjust its interval. +- Recovery login bypass is the exact `/user/login?bypass=1` browser `GET` path. It uses its own narrow bucket, bypasses ordinary website buckets needed to render the normal login form, and must not bypass CSRF, credential checks, login-failure accounting, audit logging, or post-login policy re-evaluation. Unsafe login submissions with `bypass=1` are still normal login attempts and spend the login workflow bucket. +- Workflows that do not exist in the current codebase receive catalogue entries only when doing so does not create dead services, routes, or unreachable tests. +- Limiter keys come only from the shared subject/client-identity resolver. Raw request headers, API-key material, usernames, email addresses, scheduler credentials, and other user-submitted identifiers must never become keys directly; workflow account subjects and scheduler credential subjects are normalized and HMAC-redacted before they can be used for login, registration, password-reset, or scheduler interval buckets. +- Limiter storage degradation is fail-open by policy: the facade allows the request, records safe Message-layer diagnostics where possible, and preserves Owner recovery instead of returning an invisible hard block. Symfony limiter state is isolated by descriptor capacity/window shape so profile changes do not reuse stale fixed-window state, and cache-backed consume operations use the configured Symfony lock factory. +- Multi-bucket decisions are planned before credits are spent. The facade checks every descriptor/subject consume candidate first and only commits the batch when all candidates still have capacity. If any candidate is already exhausted, the request is rejected against that descriptor without decrementing earlier workflow, global, or account-scoped buckets. This is an intentional lightweight all-or-nothing policy, not a cross-bucket transaction: concurrent requests may still race between pre-check and commit, but per-key limiter locking bounds that race to a single timing-dependent request, after which the exhausted bucket is visible to later pre-checks. The branch accepts that residual race instead of adding complex distributed transaction/rollback logic because it is not a practical account-bucket poisoning vector. +- Enforcement follows the Security policy order so workflow buckets, global buckets, suspicious buckets, active bans, recovery-login rendering, and Owner/Admin protections interact predictably. +- Rate-limit responses use the documented response semantics: `429`, `Retry-After` when available, family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. +- Suspicious-probe handling runs before package loading and response-producing API availability, setup redirect, and maintenance gates. Probe responses force the shared minimal bare `400 Invalid Request` HTML shape for browser probes while the response-time passive signal recorder can still persist the security signal. Profile scaling extends the rejection window while preserving the one-probe action floor: `standard` 10 minutes, `strict` 15 minutes, and `panic` 20 minutes. +- This branch owns `no-store` behavior for rate-limit, block-adjacent recovery, browser/API/scheduler error, and sensitive retry responses it touches. It should also carry the production HTTP security-header follow-up forward to a dedicated response-hardening/frontend-delivery slice: define and test CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and documented route exceptions without broadening this branch into a full frontend policy rewrite. +- Threshold/window configuration should be represented through named policy descriptors with units, defaults, min/max bounds, disabled behavior, and diagnostics labels, even if the first implementation keeps those descriptors as code constants. +- Configurable threshold windows must not exceed the retention of the evidence they evaluate. If a bucket or mixed-signal policy depends on database projections, security signals, IP buckets, or audit context with shorter retention, validation must reject or clamp longer windows and expose a clear diagnostic rather than evaluating missing historical data. +- Valid anonymous CORS preflights should be cheap and must not spend mutating API budget. `OPTIONS` requests that carry any non-empty `Authorization` header are credentialed preflights, not anonymous browser preflights, must not be short-circuited by the CORS responder, and must spend the matching API read/write/admin bucket by `Access-Control-Request-Method` when the request reaches rate enforcement. API authentication itself remains Bearer-only; unrelated schemes may still be anonymous only for endpoint-defined public reads on non-preflight requests. +- The API integration should choose the smallest reviewable ordering that preserves the policy: valid CORS preflights stay cheap, ordinary API reads/writes are charged after authentication has resolved valid API-key subjects and before authorization failures where practical, invalid API credentials charge stable Visitor/IP fallback buckets through authentication-failure handling, and existing API availability/error boundaries remain stable. +- High-impact authenticated/admin operations should use explicit action costs or workflow buckets where the current codebase exposes them. Owner ordinary-rate-limit exemption does not remove workflow confirmation, Admin/Owner action authorization, audit, or redaction requirements. +- Setup finalization/apply attempts need a dedicated workflow bucket because this surface exists before normal Owner/Admin session protections are available. Before `APP_SETUP_COMPLETED`, the rate-limit subscriber must not call Config, DBAL, subject resolution, limiter storage, or content-backed error rendering for ordinary setup wizard traffic; only static default probe-path matching and the exact final `POST /setup/review` `_setup_action=apply` limiter path may run. The final apply limiter may resolve the rate-limit mode through the DB-ready/default-backed Config fallback and use cache/lock limiter storage, while setup-time probe responses and setup-apply `429` responses must use the shared DB-free minimal HTML `no-store` error-renderer bare response path. Shared browser rendering for all known `4xx`/`5xx` statuses must also stay minimal and DB-free until setup completes. + +## Edge cases + +- Multiple buckets may be consumed for one request; rejection should report the most user-relevant failed policy without leaking all internal counters. +- If a request has no explicit primary descriptor and is not otherwise excluded, Browser/Admin/Editor traffic should still fall back to the global website burst/sustained buckets. This keeps descriptor gaps such as safe Admin navigation from becoming unlimited crawl/refresh surfaces. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail, including manual `POST /user/login?bypass=1` requests; unsafe invalid API credentials consume stable Visitor/IP fallback buckets through the same authentication-failure path, including high-impact Admin API mutation families. Safe login, registration, and password-reset form renders do not spend workflow-specific buckets. Submitted-account workflow buckets always consume local Visitor/IP guards before HMAC-redacted account/email subjects so a source that is already locally blocked cannot poison other users' shared account buckets. Account-token workflows such as `/user/invitation/{token}` and `/user/reset-password/{token}` use HMAC-redacted token subjects so the same leaked token is shared across visitors without exposing token material or requiring database lookup during subject resolution. Recovery-login bypass renders are the explicit exception: `/user/login?bypass=1` `GET` spends the dedicated recovery-login bucket while bypassing ordinary website buckets. Successful login resets only the login-attempt bucket for the same subject keys, including HMAC-redacted submitted-account keys, and the active rate profile. +- Read-only API keys hitting write routes should still follow API write policy before or alongside authorization failure as decided by the handler order. +- CORS preflight storms should not block legitimate configured browser clients through the write limiter, but invalid origin/header/method scans should remain visible to abuse diagnostics. +- `/api/live/**` operation polling must continue to function during long admin operations, but high-signal suspicious probe paths below `/api/live/**` must still reach the early probe blocker and return the generic `400`. +- Export/download/log/support-bundle endpoints must use `no-store`, redaction, and permission checks even when the rate limiter allows them. +- Concurrent failures and immediate success/reset sequences must not accidentally reset unrelated global buckets or hide suspicious mixed-action behavior. +- HTML `429` pages may render a captcha recovery step only when an active provider can render and validate a real challenge. Without that provider, use ordinary retry-after behavior. + +## Tests and validation + +- Test each guarded workflow below and above threshold. +- Test global burst and sustained website budgets catch mixed suspicious actions without counting static assets or ordinary `/api/live/**` polling. +- Test Turbo/browser prefetch does not exhaust deliberate website buckets, does not bypass sensitive recovery/export/download classifications, and still records passive signals for excessive speculative traffic. +- Test scheduler triggers allow normal minutely cron calls while still limiting repeated trigger attempts by stable redacted scheduler credential, even when cookies or user agents change, and by IP secondary anchoring when invalid submitted scheduler credentials rotate under an authenticated browser context. +- Test setup apply, CORS preflight, high-impact admin operation, Admin-vs-Owner authority outcomes, export/download, and upload/archive validation classification attach to the expected buckets when those workflows exist. Setup wizard navigation/database-test/backtracking posts before final review apply must not spend the setup-apply bucket or invoke DB-backed rate-limit services before setup completion; the exact final apply post must still reach the setup-apply limiter. +- Test authenticated-user higher limits and Owner ordinary-rate-limit exemptions for active sessions and Owner-owned API keys, plus the explicit read-only Owner API key write-denial exception for both actual unsafe requests and credentialed preflights with unsafe requested methods. +- Test that valid authenticated browser/API requests are evaluated after Symfony authentication, while failed login/API credentials still spend stable workflow buckets through authentication-failure events. +- Test recovery-login bypass rendering through the normal request stage, dedicated recovery bucket exhaustion, retry-after behavior, and successful-login policy re-evaluation. +- Test policy descriptor validation for invalid, missing, overly permissive, and overly restrictive threshold/window values where configuration is introduced. +- Test profile resolution for `off`, `standard`, `strict`, and `panic`, including the central `off` facade gate that performs no limiter consume. +- Test that strict and panic profile values derive from the standard catalogue descriptors through documented multipliers. +- Test that configurable limiter and mixed-signal windows are rejected or clamped when they exceed the retained evidence required by that policy. +- Test successful login resets only the login bucket, including the submitted-account key used by failed login enforcement. +- Test verified captcha success can reset only the configured scoped bucket, while provider `none`/missing/disabled success resets nothing. +- Test the captcha failure bucket descriptor and the dormant scoped reset interface without wiring a non-existing captcha provider. +- Test captcha-on-`429` is unavailable without an active provider and falls back to retry-after behavior. +- Test `/api/live/**` never receives ordinary rate-limit `429`. +- Test browser HTML and API/scheduler JSON `429` shapes, including path-boundary checks so lookalike browser content such as `/cronjobs` does not receive scheduler JSON. +- Test response cache headers and redaction for browser/API/scheduler limit failures. +- Test that any `no-store` headers added in this branch are route-scoped and do not claim to complete the full production HTTP security-header policy until the dedicated response-hardening/frontend-delivery slice defines CSP and related headers. +- Test that non-existing optional workflows are not wired as dead routes/services and that later workflow branches have a clear catalogue attachment point. +- Test limiter storage degradation, profile-isolated limiter state, and locked consume behavior for the highest-risk workflows. +- Test configured limiter service wiring with `lint:container`. + +## Documentation and tracking + +- Update Security draft thresholds and reset behavior. +- Update Security policy defaults if implementation evidence changes any threshold, subject, or reset policy. +- Update the existing Security settings page with the Owner-gated rate-limit mode setting and matching translations when the implementation lands. +- Update API/Scheduler notes for JSON `429` behavior. +- Keep the HTTP security-header production-hardening follow-up linked from this branch if the full policy is still deferred after rate enforcement. +- Update class map for facade/enforcement services. +- Record focused test commands and any threshold changes in the worklog. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No auto-ban records. +- No partial refund API. +- No IconCaptcha provider. + +## Acceptance criteria + +- Known workflows are rate-limited through one facade. +- Successful human outcomes can clear scoped local buckets without weakening global abuse detection. +- Browser/API/scheduler rate-limit responses are redacted, include only safe request references, and use `no-store` plus `Retry-After` where available. diff --git a/dev/draft/security-hardening/remember-me.md b/dev/draft/security-hardening/remember-me.md new file mode 100644 index 00000000..63107ea9 --- /dev/null +++ b/dev/draft/security-hardening/remember-me.md @@ -0,0 +1,81 @@ +# Remember-me branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-remember-me` implementation plan. + +## Goal + +Add persistent login using server-side revocable tokens that remain bound to account status, visitor identity, and audit policy. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- Existing Symfony Security login, visitor identity/session binding, account lifecycle, audit logging, password change, logout, and APP_SECRET rotation handling. +- Symfony remember-me foundation. + +## Implementation sequence + +1. Add a server-side persistent login token model with selector, hashed token, owning user, visitor binding, issued/last-used timestamps, expiry, status, and revocation reason. +2. Configure Symfony remember-me to use an opaque browser selector/token and the server-side provider. +3. Add a login checkbox that issues a seven-day token only after explicit credential login. +4. On automatic login, validate token status, expiry, user status, visitor binding, and suspicious reuse before creating a fresh Symfony session. +5. Rotate token value on successful automatic login while preserving original expiry unless a full credential login issues a new token. +6. Revoke tokens on manual logout, password change/reset, account inactive/deleted status, security-review dispute, APP_SECRET emergency handling, and suspicious reuse. +7. Add audit entries for issue, auto-login success, logout revocation, mismatch, reuse, and lifecycle revocation. +8. Add a minimal profile/security UI for active persistent tokens with revoke-current, revoke-other, and revoke-all actions. + +## Public interfaces and data decisions + +- Trust window is seven days. +- Browser cookie contains only opaque selector/token material. +- Server stores only hashed token values. +- Remember-me never bypasses `UserAccountChecker`, account status, role checks, or session visitor binding. +- Token records include selector, hashed token, user, visitor binding hash, issued at, last used at, expires at, status, revocation reason, last IP bucket, last user-agent hash, and token family identifier. +- Token list/revocation UI is part of the branch scope so users can operate the feature without direct database access. +- Token expiry and rotation use an injectable clock/time boundary for deterministic tests. + +## Edge cases + +- Copied remember-me cookie with different visitor signal is revoked and audited. +- Reused old rotated token revokes the token family where practical. +- Deleted/inactive users cannot auto-login. +- Owner accounts may use remember-me, but lifecycle revocation and recovery protection still apply. +- APP_SECRET rotation revokes active persistent tokens unless a tested re-encryption/rehash path exists. +- Revoking all other tokens must not destroy the current authenticated session unless the user explicitly revokes the current token/session. +- Concurrent automatic logins with the same selector/token must rotate or revoke deterministically and audit reuse/mismatch without creating two valid successor tokens. + +## Tests and validation + +- Test issue, auto-login, rotation, expiry, logout revocation, password/status revocation, and APP_SECRET revocation. +- Test visitor mismatch and token reuse audit. +- Test inactive/deleted user denial. +- Test cookie attributes and absence of raw token storage. +- Test profile token list and revoke actions, including current-token and other-token behavior. +- Test concurrent auto-login/rotation behavior and deterministic expiry through the time boundary. +- Test container/security firewall wiring. + +## Documentation and tracking + +- Update Security draft with final remember-me model. +- Update user account docs for the persistent-token review/revocation UI. +- Update class map for entity/provider/services/subscribers. +- Record verification around copied-cookie risk. +- Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. + +## Non-goals + +- No bare identity cookie. +- No indefinite sliding session. +- No MFA/2FA step-up in this branch. + +## Acceptance criteria + +- Persistent login is revocable, auditable, visitor-bound, and bounded to seven days. +- Automatic login cannot revive disabled accounts or bypass existing security checks. diff --git a/dev/manual/local-agent-tooling-snippets.md b/dev/manual/local-agent-tooling-snippets.md index dbf824ab..25517f1e 100644 --- a/dev/manual/local-agent-tooling-snippets.md +++ b/dev/manual/local-agent-tooling-snippets.md @@ -23,7 +23,7 @@ Project-wide developer commands that are useful for agent workflows live outside | `bin/lint` | Runs the full project lint suite or focused lint checks. | | `bin/lint --diff` | Lints supported files from the current staged and unstaged Git diff when Git and a work tree are available. | | `bin/lint --staged` | Lints supported files staged for commit when Git and a work tree are available. | -| `php bin/console render:route /path` | Renders a route through the Symfony kernel with optional debug `--role`, `--user`, `--method`, `--host`, `--https`, and `--setup-completed=0` context. | +| `php bin/console render:route /path` | Renders a route through the Symfony kernel in development/test environments with optional debug `--role`, `--user`, `--method`, `--host`, `--https`, `--header`, `--include-headers`, and `--setup-completed=0` context. | ## Cleanup notes diff --git a/importmap.php b/importmap.php index e3f26ccb..bd4dee3f 100755 --- a/importmap.php +++ b/importmap.php @@ -28,13 +28,9 @@ '@hotwired/stimulus' => ['version' => '3.2.2'], '@hotwired/turbo' => ['version' => '8.0.23'], 'codemirror' => ['version' => '6.0.2'], - '@codemirror/view' => ['version' => '6.43.0'], '@codemirror/state' => ['version' => '6.6.0'], '@codemirror/language' => ['version' => '6.12.3'], '@codemirror/commands' => ['version' => '6.10.3'], - '@codemirror/search' => ['version' => '6.7.0'], - '@codemirror/autocomplete' => ['version' => '6.20.2'], - '@codemirror/lint' => ['version' => '6.9.6'], 'style-mod' => ['version' => '4.1.3'], 'w3c-keyname' => ['version' => '2.2.8'], 'crelt' => ['version' => '1.0.6'], @@ -56,7 +52,6 @@ '@codemirror/lang-json' => ['version' => '6.0.2'], '@lezer/json' => ['version' => '1.0.3'], '@codemirror/lang-markdown' => ['version' => '6.5.0'], - '@lezer/markdown' => ['version' => '1.6.3'], 'tom-select' => ['version' => '2.6.1'], '@orchidjs/sifter' => ['version' => '1.1.0'], '@orchidjs/unicode-variants' => ['version' => '1.1.2'], @@ -65,7 +60,6 @@ 'tom-select/dist/css/tom-select.bootstrap4.css' => ['version' => '2.6.1', 'type' => 'css'], 'tom-select/dist/css/tom-select.bootstrap5.css' => ['version' => '2.6.1', 'type' => 'css'], 'chart.js' => ['version' => '4.5.1'], - '@kurkle/color' => ['version' => '0.3.4'], 'cropperjs' => ['version' => '1.6.2'], 'cropperjs/dist/cropper.min.css' => ['version' => '1.6.2', 'type' => 'css'], '@symfony/ux-live-component' => ['path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js'], @@ -92,4 +86,10 @@ 'leaflet' => ['version' => '1.9.4'], 'leaflet/dist/leaflet.min.css' => ['version' => '1.9.4', 'type' => 'css'], '@symfony/ux-leaflet-map' => ['path' => './vendor/symfony/ux-leaflet-map/assets/dist/map_controller.js'], + '@codemirror/lint' => ['version' => '6.9.7'], + '@lezer/markdown' => ['version' => '1.6.4'], + '@kurkle/color' => ['version' => '0.4.0'], + '@codemirror/autocomplete' => ['version' => '6.20.3'], + '@codemirror/view' => ['version' => '6.43.1'], + '@codemirror/search' => ['version' => '6.7.1'], ]; diff --git a/migrations/Version20260531000000.php b/migrations/Version20260531000000.php index 45e309be..14b46855 100644 --- a/migrations/Version20260531000000.php +++ b/migrations/Version20260531000000.php @@ -155,6 +155,103 @@ public function up(Schema $schema): void $this->addIndex($accessStatistic, ['country', 'occurred_at'], 'idx_access_statistic_country_at'); $this->addIndex($accessStatistic, ['continent', 'occurred_at'], 'idx_access_statistic_continent_at'); + $messageLog = $schema->createTable('message_log_entry'); + $messageLog->addColumn('uid', 'string', ['length' => 36]); + $messageLog->addColumn('occurred_at', 'datetime_immutable'); + $messageLog->addColumn('level', 'string', ['length' => 16]); + $messageLog->addColumn('message', 'string', ['length' => 255]); + $messageLog->addColumn('code', 'string', ['length' => 160, 'notnull' => false]); + $messageLog->addColumn('context', 'json'); + $this->addPrimaryKey($messageLog, 'uid'); + $this->addIndex($messageLog, ['occurred_at'], 'idx_message_log_occurred_at'); + $this->addIndex($messageLog, ['level', 'occurred_at'], 'idx_message_log_level_at'); + $this->addIndex($messageLog, ['code', 'occurred_at'], 'idx_message_log_code_at'); + + $auditLog = $schema->createTable('audit_log_entry'); + $auditLog->addColumn('uid', 'string', ['length' => 36]); + $auditLog->addColumn('occurred_at', 'datetime_immutable'); + $auditLog->addColumn('user_name', 'string', ['length' => 180]); + $auditLog->addColumn('user_uid', 'string', ['length' => 36, 'notnull' => false]); + $auditLog->addColumn('user_access_level', 'integer'); + $auditLog->addColumn('action', 'string', ['length' => 160]); + $auditLog->addColumn('request_id', 'string', ['length' => 64]); + $auditLog->addColumn('visitor_id', 'string', ['length' => 64]); + $auditLog->addColumn('requested_path', 'string', ['length' => 1024]); + $auditLog->addColumn('resolved_route', 'string', ['length' => 190]); + $auditLog->addColumn('context', 'json'); + $this->addPrimaryKey($auditLog, 'uid'); + $this->addIndex($auditLog, ['occurred_at'], 'idx_audit_log_occurred_at'); + $this->addIndex($auditLog, ['action', 'occurred_at'], 'idx_audit_log_action_at'); + $this->addIndex($auditLog, ['user_uid', 'occurred_at'], 'idx_audit_log_user_at'); + $this->addIndex($auditLog, ['request_id'], 'idx_audit_log_request_id'); + + $accessLog = $schema->createTable('access_log_entry'); + $accessLog->addColumn('uid', 'string', ['length' => 36]); + $accessLog->addColumn('occurred_at', 'datetime_immutable'); + $accessLog->addColumn('request_id', 'string', ['length' => 64]); + $accessLog->addColumn('correlation_id', 'string', ['length' => 64]); + $accessLog->addColumn('method', 'string', ['length' => 16]); + $accessLog->addColumn('path', 'string', ['length' => 1024]); + $accessLog->addColumn('requested_path', 'string', ['length' => 1024]); + $accessLog->addColumn('route', 'string', ['length' => 190]); + $accessLog->addColumn('resolved_route', 'string', ['length' => 190]); + $accessLog->addColumn('surface', 'string', ['length' => 40]); + $accessLog->addColumn('query_string', 'string', ['length' => 1024]); + $accessLog->addColumn('http_status', 'integer'); + $accessLog->addColumn('duration_ms', 'integer', ['notnull' => false]); + $accessLog->addColumn('visitor_id', 'string', ['length' => 64]); + $accessLog->addColumn('scheme', 'string', ['length' => 10]); + $accessLog->addColumn('host', 'string', ['length' => 255]); + $accessLog->addColumn('client_ip', 'string', ['length' => 45]); + $accessLog->addColumn('proxy_client_ip', 'string', ['length' => 45]); + $accessLog->addColumn('user_agent', 'string', ['length' => 500]); + $accessLog->addColumn('referrer', 'string', ['length' => 1024]); + $accessLog->addColumn('referrer_host', 'string', ['length' => 255]); + $accessLog->addColumn('accept_language', 'string', ['length' => 255]); + $accessLog->addColumn('preferred_language', 'string', ['length' => 20]); + $accessLog->addColumn('request_content_type', 'string', ['length' => 120]); + $accessLog->addColumn('response_content_type', 'string', ['length' => 120]); + $accessLog->addColumn('response_size', 'integer', ['notnull' => false]); + $accessLog->addColumn('city', 'string', ['length' => 80]); + $accessLog->addColumn('state', 'string', ['length' => 80]); + $accessLog->addColumn('country', 'string', ['length' => 80]); + $accessLog->addColumn('continent', 'string', ['length' => 80]); + $accessLog->addColumn('context', 'json'); + $this->addPrimaryKey($accessLog, 'uid'); + $this->addIndex($accessLog, ['occurred_at'], 'idx_access_log_occurred_at'); + $this->addIndex($accessLog, ['request_id'], 'idx_access_log_request_id'); + $this->addIndex($accessLog, ['visitor_id', 'occurred_at'], 'idx_access_log_visitor_at'); + $this->addIndex($accessLog, ['surface', 'occurred_at'], 'idx_access_log_surface_at'); + $this->addIndex($accessLog, ['resolved_route', 'occurred_at'], 'idx_access_log_route_at'); + $this->addIndex($accessLog, ['http_status', 'occurred_at'], 'idx_access_log_status_at'); + $this->addIndex($accessLog, ['client_ip', 'occurred_at'], 'idx_access_log_client_ip_at'); + + $securitySignalLog = $schema->createTable('security_signal_event'); + $securitySignalLog->addColumn('uid', 'string', ['length' => 36]); + $securitySignalLog->addColumn('occurred_at', 'datetime_immutable'); + $securitySignalLog->addColumn('expires_at', 'datetime_immutable'); + $securitySignalLog->addColumn('signal_type', 'string', ['length' => 80]); + $securitySignalLog->addColumn('reason_code', 'string', ['length' => 120]); + $securitySignalLog->addColumn('severity', 'string', ['length' => 16]); + $securitySignalLog->addColumn('confidence', 'integer'); + $securitySignalLog->addColumn('subject_type', 'string', ['length' => 40]); + $securitySignalLog->addColumn('subject_identifier', 'string', ['length' => 190]); + $securitySignalLog->addColumn('ip_derived', 'boolean'); + $securitySignalLog->addColumn('request_family', 'string', ['length' => 40]); + $securitySignalLog->addColumn('request_intent', 'string', ['length' => 80]); + $securitySignalLog->addColumn('request_id', 'string', ['length' => 64]); + $securitySignalLog->addColumn('visitor_id', 'string', ['length' => 64]); + $securitySignalLog->addColumn('path', 'string', ['length' => 1024]); + $securitySignalLog->addColumn('route', 'string', ['length' => 190]); + $securitySignalLog->addColumn('http_status', 'integer', ['notnull' => false]); + $securitySignalLog->addColumn('context', 'json'); + $this->addPrimaryKey($securitySignalLog, 'uid'); + $this->addIndex($securitySignalLog, ['occurred_at'], 'idx_security_signal_occurred_at'); + $this->addIndex($securitySignalLog, ['expires_at'], 'idx_security_signal_expires_at'); + $this->addIndex($securitySignalLog, ['subject_type', 'subject_identifier', 'occurred_at'], 'idx_security_signal_subject_at'); + $this->addIndex($securitySignalLog, ['signal_type', 'occurred_at'], 'idx_security_signal_type_at'); + $this->addIndex($securitySignalLog, ['reason_code', 'occurred_at'], 'idx_security_signal_reason_at'); + $aclGroup = $schema->createTable('acl_group'); $aclGroup->addColumn('uid', 'string', ['length' => 36]); $aclGroup->addColumn('identifier', 'string', ['length' => 80]); @@ -390,6 +487,10 @@ public function down(Schema $schema): void 'user_acl_group', 'user_account', 'acl_group', + 'security_signal_event', + 'access_log_entry', + 'audit_log_entry', + 'message_log_entry', 'scheduler_task_run', 'scheduler_task', 'package_setting_entry', diff --git a/src/Api/Admin/AdminDeferredApiHandler.php b/src/Api/Admin/AdminDeferredApiHandler.php index 3f072442..f39a4d15 100644 --- a/src/Api/Admin/AdminDeferredApiHandler.php +++ b/src/Api/Admin/AdminDeferredApiHandler.php @@ -17,6 +17,7 @@ public function __construct( private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -32,6 +33,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.backup_restore', 'listAdminBackups')) { + return $denied; + } + return $this->responder->data([ 'type' => 'api_navigation', 'id' => $endpoint->path(), diff --git a/src/Api/Admin/AdminFeatureApiGuard.php b/src/Api/Admin/AdminFeatureApiGuard.php new file mode 100644 index 00000000..8897293e --- /dev/null +++ b/src/Api/Admin/AdminFeatureApiGuard.php @@ -0,0 +1,66 @@ +adminAcl->isVisible($feature, $this->actor($request))) { + return null; + } + + return $this->denied($request, $feature, $operation, 'feature_hidden'); + } + + public function denyUnlessMutable(Request $request, string $feature, string $operation): ?Response + { + if ($this->adminAcl->isMutable($feature, $this->actor($request))) { + return null; + } + + return $this->denied($request, $feature, $operation, 'feature_read_only'); + } + + public function isMutable(Request $request, string $feature): bool + { + return $this->adminAcl->isMutable($feature, $this->actor($request)); + } + + private function actor(Request $request): AccessActor + { + return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + } + + private function denied(Request $request, string $feature, string $operation, string $reason): Response + { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => $operation, + ], [ + 'feature' => $feature, + 'reason' => $reason, + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } +} diff --git a/src/Api/Admin/AdminLogApiHandler.php b/src/Api/Admin/AdminLogApiHandler.php index 9bff4019..7335bd80 100644 --- a/src/Api/Admin/AdminLogApiHandler.php +++ b/src/Api/Admin/AdminLogApiHandler.php @@ -12,7 +12,7 @@ use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Core\Access\AccessLevel; -use App\Core\Log\LogFileBrowser; +use App\Core\Log\AdminLogBrowser; use App\Core\Message\Message; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,10 +20,11 @@ final readonly class AdminLogApiHandler implements ApiEndpointHandlerInterface { public function __construct( - private LogFileBrowser $logs, + private AdminLogBrowser $logs, private ApiListQueryNormalizer $listQueries, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -39,12 +40,19 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.logs', 'listAdminLogs')) { + return $denied; + } + $source = $this->sourceFromPath($request->getPathInfo()); if (null === $source) { $view = $this->logs->browse([]); + $sources = $this->featureGuard->isMutable($request, 'admin.logs') + ? $view['sources'] + : $this->visibleSources($view['sources']); - return $this->responder->data($this->sourceResources($view['sources']), meta: [ - 'count' => count($view['sources']), + return $this->responder->data($this->sourceResources($sources), meta: [ + 'count' => count($sources), 'default_source' => $view['selected_source'], ]); } @@ -52,6 +60,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo if (!$this->isKnownSource($source)) { return $this->notFound($request, $source); } + if ($this->isSensitiveSource($source)) { + $denied = $this->featureGuard->denyUnlessMutable($request, 'admin.logs', 'readAdminLogSource'); + if (null !== $denied) { + return $denied; + } + } $view = $this->logs->browse([ ...$this->listQueries->backendQuery($request->query->all()), @@ -75,18 +89,50 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo */ private function sourceResources(array $sources): array { - return array_map(static fn (array $source): array => [ + return array_map(fn (array $source): array => [ 'type' => 'log_source', 'id' => $source['key'], 'attributes' => [ 'source' => $source['key'], 'label_key' => $source['label'], 'path' => '/api/v1/admin/logs/'.$source['key'], - 'filters' => ['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + 'filters' => $this->filtersForSource((string) $source['key']), ], ], $sources); } + /** + * @return list + */ + private function filtersForSource(string $source): array + { + return match ($source) { + 'application' => ['level', 'q', 'match', 'time_window', 'limit', 'page'], + 'message' => ['level', 'q', 'match', 'time_window', 'limit', 'page'], + 'audit' => ['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + 'security_signal' => ['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], + default => ['q', 'match', 'time_window', 'limit', 'page'], + }; + } + + /** + * @param list $sources + * + * @return list + */ + private function visibleSources(array $sources): array + { + return array_values(array_filter( + $sources, + fn (array $source): bool => !$this->isSensitiveSource((string) ($source['key'] ?? '')), + )); + } + + private function isSensitiveSource(string $source): bool + { + return in_array($source, ['audit', 'security_signal'], true); + } + private function sourceFromPath(string $path): ?string { $prefix = '/api/v1/admin/logs/'; diff --git a/src/Api/Admin/AdminOperationApiHandler.php b/src/Api/Admin/AdminOperationApiHandler.php index 8afa0608..b7a12575 100644 --- a/src/Api/Admin/AdminOperationApiHandler.php +++ b/src/Api/Admin/AdminOperationApiHandler.php @@ -11,6 +11,7 @@ use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; +use App\Backend\AdminOperationFeatureResolver; use App\Core\Access\AccessLevel; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; @@ -29,6 +30,8 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, + private AdminOperationFeatureResolver $operationFeatures, ) { } @@ -44,6 +47,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.operations', 'listAdminOperations')) { + return $denied; + } + $maintenanceAction = $this->maintenanceActionFromPath($request->getPathInfo()); if (null !== $maintenanceAction) { return $this->maintenance($request, $maintenanceAction); @@ -72,6 +79,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function maintenance(Request $request, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.operations', 'runAdminOperationMaintenance')) { + return $denied; + } + } + if (!$request->query->getBoolean('confirm')) { return $this->responder->data([ 'type' => 'operation_maintenance_review', @@ -144,6 +157,12 @@ private function operation(Request $request, string $operationId): Response private function continueOperation(Request $request, string $operationId): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.operations', 'continueAdminOperation')) { + return $denied; + } + } + if (null === $this->operations->report($operationId)) { return $this->notFound($request, $operationId); } @@ -156,6 +175,13 @@ private function continueOperation(Request $request, string $operationId): Respo ]); } + if ($request->query->getBoolean('confirm')) { + $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); + if (is_string($targetFeature) && $denied = $this->featureGuard->denyUnlessMutable($request, $targetFeature, 'continueAdminOperation')) { + return $denied; + } + } + if (!$request->query->getBoolean('confirm')) { return $this->responder->data([ 'type' => 'operation_continuation', diff --git a/src/Api/Admin/AdminOperationalApiEndpointProvider.php b/src/Api/Admin/AdminOperationalApiEndpointProvider.php index 24133eab..21a739f6 100644 --- a/src/Api/Admin/AdminOperationalApiEndpointProvider.php +++ b/src/Api/Admin/AdminOperationalApiEndpointProvider.php @@ -23,7 +23,7 @@ public function apiEndpoints(): array return [ $this->endpoint('/api/v1/admin/backups', 'listAdminBackups', 'List backup API capabilities prepared for future backup operations.', self::HANDLER_BACKUPS), $this->endpoint('/api/v1/admin/logs', 'listAdminLogSources', 'List administrative log sources visible to administrators.', self::HANDLER_LOGS), - $this->endpoint('/api/v1/admin/logs/{log}', 'listAdminLogEntries', 'List administrative log entries for one log source.', self::HANDLER_LOGS, parameters: $this->logParameters(), pathPattern: '#^/api/v1/admin/logs/[a-z]+$#'), + $this->endpoint('/api/v1/admin/logs/{log}', 'listAdminLogEntries', 'List administrative log entries for one log source.', self::HANDLER_LOGS, parameters: $this->logParameters(), pathPattern: '#^/api/v1/admin/logs/[a-z_]+$#'), $this->endpoint('/api/v1/admin/operations', 'listAdminOperations', 'List live operation runs visible to administrators.', self::HANDLER_OPERATIONS), $this->endpoint('/api/v1/admin/operations/{action}', 'runAdminOperationMaintenance', 'Review or run an administrative live-operation maintenance action.', self::HANDLER_OPERATIONS, Request::METHOD_POST, parameters: $this->maintenanceOperationParameters(), responseSchema: ['type' => 'object'], pathPattern: '#^/api/v1/admin/operations/(cleanup|clear-stale-lock|kill-stale-runner)$#'), $this->endpoint('/api/v1/admin/operations/{operation_id}', 'getAdminOperation', 'Return one live operation report visible to administrators.', self::HANDLER_OPERATIONS, parameters: $this->operationParameters(), pathPattern: '#^/api/v1/admin/operations/[a-f0-9]{32}$#'), @@ -76,7 +76,7 @@ private function endpoint( private function logParameters(): array { return [ - ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['application', 'message', 'audit', 'access']]], + ['name' => 'log', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['application', 'message', 'audit', 'access', 'security_signal']]], ['name' => 'level', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'q', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']], ['name' => 'match', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string', 'enum' => ['contains', 'equals']]], diff --git a/src/Api/Admin/AdminSchedulerApiHandler.php b/src/Api/Admin/AdminSchedulerApiHandler.php index ff73b778..c4e0f3ec 100644 --- a/src/Api/Admin/AdminSchedulerApiHandler.php +++ b/src/Api/Admin/AdminSchedulerApiHandler.php @@ -35,6 +35,7 @@ public function __construct( private ApiJsonRequestParser $jsonRequests, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -50,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.scheduler', 'listAdminSchedulerTasks')) { + return $denied; + } + $taskIdentifier = $this->taskIdentifierFromPath($request->getPathInfo()); if (null !== $taskIdentifier) { $task = $this->task($taskIdentifier); @@ -80,6 +85,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo */ private function runTask(Request $request, SchedulerTask $task): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.scheduler', 'runAdminSchedulerTask')) { + return $denied; + } + if (SchedulerTaskStatus::Active !== $task->status()) { return $this->operationUnavailable($request, 'runAdminSchedulerTask', [ 'task_identifier' => $task->identifier(), @@ -99,6 +108,10 @@ private function runTask(Request $request, SchedulerTask $task): Response private function updateTask(Request $request, SchedulerTask $task): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.scheduler', 'updateAdminSchedulerTask')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { diff --git a/src/Api/Admin/AdminStatisticsApiHandler.php b/src/Api/Admin/AdminStatisticsApiHandler.php index 1e60fa30..5688cc03 100644 --- a/src/Api/Admin/AdminStatisticsApiHandler.php +++ b/src/Api/Admin/AdminStatisticsApiHandler.php @@ -19,6 +19,7 @@ public function __construct( private AccessStatisticsSnapshotProvider $statistics, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -34,6 +35,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.settings.statistics', 'getAdminStatistics')) { + return $denied; + } + return $this->responder->data([ 'type' => 'access_statistics', 'id' => (string) $request->query->get('statistics_window', '24h'), diff --git a/src/Api/Admin/AdminThemeApiHandler.php b/src/Api/Admin/AdminThemeApiHandler.php index dea8dec8..43fec259 100644 --- a/src/Api/Admin/AdminThemeApiHandler.php +++ b/src/Api/Admin/AdminThemeApiHandler.php @@ -19,6 +19,7 @@ public function __construct( private ThemeAdminOverview $themes, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -34,6 +35,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.packages', 'listAdminThemes')) { + return $denied; + } + $sections = array_map($this->section(...), $this->themes->sections()); return $this->responder->data($sections, meta: ['count' => count($sections)]); diff --git a/src/Api/Http/ApiTraceHeaderSubscriber.php b/src/Api/Http/ApiTraceHeaderSubscriber.php index 1037a1bd..78032872 100644 --- a/src/Api/Http/ApiTraceHeaderSubscriber.php +++ b/src/Api/Http/ApiTraceHeaderSubscriber.php @@ -4,6 +4,7 @@ namespace App\Api\Http; +use App\Api\Security\ApiRequestMethodPolicy; use App\Core\Log\AccessRequestMetadata; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; @@ -11,8 +12,10 @@ final readonly class ApiTraceHeaderSubscriber implements EventSubscriberInterface { - public function __construct(private AccessRequestMetadata $accessRequestMetadata) - { + public function __construct( + private AccessRequestMetadata $accessRequestMetadata, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } /** @@ -27,7 +30,7 @@ public static function getSubscribedEvents(): array public function onKernelResponse(ResponseEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiAvailabilitySubscriber.php b/src/Api/Security/ApiAvailabilitySubscriber.php index 5ff4f230..562bfa4f 100644 --- a/src/Api/Security/ApiAvailabilitySubscriber.php +++ b/src/Api/Security/ApiAvailabilitySubscriber.php @@ -16,6 +16,7 @@ public function __construct( private ApiAvailabilityCheckerInterface $availabilityChecker, private ApiUnavailableResponder $unavailableResponder, private ApiFeaturePolicy $apiFeaturePolicy, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -28,7 +29,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiContentTypeSubscriber.php b/src/Api/Security/ApiContentTypeSubscriber.php index ddee9965..7a5f0920 100644 --- a/src/Api/Security/ApiContentTypeSubscriber.php +++ b/src/Api/Security/ApiContentTypeSubscriber.php @@ -20,6 +20,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiCorsSubscriber.php b/src/Api/Security/ApiCorsSubscriber.php index 1367b077..aa40905c 100644 --- a/src/Api/Security/ApiCorsSubscriber.php +++ b/src/Api/Security/ApiCorsSubscriber.php @@ -18,8 +18,10 @@ private const ALLOWED_HEADERS = 'Authorization, Content-Type, Accept, Accept-Language, X-Correlation-ID, X-Request-ID'; private const EXPOSED_HEADERS = 'X-Request-ID, X-Correlation-ID'; - public function __construct(private ApiFeaturePolicy $apiFeaturePolicy) - { + public function __construct( + private ApiFeaturePolicy $apiFeaturePolicy, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } /** @@ -40,7 +42,11 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!$this->isApiRequest($request) || !$this->isPreflight($request)) { + if (!$this->methodPolicy->isApiV1Request($request) || !$this->methodPolicy->isCorsPreflight($request)) { + return; + } + + if ($this->methodPolicy->hasAuthorizationHeader($request)) { return; } @@ -61,7 +67,7 @@ public function onKernelResponse(ResponseEvent $event): void } $request = $event->getRequest(); - if (!$this->isApiRequest($request)) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } @@ -73,18 +79,6 @@ public function onKernelResponse(ResponseEvent $event): void $this->applyHeaders($event->getResponse(), $origin); } - private function isApiRequest(Request $request): bool - { - return str_starts_with($request->getPathInfo(), '/api/v1'); - } - - private function isPreflight(Request $request): bool - { - return $request->isMethod(Request::METHOD_OPTIONS) - && is_string($request->headers->get('Origin')) - && is_string($request->headers->get('Access-Control-Request-Method')); - } - private function allowedOrigin(Request $request): ?string { if (!$this->apiFeaturePolicy->corsEnabled()) { diff --git a/src/Api/Security/ApiDatabaseExceptionSubscriber.php b/src/Api/Security/ApiDatabaseExceptionSubscriber.php index bd4548f6..07ce0101 100644 --- a/src/Api/Security/ApiDatabaseExceptionSubscriber.php +++ b/src/Api/Security/ApiDatabaseExceptionSubscriber.php @@ -11,8 +11,10 @@ final readonly class ApiDatabaseExceptionSubscriber implements EventSubscriberInterface { - public function __construct(private ApiUnavailableResponder $unavailableResponder) - { + public function __construct( + private ApiUnavailableResponder $unavailableResponder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } public static function getSubscribedEvents(): array @@ -24,7 +26,7 @@ public static function getSubscribedEvents(): array public function onKernelException(ExceptionEvent $event): void { - if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api/v1')) { + if (!$event->isMainRequest() || !$this->methodPolicy->isApiV1Request($event->getRequest())) { return; } diff --git a/src/Api/Security/ApiEndpointAccessSubscriber.php b/src/Api/Security/ApiEndpointAccessSubscriber.php index 697392a9..6c3467f6 100644 --- a/src/Api/Security/ApiEndpointAccessSubscriber.php +++ b/src/Api/Security/ApiEndpointAccessSubscriber.php @@ -23,6 +23,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiResponder $responder, private SystemPackageMetadataProvider $systemPackageMetadata, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiEndpointPermissionSubscriber.php b/src/Api/Security/ApiEndpointPermissionSubscriber.php index 93d85e75..0829c4ba 100644 --- a/src/Api/Security/ApiEndpointPermissionSubscriber.php +++ b/src/Api/Security/ApiEndpointPermissionSubscriber.php @@ -23,6 +23,7 @@ public function __construct( private ApiEndpointRegistry $endpoints, private ApiEndpointAccessPolicy $policy, private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -40,7 +41,7 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiKeyAuthenticator.php b/src/Api/Security/ApiKeyAuthenticator.php index 7169dcd7..3c02f067 100644 --- a/src/Api/Security/ApiKeyAuthenticator.php +++ b/src/Api/Security/ApiKeyAuthenticator.php @@ -27,12 +27,13 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ApiKeyVault $apiKeyVault, private readonly ApiSecurityHandler $securityHandler, + private readonly ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } public function supports(Request $request): ?bool { - return str_starts_with($request->getPathInfo(), '/api/v1') + return $this->methodPolicy->isApiV1Request($request) && $this->hasBearerAuthorizationScheme($request); } diff --git a/src/Api/Security/ApiMaintenanceModeSubscriber.php b/src/Api/Security/ApiMaintenanceModeSubscriber.php index 314b8b03..76fbf0da 100644 --- a/src/Api/Security/ApiMaintenanceModeSubscriber.php +++ b/src/Api/Security/ApiMaintenanceModeSubscriber.php @@ -15,6 +15,7 @@ public function __construct( private bool $maintenanceEnabled, private ApiUnavailableResponder $unavailableResponder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { } @@ -28,7 +29,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); - if (!$this->maintenanceEnabled || !$event->isMainRequest() || $event->hasResponse() || !str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->maintenanceEnabled || !$event->isMainRequest() || $event->hasResponse() || !$this->methodPolicy->isApiV1Request($request)) { return; } diff --git a/src/Api/Security/ApiReadOnlyMethodSubscriber.php b/src/Api/Security/ApiReadOnlyMethodSubscriber.php index 65c69f71..e8f91a04 100644 --- a/src/Api/Security/ApiReadOnlyMethodSubscriber.php +++ b/src/Api/Security/ApiReadOnlyMethodSubscriber.php @@ -18,8 +18,10 @@ final readonly class ApiReadOnlyMethodSubscriber implements EventSubscriberInterface { - public function __construct(private ApiResponder $responder) - { + public function __construct( + private ApiResponder $responder, + private ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), + ) { } public static function getSubscribedEvents(): array @@ -42,7 +44,7 @@ public function onKernelRequest(RequestEvent $event): void return; } - if ($this->isAllowedReadOnlyMethod($request)) { + if ($this->methodPolicy->isSafeEffectiveMethod($request)) { return; } @@ -57,12 +59,4 @@ public function onKernelRequest(RequestEvent $event): void )); } - private function isAllowedReadOnlyMethod(Request $request): bool - { - return in_array($request->getMethod(), [ - Request::METHOD_GET, - Request::METHOD_HEAD, - Request::METHOD_OPTIONS, - ], true); - } } diff --git a/src/Api/Security/ApiRequestMethodPolicy.php b/src/Api/Security/ApiRequestMethodPolicy.php new file mode 100644 index 00000000..1fc0f7fd --- /dev/null +++ b/src/Api/Security/ApiRequestMethodPolicy.php @@ -0,0 +1,71 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public function isApiV1Request(Request $request): bool + { + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1'); + } + + public function isCorsPreflight(Request $request): bool + { + return $request->isMethod(Request::METHOD_OPTIONS) + && is_string($request->headers->get('Origin')) + && is_string($request->headers->get('Access-Control-Request-Method')); + } + + public function isCredentialedOptions(Request $request): bool + { + return $request->isMethod(Request::METHOD_OPTIONS) && $this->hasAuthorizationHeader($request); + } + + public function hasAuthorizationHeader(Request $request): bool + { + return '' !== trim((string) $request->headers->get('Authorization', '')); + } + + public function effectiveMethod(Request $request): string + { + if ($request->isMethod(Request::METHOD_OPTIONS)) { + return $this->requestedPreflightMethod($request) + ?? ($this->isCredentialedOptions($request) ? Request::METHOD_GET : Request::METHOD_OPTIONS); + } + + return strtoupper($request->getMethod()); + } + + public function isSafeEffectiveMethod(Request $request): bool + { + return $this->isSafeMethod($this->effectiveMethod($request)); + } + + public function isSafeMethod(string $method): bool + { + return in_array(strtoupper($method), [ + Request::METHOD_GET, + Request::METHOD_HEAD, + Request::METHOD_OPTIONS, + ], true); + } + + public function requestedPreflightMethod(Request $request): ?string + { + $method = $request->headers->get('Access-Control-Request-Method'); + + return is_string($method) && '' !== trim($method) ? strtoupper(trim($method)) : null; + } +} diff --git a/src/Backend/AdminControllerContext.php b/src/Backend/AdminControllerContext.php index a713fb92..992afe36 100644 --- a/src/Backend/AdminControllerContext.php +++ b/src/Backend/AdminControllerContext.php @@ -31,7 +31,7 @@ public function accessResponse(Request $request, mixed $user): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); diff --git a/src/Backend/AdminOperationFeatureResolver.php b/src/Backend/AdminOperationFeatureResolver.php new file mode 100644 index 00000000..b3941125 --- /dev/null +++ b/src/Backend/AdminOperationFeatureResolver.php @@ -0,0 +1,25 @@ + 'admin.packages', + LiveOperationQueueFactory::PACKAGE_ASSET_REBUILD, + LiveOperationQueueFactory::BACKEND_CACHE_CLEAR => 'admin.actions.maintenance', + LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE => 'admin.settings.statistics.geoip', + LiveOperationQueueFactory::ACL_GROUP_APPLY => 'admin.users.acl', + default => null, + }; + } +} diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index 7898ea65..a394b5ce 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -4,19 +4,37 @@ namespace App\Backend; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminFeatureDefinition; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; +use App\Core\AdminAcl\AdminPermissionState; +use App\Core\AdminAcl\AdminPermissionSurface; use App\Core\Diagnostics\SystemInfoProvider; -use App\Core\Log\LogFileBrowser; +use App\Core\Geo\GeoIpResolverInterface; +use App\Core\Geo\MaxMindGeoIpConfig; +use App\Core\Log\AdminLogBrowser; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; +use App\Entity\UserAccount; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; final readonly class AdminViewContextProvider { public function __construct( private LiveOperationRunStore $liveOperationRunStore, - private LogFileBrowser $logFileBrowser, + private AdminLogBrowser $logBrowser, private AccessStatisticsSnapshotProvider $accessStatisticsSnapshotProvider, private SystemInfoProvider $systemInfoProvider, + private MaxMindGeoIpConfig $maxMindGeoIpConfig, + private GeoIpResolverInterface $geoIpResolver, + private Security $security, + private BackendActions $backendActions, + private AdminFeatureRegistry $adminFeatureRegistry, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, + private AdminFeatureOverrideStore $adminFeatureOverrideStore, ) { } @@ -31,9 +49,7 @@ public function variables(Request $request, ?BackendViewDefinition $view): array return match ($view->uid()) { 'backend-admin-operations' => $this->operationVariables(), - 'backend-admin-logs' => [ - 'log_view' => $this->logFileBrowser->browse($request->query->all()), - ], + 'backend-admin-logs' => $this->logVariables($request), 'backend-admin-statistics' => [ 'access_statistics' => $this->accessStatisticsSnapshotProvider->snapshot($request->query->get('statistics_window')), 'access_statistics_windows' => $this->accessStatisticsSnapshotProvider->windows(), @@ -41,6 +57,16 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'backend-admin-settings-system-info' => [ 'system_info' => $this->systemInfoProvider->report($request->server->all()), ], + 'backend-admin-settings-statistics' => [ + 'geoip_settings' => [ + 'has_license_key' => $this->maxMindGeoIpConfig->hasLicenseKey(), + 'can_update' => [] !== $this->backendActions->definitions([BackendActions::GEOIP_DATABASE_UPDATE], $this->actor()), + 'status' => $this->geoIpResolver->status()->toSafeArray(), + ], + ], + 'backend-admin-settings-acl' => [ + 'acl_matrix' => $this->aclMatrix(), + ], default => [], }; } @@ -51,8 +77,121 @@ public function variables(Request $request, ?BackendViewDefinition $view): array private function operationVariables(): array { return [ + 'operations_mutable' => $this->adminFeatureAccessPolicy->isMutable('admin.operations', $this->actor()), 'operation_runs' => $this->liveOperationRunStore->summaries(), 'operation_lock' => $this->liveOperationRunStore->runnerLockStatus(3600), ]; } + + /** + * @return array + */ + private function logVariables(Request $request): array + { + $actor = $this->actor(); + $mutable = $this->adminFeatureAccessPolicy->isMutable('admin.logs', $actor); + $query = $request->query->all(); + + if (!$mutable && $this->isSensitiveLogSource($query['source'] ?? null)) { + $query['source'] = 'message'; + } + + $view = $this->logBrowser->browse($query); + + if (!$mutable) { + $view['sources'] = array_values(array_filter( + $view['sources'] ?? [], + fn (array $source): bool => !$this->isSensitiveLogSource($source['key'] ?? null), + )); + } + + return ['log_view' => $view]; + } + + private function isSensitiveLogSource(mixed $source): bool + { + return is_string($source) && in_array($source, ['audit', 'security_signal'], true); + } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + + /** + * @return array + */ + private function aclMatrix(): array + { + $overrides = $this->adminFeatureOverrideStore->overrides(); + $defaults = $this->adminFeatureOverrideStore->defaultOverrides(); + $surfaces = []; + + foreach (AdminPermissionSurface::cases() as $surface) { + $groups = $this->adminFeatureAccessPolicy->availableGroups($surface); + $surfaces[] = [ + 'key' => $surface->value, + 'label_key' => $surface->labelKey(), + 'groups' => $groups, + 'rows' => array_map( + fn (AdminFeatureDefinition $definition): array => $this->aclMatrixRow($definition, $overrides[$definition->identifier()] ?? [], $defaults[$definition->identifier()] ?? [], $groups), + $this->adminFeatureRegistry->definitions($surface), + ), + ]; + } + + return [ + 'states' => array_map( + static fn (AdminPermissionState $state): array => [ + 'value' => $state->value, + 'label_key' => 'admin.acl.states.'.$state->value, + ], + AdminPermissionState::cases(), + ), + 'group_states' => [ + [ + 'value' => '', + 'label_key' => 'admin.acl.states.inherit', + ], + ...array_map( + static fn (AdminPermissionState $state): array => [ + 'value' => $state->value, + 'label_key' => 'admin.acl.states.'.$state->value, + ], + AdminPermissionState::cases(), + ), + ], + 'surfaces' => $surfaces, + ]; + } + + /** + * @param array $override + * @param list $groups + * + * @return array + */ + private function aclMatrixRow(AdminFeatureDefinition $definition, array $override, array $defaultOverride, array $groups): array + { + $groupOverrides = is_array($override['groups'] ?? null) ? $override['groups'] : []; + + return [ + 'identifier' => $definition->identifier(), + 'label_key' => $definition->labelKey(), + 'description_key' => $definition->descriptionKey(), + 'category_key' => $definition->categoryKey(), + 'configurable' => $definition->configurable(), + 'default_state' => AdminPermissionState::fromMixed($defaultOverride['state'] ?? null, $definition->defaultState())->value, + 'state' => AdminPermissionState::fromMixed($override['state'] ?? null, $definition->defaultState())->value, + 'groups' => array_map( + static fn (array $group): array => [ + ...$group, + 'state' => is_string($groupOverrides[$group['identifier']] ?? null) ? (string) $groupOverrides[$group['identifier']] : '', + ], + $groups, + ), + ]; + } } diff --git a/src/Backend/BackendActionResponder.php b/src/Backend/BackendActionResponder.php index a989215c..1237ec4d 100644 --- a/src/Backend/BackendActionResponder.php +++ b/src/Backend/BackendActionResponder.php @@ -5,10 +5,12 @@ namespace App\Backend; use App\Backend\BackendMessageKey; +use App\Core\Access\AccessActor; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; use App\Core\Workflow\WorkflowResult; +use App\Entity\UserAccount; use App\Form\FormTokenValidator; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; @@ -44,13 +46,13 @@ public function respond(Request $request, mixed $user): Response ); if ('1' === $this->stringField($request, '_operation_live')) { - $result = $validToken ? $this->backendActions->startLive($action) : $this->invalidCsrfResult($action); + $result = $validToken ? $this->backendActions->startLive($action, $this->actor($user)) : $this->invalidCsrfResult($action); $this->audit($user, $action, $result, 'live'); return $this->liveOperationResponder->render($result); } - $result = $validToken ? $this->backendActions->run($action) : $this->invalidCsrfResult($action); + $result = $validToken ? $this->backendActions->run($action, $this->actor($user)) : $this->invalidCsrfResult($action); $this->flashResult($result); $this->audit($user, $action, $result, 'sync'); @@ -71,6 +73,11 @@ private function invalidCsrfResult(string $action): WorkflowResult ], ['action' => $action]); } + private function actor(mixed $user): AccessActor + { + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + /** * @param WorkflowResult $result */ diff --git a/src/Backend/BackendActions.php b/src/Backend/BackendActions.php index 564246d5..ca188ce1 100644 --- a/src/Backend/BackendActions.php +++ b/src/Backend/BackendActions.php @@ -6,6 +6,11 @@ use App\Backend\BackendMessageCode; use App\Backend\BackendMessageKey; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessMessageCode; +use App\Core\Access\AccessMessageKey; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\Message; use App\Core\Operation\ActionQueue; use App\Core\Operation\Live\LiveOperationQueueFactory; @@ -24,6 +29,7 @@ public const PACKAGE_DISCOVERY = 'package_discovery'; public const ASSET_REBUILD = 'asset_rebuild'; public const CACHE_CLEAR = 'cache_clear'; + public const GEOIP_DATABASE_UPDATE = 'geoip_database_update'; public function __construct( private KernelInterface $kernel, @@ -32,6 +38,7 @@ public function __construct( private OperationExecutor $operationExecutor, private LiveOperationStarter $liveOperationStarter, private PhpCliBinaryManager $phpCliBinaryManager, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -40,47 +47,79 @@ public function __construct( * * @return list */ - public function definitions(array $ids = []): array + public function definitions(array $ids = [], ?AccessActor $actor = null): array { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); $definitions = [ self::PACKAGE_DISCOVERY => [ 'id' => self::PACKAGE_DISCOVERY, 'label_key' => 'admin.actions.package_discovery.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.packages', ], self::ASSET_REBUILD => [ 'id' => self::ASSET_REBUILD, 'label_key' => 'admin.actions.asset_rebuild.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.actions.maintenance', ], self::CACHE_CLEAR => [ 'id' => self::CACHE_CLEAR, 'label_key' => 'admin.actions.cache_clear.label', 'variant' => 'secondary', 'live' => true, + 'access_feature' => 'admin.actions.maintenance', + ], + self::GEOIP_DATABASE_UPDATE => [ + 'id' => self::GEOIP_DATABASE_UPDATE, + 'label_key' => 'admin.actions.geoip_database_update.label', + 'variant' => 'secondary', + 'live' => true, + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, ], ]; if ([] === $ids) { - return array_values($definitions); + return array_values(array_map( + fn (array $definition): array => $this->publicDefinition($definition, $actor), + array_filter( + $definitions, + fn (array $definition): bool => $this->definitionAllows($definition, $actor), + ), + )); + } + + $visible = []; + foreach ($ids as $id) { + $definition = $definitions[$id] ?? null; + + if ($this->definitionAllows($definition, $actor)) { + $visible[] = $this->publicDefinition($definition, $actor); + } } - return array_values(array_filter( - array_map(static fn (string $id): ?array => $definitions[$id] ?? null, $ids), - )); + return $visible; } /** * @return WorkflowResult */ - public function run(string $action): WorkflowResult + public function run(string $action, ?AccessActor $actor = null): WorkflowResult { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); + + if (!$this->actionAllows($action, $actor)) { + return $this->accessDenied($action, $actor); + } + return match ($action) { self::PACKAGE_DISCOVERY => ($this->packageDiscoveryRunner)('admin_ui'), self::ASSET_REBUILD => $this->assetRebuildDispatcher->dispatch($this->kernel->getEnvironment(), 'admin_ui'), self::CACHE_CLEAR => $this->clearCache(), + self::GEOIP_DATABASE_UPDATE => $this->startLive($action, $actor), default => WorkflowResult::invalid([ Message::warning( BackendMessageCode::BACKEND_ACTION_UNKNOWN, @@ -95,8 +134,14 @@ public function run(string $action): WorkflowResult /** * @return WorkflowResult> */ - public function startLive(string $action): WorkflowResult + public function startLive(string $action, ?AccessActor $actor = null): WorkflowResult { + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); + + if (!$this->actionAllows($action, $actor)) { + return $this->accessDenied($action, $actor); + } + return match ($action) { self::PACKAGE_DISCOVERY => $this->liveOperationStarter->start( LiveOperationQueueFactory::PACKAGE_DISCOVERY, @@ -113,6 +158,11 @@ public function startLive(string $action): WorkflowResult ['environment' => $this->kernel->getEnvironment(), 'trigger' => 'admin_ui'], 'Cache clear', ), + self::GEOIP_DATABASE_UPDATE => $this->liveOperationStarter->start( + LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE, + ['environment' => $this->kernel->getEnvironment(), 'trigger' => 'admin_ui'], + 'GeoIP2 database update', + ), default => WorkflowResult::invalid([ Message::warning( BackendMessageCode::BACKEND_ACTION_UNKNOWN, @@ -124,6 +174,80 @@ public function startLive(string $action): WorkflowResult }; } + /** + * @param array|null $definition + */ + private function definitionAllows(?array $definition, AccessActor $actor): bool + { + if (null === $definition) { + return false; + } + + $feature = $definition['access_feature'] ?? null; + + return !is_string($feature) || $this->adminAcl->isVisible($feature, $actor); + } + + /** + * @param array $definition + * + * @return array + */ + private function publicDefinition(array $definition, AccessActor $actor): array + { + $feature = $definition['access_feature'] ?? null; + + if (is_string($feature)) { + $definition['disabled'] = !$this->adminAcl->isMutable($feature, $actor); + } + + return $definition; + } + + private function actionAllows(string $action, AccessActor $actor): bool + { + if (!in_array($action, [ + self::PACKAGE_DISCOVERY, + self::ASSET_REBUILD, + self::CACHE_CLEAR, + self::GEOIP_DATABASE_UPDATE, + ], true)) { + return true; + } + + $definitions = $this->definitions([$action], $actor); + $definition = $definitions[0] ?? null; + + return is_array($definition) && true !== ($definition['disabled'] ?? false); + } + + /** + * @return WorkflowResult + */ + private function accessDenied(string $action, AccessActor $actor): WorkflowResult + { + return WorkflowResult::invalid([ + Message::warning( + AccessMessageCode::ACCESS_DENIED, + AccessMessageKey::ACCESS_DENIED, + [ + '%capability%' => 'backend.action.'.$action, + '%required_level%' => AccessLevel::OWNER, + '%actor_level%' => $actor->accessLevel(), + ], + [ + ...$actor->toContext(), + 'capability' => 'backend.action.'.$action, + 'required_access_level' => AccessLevel::OWNER, + ], + ), + ], [ + 'action' => $action, + 'required_access_level' => AccessLevel::OWNER, + 'actor_access_level' => $actor->accessLevel(), + ]); + } + /** * @return WorkflowResult */ diff --git a/src/Backend/BackendNavigationSubscriber.php b/src/Backend/BackendNavigationSubscriber.php index 0c28006f..54d8fcc0 100644 --- a/src/Backend/BackendNavigationSubscriber.php +++ b/src/Backend/BackendNavigationSubscriber.php @@ -44,6 +44,7 @@ public function onNavigationBuilder(NavigationBuilderEvent $event): void [ 'min_access_level' => $view->minimumAccessLevel(), 'access_groups' => $view->accessGroups(), + 'access_feature' => $view->accessFeature(), 'route_parameters' => $view->routeParameters(), 'link_attributes' => $view->linkAttributes(), ], diff --git a/src/Backend/BackendViewDefinition.php b/src/Backend/BackendViewDefinition.php index 94273b80..d3ec5823 100644 --- a/src/Backend/BackendViewDefinition.php +++ b/src/Backend/BackendViewDefinition.php @@ -106,4 +106,11 @@ public function context(): array { return $this->context; } + + public function accessFeature(): ?string + { + $feature = $this->context['access_feature'] ?? null; + + return is_string($feature) && '' !== $feature ? $feature : null; + } } diff --git a/src/Backend/CoreAdminSettingsBackendViewProvider.php b/src/Backend/CoreAdminSettingsBackendViewProvider.php new file mode 100644 index 00000000..4523dc0c --- /dev/null +++ b/src/Backend/CoreAdminSettingsBackendViewProvider.php @@ -0,0 +1,87 @@ + + */ + public function backendViews(): array + { + return [ + $this->settingsSection('general', 'admin.navigation.general_settings', 10), + $this->settingsSection('dashboard', 'admin.navigation.dashboard_settings', 20), + $this->settingsSection('users', 'admin.navigation.user_settings', 30), + $this->settingsSection('mail', 'admin.navigation.mail_settings', 40), + $this->settingsSection('security', 'admin.navigation.security_settings', 50, 'admin.settings.security'), + $this->settingsSection('statistics', 'admin.navigation.statistics_settings', 55, 'admin.settings.statistics'), + $this->settingsSection('logging', 'admin.navigation.logging_settings', 57, 'admin.settings.logging'), + $this->settingsSection('api', 'admin.navigation.api_settings', 60, 'admin.settings.api'), + new BackendViewDefinition( + 'backend-admin-settings-acl', + BackendArea::Admin, + 'settings/acl', + 'admin.navigation.acl_settings', + '@backend/admin/settings/acl.html.twig', + 65, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::OWNER, + ), + new BackendViewDefinition( + 'backend-admin-settings-packages', + BackendArea::Admin, + 'settings/packages', + 'admin.navigation.package_settings', + '@backend/admin/settings/packages.html.twig', + 70, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.settings.packages', + ], + ), + $this->settingsSection('scheduler', 'admin.navigation.scheduler_settings', 70, 'admin.settings.scheduler'), + new BackendViewDefinition( + 'backend-admin-settings-system-info', + BackendArea::Admin, + 'settings/system-info', + 'admin.navigation.system_info', + '@backend/admin/settings/system-info.html.twig', + 75, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + ), + ]; + } + + private function settingsSection(string $section, string $label, int $sortOrder, ?string $feature = null): BackendViewDefinition + { + $context = [ + 'title_key' => 'admin.settings.'.$section.'.title', + 'foundation_title_key' => 'admin.settings.'.$section.'.foundation_title', + 'foundation_text_key' => 'admin.settings.'.$section.'.foundation_text', + 'settings_section' => $section, + ]; + + if (null !== $feature) { + $context['access_feature'] = $feature; + } + + return new BackendViewDefinition( + 'backend-admin-settings-'.$section, + BackendArea::Admin, + 'settings/'.$section, + $label, + '@backend/admin/settings/section.html.twig', + $sortOrder, + parentUid: 'backend-admin-settings', + minimumAccessLevel: AccessLevel::ADMIN, + context: $context, + ); + } +} diff --git a/src/Backend/CoreBackendViewProvider.php b/src/Backend/CoreBackendViewProvider.php index f0584431..db33be15 100644 --- a/src/Backend/CoreBackendViewProvider.php +++ b/src/Backend/CoreBackendViewProvider.php @@ -31,6 +31,9 @@ public function backendViews(): array '@backend/admin/packages.html.twig', 20, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.packages', + ], ), new BackendViewDefinition( 'backend-admin-themes', @@ -40,6 +43,9 @@ public function backendViews(): array '@backend/admin/themes.html.twig', 30, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.packages', + ], ), new BackendViewDefinition( 'backend-admin-users', @@ -53,6 +59,7 @@ public function backendViews(): array 'title_key' => 'admin.users.title', 'foundation_title_key' => 'admin.users.foundation_title', 'foundation_text_key' => 'admin.users.foundation_text', + 'access_feature' => 'admin.users', ], ), new BackendViewDefinition( @@ -64,6 +71,9 @@ public function backendViews(): array 10, parentUid: 'backend-admin-users', minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.users.acl', + ], ), new BackendViewDefinition( 'backend-admin-user-reviews', @@ -74,6 +84,9 @@ public function backendViews(): array 20, parentUid: 'backend-admin-users', minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.users.review', + ], ), new BackendViewDefinition( 'backend-admin-scheduler', @@ -87,6 +100,7 @@ public function backendViews(): array 'title_key' => 'admin.scheduler.title', 'foundation_title_key' => 'admin.scheduler.foundation_title', 'foundation_text_key' => 'admin.scheduler.foundation_text', + 'access_feature' => 'admin.scheduler', ], ), new BackendViewDefinition( @@ -101,6 +115,7 @@ public function backendViews(): array 'title_key' => 'admin.backups.title', 'foundation_title_key' => 'admin.backups.foundation_title', 'foundation_text_key' => 'admin.backups.foundation_text', + 'access_feature' => 'admin.backup_restore', ], ), new BackendViewDefinition( @@ -111,6 +126,9 @@ public function backendViews(): array '@backend/admin/operations.html.twig', 70, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.operations', + ], ), new BackendViewDefinition( 'backend-admin-logs', @@ -120,6 +138,9 @@ public function backendViews(): array '@backend/admin/logs.html.twig', 800, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.logs', + ], ), new BackendViewDefinition( 'backend-admin-statistics', @@ -129,6 +150,9 @@ public function backendViews(): array '@backend/admin/statistics.html.twig', 810, minimumAccessLevel: AccessLevel::ADMIN, + context: [ + 'access_feature' => 'admin.settings.statistics', + ], ), new BackendViewDefinition( 'backend-admin-settings', @@ -139,154 +163,6 @@ public function backendViews(): array 900, minimumAccessLevel: AccessLevel::ADMIN, ), - new BackendViewDefinition( - 'backend-admin-settings-general', - BackendArea::Admin, - 'settings/general', - 'admin.navigation.general_settings', - '@backend/admin/settings/section.html.twig', - 10, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.general.title', - 'foundation_title_key' => 'admin.settings.general.foundation_title', - 'foundation_text_key' => 'admin.settings.general.foundation_text', - 'settings_section' => 'general', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-dashboard', - BackendArea::Admin, - 'settings/dashboard', - 'admin.navigation.dashboard_settings', - '@backend/admin/settings/section.html.twig', - 20, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.dashboard.title', - 'foundation_title_key' => 'admin.settings.dashboard.foundation_title', - 'foundation_text_key' => 'admin.settings.dashboard.foundation_text', - 'settings_section' => 'dashboard', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-users', - BackendArea::Admin, - 'settings/users', - 'admin.navigation.user_settings', - '@backend/admin/settings/section.html.twig', - 30, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.users.title', - 'foundation_title_key' => 'admin.settings.users.foundation_title', - 'foundation_text_key' => 'admin.settings.users.foundation_text', - 'settings_section' => 'users', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-mail', - BackendArea::Admin, - 'settings/mail', - 'admin.navigation.mail_settings', - '@backend/admin/settings/section.html.twig', - 40, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.mail.title', - 'foundation_title_key' => 'admin.settings.mail.foundation_title', - 'foundation_text_key' => 'admin.settings.mail.foundation_text', - 'settings_section' => 'mail', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-security', - BackendArea::Admin, - 'settings/security', - 'admin.navigation.security_settings', - '@backend/admin/settings/section.html.twig', - 50, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.security.title', - 'foundation_title_key' => 'admin.settings.security.foundation_title', - 'foundation_text_key' => 'admin.settings.security.foundation_text', - 'settings_section' => 'security', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-statistics', - BackendArea::Admin, - 'settings/statistics', - 'admin.navigation.statistics_settings', - '@backend/admin/settings/section.html.twig', - 55, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.statistics.title', - 'foundation_title_key' => 'admin.settings.statistics.foundation_title', - 'foundation_text_key' => 'admin.settings.statistics.foundation_text', - 'settings_section' => 'statistics', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-packages', - BackendArea::Admin, - 'settings/packages', - 'admin.navigation.package_settings', - '@backend/admin/settings/packages.html.twig', - 70, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - ), - new BackendViewDefinition( - 'backend-admin-settings-api', - BackendArea::Admin, - 'settings/api', - 'admin.navigation.api_settings', - '@backend/admin/settings/section.html.twig', - 60, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.api.title', - 'foundation_title_key' => 'admin.settings.api.foundation_title', - 'foundation_text_key' => 'admin.settings.api.foundation_text', - 'settings_section' => 'api', - ], - ), - new BackendViewDefinition( - 'backend-admin-settings-system-info', - BackendArea::Admin, - 'settings/system-info', - 'admin.navigation.system_info', - '@backend/admin/settings/system-info.html.twig', - 75, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - ), - new BackendViewDefinition( - 'backend-admin-settings-scheduler', - BackendArea::Admin, - 'settings/scheduler', - 'admin.navigation.scheduler_settings', - '@backend/admin/settings/section.html.twig', - 70, - parentUid: 'backend-admin-settings', - minimumAccessLevel: AccessLevel::ADMIN, - context: [ - 'title_key' => 'admin.settings.scheduler.title', - 'foundation_title_key' => 'admin.settings.scheduler.foundation_title', - 'foundation_text_key' => 'admin.settings.scheduler.foundation_text', - 'settings_section' => 'scheduler', - ], - ), new BackendViewDefinition( 'backend-editor-dashboard', BackendArea::Editor, diff --git a/src/Backend/PackageAdminDetailProvider.php b/src/Backend/PackageAdminDetailProvider.php index 39721c85..4cdb3863 100644 --- a/src/Backend/PackageAdminDetailProvider.php +++ b/src/Backend/PackageAdminDetailProvider.php @@ -4,10 +4,15 @@ namespace App\Backend; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Package\ExtensionPackageStatus; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class PackageAdminDetailProvider { @@ -17,6 +22,8 @@ public function __construct( private PackageAdminFileReader $fileReader, private PackageAdminLinkResolver $linkResolver, private PackageDependencyLabelParser $dependencyLabelParser, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -114,31 +121,38 @@ private function scopeRows(array $scopes): array */ private function actions(ExtensionPackage $package): array { + $state = $this->adminAcl->state('admin.packages', $this->actor()); + + if (!$state->isVisible()) { + return []; + } + $stateActions = match ($package->status()) { - ExtensionPackageStatus::Inactive => [$this->action($package, PackageLifecycleAdmin::ACTION_ACTIVATE, 'primary')], - ExtensionPackageStatus::Active => [$this->action($package, PackageLifecycleAdmin::ACTION_DEACTIVATE, 'secondary')], - ExtensionPackageStatus::Faulty => [$this->action($package, PackageLifecycleAdmin::ACTION_RESET_FAULT, 'secondary')], + ExtensionPackageStatus::Inactive => [$this->action($package, PackageLifecycleAdmin::ACTION_ACTIVATE, 'primary', $state)], + ExtensionPackageStatus::Active => [$this->action($package, PackageLifecycleAdmin::ACTION_DEACTIVATE, 'secondary', $state)], + ExtensionPackageStatus::Faulty => [$this->action($package, PackageLifecycleAdmin::ACTION_RESET_FAULT, 'secondary', $state)], ExtensionPackageStatus::Removed => [], }; $cleanupActions = ExtensionPackageStatus::Removed === $package->status() - ? [$this->action($package, PackageLifecycleAdmin::ACTION_PURGE, 'danger')] + ? [$this->action($package, PackageLifecycleAdmin::ACTION_PURGE, 'danger', $state)] : []; if (ExtensionPackageStatus::Removed !== $package->status()) { - $cleanupActions[] = $this->action($package, PackageLifecycleAdmin::ACTION_DELETE, 'danger'); + $cleanupActions[] = $this->action($package, PackageLifecycleAdmin::ACTION_DELETE, 'danger', $state); } return [...$stateActions, ...$cleanupActions]; } - private function action(ExtensionPackage $package, string $action, string $variant): array + private function action(ExtensionPackage $package, string $action, string $variant, AdminPermissionState $state): array { return [ 'id' => $action, 'label_key' => 'admin.packages.lifecycle.'.$this->actionKey($action).'.label', 'path' => $this->actionPath($package->packageName(), $action), 'variant' => $variant, + 'disabled' => !$state->isMutable(), ]; } @@ -172,4 +186,11 @@ private function statusTone(ExtensionPackageStatus $status): string }; } + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + } diff --git a/src/Command/RenderRouteCommand.php b/src/Command/RenderRouteCommand.php index 5875ac21..b6a940a5 100644 --- a/src/Command/RenderRouteCommand.php +++ b/src/Command/RenderRouteCommand.php @@ -21,8 +21,10 @@ )] final class RenderRouteCommand extends Command { - public function __construct(private readonly RouteRenderer $renderer) - { + public function __construct( + private readonly RouteRenderer $renderer, + private readonly string $environment, + ) { parent::__construct(); } @@ -35,14 +37,22 @@ protected function configure(): void ->addOption('user', null, InputOption::VALUE_REQUIRED, 'Existing username to render as.') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'HTTP host for the synthetic request.', 'localhost') ->addOption('https', null, InputOption::VALUE_NONE, 'Render the request as HTTPS.') + ->addOption('header', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'HTTP header to send with the synthetic request, for example "Accept: application/json".') ->addOption('setup-completed', null, InputOption::VALUE_REQUIRED, 'Set to 0 to render setup-required routes without the debug completion bypass.', '1') - ->addOption('include-status', null, InputOption::VALUE_NONE, 'Print the response status line before the response body.'); + ->addOption('include-status', null, InputOption::VALUE_NONE, 'Print the response status line before the response body.') + ->addOption('include-headers', null, InputOption::VALUE_NONE, 'Print response headers before the response body.'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + if ('prod' === $this->environment) { + $io->error('The render:route command is available only for development and test environments.'); + + return Command::FAILURE; + } + try { $role = $this->nullableString($input->getOption('role')); $username = $this->nullableString($input->getOption('user')); @@ -59,6 +69,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int setupCompleted: $this->truthy((string) $input->getOption('setup-completed')), host: (string) $input->getOption('host'), secure: (bool) $input->getOption('https'), + headers: $this->headers($input->getOption('header')), )); } catch (\Throwable $error) { $io->error($error->getMessage()); @@ -70,6 +81,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('HTTP %d', $result->statusCode)); } + if ((bool) $input->getOption('include-headers')) { + foreach ($result->headers as $name => $values) { + foreach ($values as $value) { + $output->writeln($name.': '.$value); + } + } + $output->writeln(''); + } + $output->write($result->content); return $result->statusCode >= 500 ? Command::FAILURE : Command::SUCCESS; @@ -94,6 +114,39 @@ private function nullableString(mixed $value): ?string return is_string($value) && '' !== trim($value) ? trim($value) : null; } + /** + * @return array> + */ + private function headers(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + $headers = []; + foreach ($values as $value) { + if (!is_string($value) || !str_contains($value, ':')) { + throw new \InvalidArgumentException('Headers must use "Name: value" syntax.'); + } + + [$name, $headerValue] = explode(':', $value, 2); + $name = trim($name); + $headerValue = trim($headerValue); + + if ('' === $name || 1 !== preg_match('/^[A-Za-z0-9-]+$/', $name)) { + throw new \InvalidArgumentException(sprintf('Header name "%s" is invalid.', $name)); + } + + if (1 === preg_match('/[\r\n\x00]/', $headerValue)) { + throw new \InvalidArgumentException(sprintf('Header "%s" contains unsupported control characters.', $name)); + } + + $headers[$name][] = $headerValue; + } + + return $headers; + } + private function truthy(string $value): bool { return in_array(strtolower(trim($value, " \t\n\r\0\x0B'\"")), ['1', 'true', 'yes'], true); diff --git a/src/Content/Routing/ContentRouteGuard.php b/src/Content/Routing/ContentRouteGuard.php index dddf2f26..95243a09 100644 --- a/src/Content/Routing/ContentRouteGuard.php +++ b/src/Content/Routing/ContentRouteGuard.php @@ -22,6 +22,7 @@ 'api', 'assets', '_profiler', + 'profiler', '_wdt', 'build', 'packages', diff --git a/src/Controller/AdminAclGroupController.php b/src/Controller/AdminAclGroupController.php index 38883bf6..e7497652 100644 --- a/src/Controller/AdminAclGroupController.php +++ b/src/Controller/AdminAclGroupController.php @@ -6,6 +6,7 @@ use App\Backend\AdminControllerContext; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Id\UuidFactory; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; @@ -30,6 +31,8 @@ final class AdminAclGroupController extends AbstractController { + private const FEATURE = 'admin.users.acl'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -42,6 +45,7 @@ public function __construct( private readonly LiveOperationHttpResponder $liveOperationResponder, private readonly UuidFactory $uuidFactory, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminFeatureAccessPolicy, ) { } @@ -51,8 +55,15 @@ public function groups(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + $this->createGroup($request); return $this->redirectToRoute('backend_admin_user_groups'); @@ -62,6 +73,7 @@ public function groups(Request $request): Response return $this->render('@backend/admin/users/groups.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'acl_mutable' => $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'groups' => $groupsView['items'], 'groups_view' => $groupsView, ]); @@ -73,6 +85,9 @@ public function group(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $group = $this->groupByIdentifier($identifier); @@ -81,6 +96,10 @@ public function group(Request $request, string $identifier): Response } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + if ($response = $this->updateGroup($request, $group)) { return $response; } @@ -90,6 +109,7 @@ public function group(Request $request, string $identifier): Response return $this->render('@backend/admin/users/group-detail.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'acl_mutable' => $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'group' => $group, 'member_count' => $this->memberProvider->count($group), 'members' => $this->memberProvider->members($group), @@ -102,6 +122,9 @@ public function delete(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $group = $this->groupByIdentifier($identifier); @@ -134,12 +157,13 @@ public function delete(Request $request, string $identifier): Response } if ('1' === $this->field($request, '_operation_live')) { - return $this->startAclGroupLiveOperation($group, 'delete'); + return $this->startAclGroupLiveOperation($request, $group, 'delete'); } $cleanupImpact = $this->aclGroupImpact->removeReferences($group); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_deleted', [ 'group' => $group->identifier(), 'impact' => $cleanupImpact['summary'], @@ -174,6 +198,7 @@ private function createGroup(Request $request): void ); $this->entityManager->persist($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_created', ['group' => $group->identifier()]); $this->alertKey('success', 'admin.groups.created'); } catch (Throwable) { @@ -227,7 +252,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response } if ('1' === $this->field($request, '_operation_live')) { - return $this->startAclGroupLiveOperation($group, 'update', $pending); + return $this->startAclGroupLiveOperation($request, $group, 'update', $pending); } try { @@ -237,6 +262,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response $floorCleanup = $this->aclGroupImpact->removeBelowMinRoleReferences($group, $pending['min_role']); $group->changeMinRole($pending['min_role']); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->adminContext->audit($this->getUser(), 'acl.group_updated', [ 'group' => $group->identifier(), 'old_name' => $oldName, @@ -257,8 +283,15 @@ private function updateGroup(Request $request, AclGroup $group): ?Response /** * @param array $payload */ - private function startAclGroupLiveOperation(AclGroup $group, string $action, array $payload = []): Response + private function startAclGroupLiveOperation(Request $request, AclGroup $group, string $action, array $payload = []): Response { + if (!$this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser()))) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => 'mutable', + ]); + } + $result = $this->liveOperationStarter->start( LiveOperationQueueFactory::ACL_GROUP_APPLY, [ @@ -280,6 +313,23 @@ private function startAclGroupLiveOperation(AclGroup $group, string $action, arr return $this->liveOperationResponder->render($result); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminFeatureAccessPolicy->isMutable(self::FEATURE, $actor) + : $this->adminFeatureAccessPolicy->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + private function field(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/AdminOperationController.php b/src/Controller/AdminOperationController.php index 216386cf..391b51fc 100644 --- a/src/Controller/AdminOperationController.php +++ b/src/Controller/AdminOperationController.php @@ -5,7 +5,9 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Backend\AdminOperationFeatureResolver; use App\Backend\BackendArea; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Operation\Live\LiveOperationStarter; @@ -21,6 +23,8 @@ final class AdminOperationController extends AbstractController { + private const FEATURE = 'admin.operations'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -28,6 +32,8 @@ public function __construct( private readonly LiveOperationStarter $liveOperationStarter, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, + private readonly AdminOperationFeatureResolver $operationFeatures, ) { } @@ -39,6 +45,9 @@ public function maintenance(Request $request): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->formTokenValidator->isValid('admin-operations', $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); @@ -100,11 +109,14 @@ public function detail(Request $request, string $operationId): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $report = $this->liveOperationRunStore->report($operationId); if (null === $report) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'operation_id' => $operationId, ]); @@ -114,6 +126,8 @@ public function detail(Request $request, string $operationId): Response 'area' => BackendArea::Admin, 'navigation' => $this->adminContext->navigation($request, $this->getUser()), 'operation_report' => $report, + 'operations_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), + 'operation_continuation_mutable' => $this->operationContinuationMutable($operationId), ]); } @@ -125,6 +139,9 @@ public function continue(Request $request, string $operationId): Response if (null !== $access) { return $access; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->formTokenValidator->isValid('admin-operations', 'admin-operations', $this->stringField($request, '_csrf_token'))) { $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); @@ -138,6 +155,14 @@ public function continue(Request $request, string $operationId): Response return $this->redirectToRoute('backend_admin_operation_detail', ['operationId' => $operationId]); } + $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); + if (is_string($targetFeature) && !$this->adminAcl->isMutable($targetFeature, $this->adminContext->actor($this->getUser()))) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => $targetFeature, + 'required_state' => 'mutable', + ]); + } + $result = $this->liveOperationStarter->start( $continuation['operation'], $continuation['payload'], @@ -170,6 +195,37 @@ private function audit(string $action, array $context = []): void ]); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable ? $this->adminAcl->isMutable(self::FEATURE, $actor) : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + + private function operationContinuationMutable(string $operationId): bool + { + if (!$this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser()))) { + return false; + } + + $continuation = $this->liveOperationRunStore->continuationForOperator($operationId); + if (null === $continuation) { + return false; + } + + $targetFeature = $this->operationFeatures->mutationFeatureForOperation((string) $continuation['operation']); + + return !is_string($targetFeature) || $this->adminAcl->isMutable($targetFeature, $this->adminContext->actor($this->getUser())); + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/AdminPackageController.php b/src/Controller/AdminPackageController.php index 2846a519..ee52ab84 100644 --- a/src/Controller/AdminPackageController.php +++ b/src/Controller/AdminPackageController.php @@ -9,6 +9,11 @@ use App\Backend\BackendArea; use App\Backend\BackendMessageKey; use App\Backend\PackageLifecycleAdmin; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessMessageCode; +use App\Core\Access\AccessMessageKey; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; @@ -16,6 +21,7 @@ use App\Core\Operation\Live\LiveOperationStarter; use App\Core\Package\Install\PackageZipInstaller; use App\Core\Workflow\WorkflowResult; +use App\Entity\UserAccount; use App\Form\FormTokenValidator; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; @@ -29,6 +35,8 @@ final class AdminPackageController extends AbstractController { + private const PACKAGE_LIFECYCLE_FEATURE = 'admin.packages'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -40,6 +48,7 @@ public function __construct( private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, private readonly WorkflowResultAlertSelector $alertSelector, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -52,6 +61,18 @@ public function install(Request $request): Response return $access; } + if (!$this->adminAcl->isMutable(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + $result = $this->accessDeniedResult('package_install'); + + if ('1' === $this->stringField($request, '_operation_live')) { + return $this->liveOperationResponder->render($result); + } + + $this->flashResult($result); + + return $this->redirect('/admin/packages'); + } + $validToken = $this->formTokenValidator->isValid('package-install', $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token')); if (!$validToken) { @@ -115,12 +136,20 @@ public function detail(Request $request, string $packageName): Response return $access; } + if (!$this->adminAcl->isVisible(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => BackendArea::Admin->value, + 'package' => $packageName, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ]); + } + if ($request->isMethod('POST')) { if ($this->backendActionResponder->supports($request)) { return $this->backendActionResponder->respond($request, $this->getUser()); } - return $this->httpError->render(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, ]); @@ -129,7 +158,7 @@ public function detail(Request $request, string $packageName): Response $package = $this->packageLifecycleAdmin->package($packageName); if (null === $package) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, ]); @@ -151,6 +180,17 @@ public function lifecycle(Request $request, string $packageName, string $action) return $access; } + $lifecycleState = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor()); + + if (!$lifecycleState->isVisible()) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => BackendArea::Admin->value, + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ]); + } + if ($request->isMethod('POST') && $this->backendActionResponder->supports($request)) { return $this->backendActionResponder->respond($request, $this->getUser()); } @@ -158,7 +198,7 @@ public function lifecycle(Request $request, string $packageName, string $action) $review = $this->packageLifecycleAdmin->review($packageName, $action); if (null === $review['package']) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'area' => BackendArea::Admin->value, 'package' => $packageName, 'action' => $action, @@ -166,6 +206,17 @@ public function lifecycle(Request $request, string $packageName, string $action) } if ($request->isMethod('POST')) { + if (!$lifecycleState->isMutable()) { + $result = $this->accessDeniedResult('package_lifecycle_'.$action); + $this->flashResult($result); + + if ('1' === $this->stringField($request, '_operation_live')) { + return $this->liveOperationResponder->render($result); + } + + return $this->redirect($request->getPathInfo()); + } + $formId = 'package-lifecycle-'.$action.'-'.$packageName; if (!$this->formTokenValidator->isValid($formId, $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { @@ -200,11 +251,16 @@ public function lifecycle(Request $request, string $packageName, string $action) 'area' => BackendArea::Admin, 'navigation' => $this->adminContext->navigation($request, $this->getUser()), 'review' => $review, + 'lifecycle_mutable' => $lifecycleState->isMutable(), ]); } private function handleLivePackageLifecycle(string $packageName, string $action): Response { + if (!$this->adminAcl->isMutable(self::PACKAGE_LIFECYCLE_FEATURE, $this->actor())) { + return $this->liveOperationResponder->render($this->accessDeniedResult('package_lifecycle_'.$action)); + } + $label = sprintf('Package %s %s', $packageName, $action); $result = $this->liveOperationStarter->start( LiveOperationQueueFactory::PACKAGE_LIFECYCLE, @@ -240,6 +296,39 @@ private function flashResult(WorkflowResult $result): void $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } + /** + * @return WorkflowResult + */ + private function accessDeniedResult(string $capability): WorkflowResult + { + $actor = $this->actor(); + + return WorkflowResult::invalid([ + Message::warning( + AccessMessageCode::ACCESS_DENIED, + AccessMessageKey::ACCESS_DENIED, + [ + '%capability%' => $capability, + '%required_level%' => AccessLevel::OWNER, + '%actor_level%' => $actor->accessLevel(), + ], + [ + ...$actor->toContext(), + 'capability' => $capability, + 'required_access_level' => AccessLevel::OWNER, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + ], + ), + ]); + } + + private function actor(): AccessActor + { + $user = $this->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/src/Controller/AdminSchedulerController.php b/src/Controller/AdminSchedulerController.php index 90dcbf00..a36d3072 100644 --- a/src/Controller/AdminSchedulerController.php +++ b/src/Controller/AdminSchedulerController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Entity\SchedulerTask; @@ -28,6 +29,8 @@ final class AdminSchedulerController extends AbstractController { + private const FEATURE = 'admin.scheduler'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly SchedulerTaskSynchronizer $synchronizer, @@ -36,6 +39,7 @@ public function __construct( private readonly SchedulerRunner $runner, private readonly HttpErrorRenderer $httpError, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -45,6 +49,9 @@ public function runNow(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $token = $request->request->get('_csrf_token'); if (!is_string($token) || !$this->isCsrfTokenValid('scheduler-task-run-'.$identifier, $token)) { @@ -55,7 +62,7 @@ public function runNow(Request $request, string $identifier): Response $task = $this->registeredTask($identifier); if (!$task instanceof SchedulerTask) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'task' => $identifier, ]); } @@ -77,6 +84,9 @@ public function index(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $tasks = $this->synchronizer->synchronize(); usort($tasks, static fn (SchedulerTask $left, SchedulerTask $right): int => $left->identifier() <=> $right->identifier()); @@ -95,11 +105,14 @@ public function detail(Request $request, string $identifier): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: $request->isMethod('POST'))) { + return $response; + } $task = $this->registeredTask($identifier); if (!$task instanceof SchedulerTask) { - return $this->httpError->render(Response::HTTP_NOT_FOUND, $request, context: [ + return $this->httpError->resolve(Response::HTTP_NOT_FOUND, $request, context: [ 'task' => $identifier, ]); } @@ -115,6 +128,24 @@ public function detail(Request $request, string $identifier): Response 'task' => $task, 'runs' => $this->recentRuns($task), 'cron_run_url' => $this->generateUrl('scheduler_cron_run', ['job' => $task->identifier()], UrlGeneratorInterface::ABSOLUTE_URL), + 'scheduler_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), + ]); + } + + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminAcl->isMutable(self::FEATURE, $actor) + : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', ]); } diff --git a/src/Controller/AdminUserController.php b/src/Controller/AdminUserController.php index 94c19f97..1d84e06c 100644 --- a/src/Controller/AdminUserController.php +++ b/src/Controller/AdminUserController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\State\StateMarkerRecorder; @@ -31,6 +32,8 @@ final class AdminUserController extends AbstractController { + private const FEATURE = 'admin.users'; + public function __construct( private readonly AdminControllerContext $adminContext, private readonly HttpErrorRenderer $httpError, @@ -42,6 +45,7 @@ public function __construct( private readonly StateMarkerRecorder $stateMarkers, private readonly DeletedUserCleanup $deletedUserCleanup, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -51,11 +55,17 @@ public function users(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $usersView = $this->adminUserLists->usersView($request); + $mutable = $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())); return $this->render('@backend/admin/users/index.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $mutable, + 'reviews_mutable' => $this->adminAcl->isMutable('admin.users.review', $this->adminContext->actor($this->getUser())), 'users' => $usersView['items'], 'users_view' => $usersView, 'groups' => $this->assignmentOptions->groups($this->adminContext->actor($this->getUser()), UserRole::User), @@ -74,9 +84,13 @@ public function deletedUsers(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } return $this->render('@backend/admin/users/deleted.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())), 'deleted_users' => $this->deletedUserCleanup->deletedUsers(), 'retention_days' => $this->deletedUserCleanup->retentionDays(), 'cleanup_cutoff' => $this->deletedUserCleanup->cutoff(), @@ -89,6 +103,9 @@ public function cleanupDeletedUsers(Request $request): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } if (!$this->isCsrfTokenValid('admin_deleted_users_cleanup', $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -126,6 +143,9 @@ public function user(Request $request, string $username): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $user = $this->userByUsername($username); @@ -138,13 +158,20 @@ public function user(Request $request, string $username): Response } if ($request->isMethod('POST')) { + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } + $this->updateUser($request, $user); return $this->redirectToRoute('backend_admin_user_detail', ['username' => $user->username()]); } + $mutable = $this->adminAcl->isMutable(self::FEATURE, $this->adminContext->actor($this->getUser())); + return $this->render('@backend/admin/users/detail.html.twig', [ 'navigation' => $this->adminContext->navigation($request, $this->getUser()), + 'users_mutable' => $mutable, 'user_account' => $user, 'groups' => $this->assignmentOptions->groups($this->adminContext->actor($this->getUser()), $user->role()), 'role_options' => $this->assignmentOptions->roles($this->adminContext->actor($this->getUser())), @@ -166,6 +193,9 @@ public function passwordReset(Request $request, string $username): Response if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -231,6 +261,9 @@ private function changeDeletedUserStatus(Request $request, string $username, Use if ($response = $this->adminContext->accessResponse($request, $this->getUser())) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -262,6 +295,23 @@ private function changeDeletedUserStatus(Request $request, string $username, Use return $this->redirectToRoute('backend_admin_deleted_users'); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $actor = $this->adminContext->actor($this->getUser()); + $allowed = $mutable + ? $this->adminAcl->isMutable(self::FEATURE, $actor) + : $this->adminAcl->isVisible(self::FEATURE, $actor); + + if ($allowed) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => self::FEATURE, + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + private function userByUsername(string $username): ?UserAccount { $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); diff --git a/src/Controller/AdminUserInvitationController.php b/src/Controller/AdminUserInvitationController.php index 559750e1..afa93ec1 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -7,8 +7,7 @@ use App\Backend\BackendAccessGuard; use App\Backend\BackendArea; use App\Core\Access\AccessActor; -use App\Core\Message\CommonMessageCode; -use App\Core\Message\Message; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Entity\UserAccount; use App\Security\AdminUserInvitationWorkflow; use App\Security\UserRole; @@ -28,6 +27,7 @@ public function __construct( private readonly HttpErrorRenderer $httpError, private readonly AdminUserInvitationWorkflow $invitationWorkflow, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -37,6 +37,9 @@ public function invite(Request $request): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_invite', $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -58,6 +61,9 @@ public function approve(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users.review')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -77,6 +83,9 @@ public function reissue(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users.review')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -96,6 +105,9 @@ public function revoke(Request $request, string $uid): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, 'admin.users.review')) { + return $response; + } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); @@ -141,12 +153,24 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); } + private function featureResponse(Request $request, string $feature): ?Response + { + if ($this->adminAcl->isMutable($feature, $this->actor())) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => $feature, + 'required_state' => 'mutable', + ]); + } + private function actor(): AccessActor { $user = $this->getUser(); diff --git a/src/Controller/AdminUserReviewController.php b/src/Controller/AdminUserReviewController.php index 27151746..b67dee57 100644 --- a/src/Controller/AdminUserReviewController.php +++ b/src/Controller/AdminUserReviewController.php @@ -7,6 +7,7 @@ use App\Backend\BackendAccessGuard; use App\Backend\BackendArea; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; @@ -53,6 +54,7 @@ public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, private readonly StateMarkerRecorder $stateMarkers, private readonly UiAlertDispatcherInterface $alerts, + private readonly AdminFeatureAccessPolicy $adminAcl, ) { } @@ -62,11 +64,16 @@ public function reviews(Request $request): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: false)) { + return $response; + } $reviewView = $this->adminUserReviews->reviewView($request); + $mutable = $this->adminAcl->isMutable('admin.users.review', $this->actor()); return $this->render('@backend/admin/users/reviews.html.twig', [ 'navigation' => $this->navigation($request), + 'reviews_mutable' => $mutable, 'review_items' => $reviewView['items'], 'review_filter' => $reviewView['filters']['filter'], 'review_view' => $reviewView, @@ -80,6 +87,9 @@ public function reactivate(Request $request, string $username): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -126,6 +136,9 @@ public function delete(Request $request, string $username): Response if ($response = $this->adminAccessResponse($request)) { return $response; } + if ($response = $this->featureResponse($request, mutable: true)) { + return $response; + } $user = $this->userByUsername($username); @@ -180,12 +193,28 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); } + private function featureResponse(Request $request, bool $mutable): ?Response + { + $allowed = $mutable + ? $this->adminAcl->isMutable('admin.users.review', $this->actor()) + : $this->adminAcl->isVisible('admin.users.review', $this->actor()); + + if ($allowed) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.users.review', + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + /** * @return list> */ diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 9607b37a..b902307d 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -11,10 +11,12 @@ use App\Backend\BackendRouteResolver; use App\Backend\BackendViewDefinition; use App\Core\Access\AccessActor; -use App\Core\Message\Message; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminAclSettingsFormHandler; use App\Core\Config\Settings\CoreSettingsFormHandler; +use App\Core\Log\AdminLogBrowser; +use App\Core\Message\Message; use App\Core\Log\AuditLoggerInterface; -use App\Core\Log\LogFileBrowser; use App\Core\Package\Settings\PackageSettingsFormHandler; use App\Entity\UserAccount; use App\Form\FormErrorKey; @@ -39,10 +41,12 @@ public function __construct( private readonly NavigationBuilder $navigationBuilder, private readonly HttpErrorRenderer $httpError, private readonly CoreSettingsFormHandler $coreSettingsFormHandler, + private readonly AdminFeatureAccessPolicy $adminAcl, + private readonly AdminAclSettingsFormHandler $adminAclSettingsFormHandler, private readonly PackageSettingsFormHandler $packageSettingsFormHandler, private readonly AdminViewContextProvider $adminViewContextProvider, private readonly BackendActionResponder $backendActionResponder, - private readonly LogFileBrowser $logFileBrowser, + private readonly AdminLogBrowser $logBrowser, private readonly AuditLoggerInterface $auditLogger, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, @@ -55,7 +59,7 @@ public function adminIndex(Request $request): Response return $this->handle($request, BackendArea::Admin); } - #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '[a-f0-9]{24}'], methods: ['GET'])] + #[Route('/admin/logs/{entryId}', name: 'backend_admin_log_detail', requirements: ['entryId' => '(?:[0-9a-fA-F]{24}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})'], methods: ['GET'])] public function logDetail(Request $request, string $entryId): Response { $access = $this->adminAccessResponse($request); @@ -63,9 +67,24 @@ public function logDetail(Request $request, string $entryId): Response if (null !== $access) { return $access; } + if (!$this->adminAcl->isVisible('admin.logs', $this->actor())) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.logs', + 'required_state' => 'visible', + ]); + } $source = $request->query->get('source', 'message'); - $entry = $this->logFileBrowser->entry(is_string($source) ? $source : 'message', $entryId); + $source = is_string($source) ? $source : 'message'; + if (in_array($source, ['audit', 'security_signal'], true) && !$this->adminAcl->isMutable('admin.logs', $this->actor())) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.logs', + 'required_state' => 'mutable', + 'source' => $source, + ]); + } + + $entry = $this->logBrowser->entry($source, $entryId); if (null === $entry) { return $this->httpError->notFound($request); @@ -106,7 +125,7 @@ private function handle(Request $request, BackendArea $area, string $path = ''): $decision = $this->accessGuard->decide($area, $this->getUser()); if (!$decision->isGranted()) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => $area->value, 'access_decision' => $decision->toArray(), ]); @@ -120,7 +139,7 @@ private function handle(Request $request, BackendArea $area, string $path = ''): $view = $result->view(); if (null !== $view && !$this->viewAllows($view, $actor)) { - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => $area->value, 'view' => $view->uid(), ]); @@ -154,7 +173,7 @@ private function adminAccessResponse(Request $request): ?Response return null; } - return $this->httpError->render(Response::HTTP_UNAUTHORIZED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ 'area' => BackendArea::Admin->value, 'access_decision' => $decision->toArray(), ]); @@ -187,6 +206,12 @@ private function actor(): AccessActor private function viewAllows(BackendViewDefinition $view, AccessActor $actor): bool { + $feature = $view->accessFeature(); + + if (is_string($feature) && !$this->adminAcl->isVisible($feature, $actor)) { + return false; + } + if ($actor->accessLevel() >= $view->minimumAccessLevel()) { return true; } @@ -218,27 +243,43 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): $expectedFormId = 'admin-settings-'.$context['settings_section']; $auditAction = 'settings.core.save'; $auditContext = ['section' => $context['settings_section']]; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) - ? $this->coreSettingsFormHandler->submit($context['settings_section'], $request->request->all(), $this->actor()->userUid()) + ? $this->coreSettingsFormHandler->submit($context['settings_section'], $request->request->all(), $this->actor()->userUid(), $this->actor()) : $this->invalidCsrfResult($request); } elseif ('backend-admin-settings-packages' === $view->uid()) { $expectedFormId = 'admin-settings-packages'; $auditAction = 'settings.core.save'; $auditContext = ['section' => 'packages']; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) - ? $this->coreSettingsFormHandler->submit('packages', $request->request->all(), $this->actor()->userUid()) + ? $this->coreSettingsFormHandler->submit('packages', $request->request->all(), $this->actor()->userUid(), $this->actor()) + : $this->invalidCsrfResult($request); + } elseif ('backend-admin-settings-acl' === $view->uid()) { + $expectedFormId = 'admin-settings-acl'; + $auditAction = 'settings.acl.save'; + $auditContext = ['section' => 'acl']; + $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) + ? $this->adminAclSettingsFormHandler->submit($request->request->all(), $this->actor()->userUid()) : $this->invalidCsrfResult($request); } elseif (isset($context['package_name']) && is_string($context['package_name'])) { $expectedFormId = 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($context['package_name'])); $auditAction = 'settings.package.save'; $auditContext = ['package' => $context['package_name']]; + if ($response = $this->mutationDeniedResponse($request, $view)) { + return $response; + } $result = $this->formTokenValidator->isValid($expectedFormId, $formId, $token) ? $this->packageSettingsFormHandler->submit($context['package_name'], $request->request->all(), $this->actor()->userUid()) : $this->invalidCsrfResult($request); } if (!$result instanceof FormSubmissionResult) { - return $this->httpError->render(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ + return $this->httpError->resolve(Response::HTTP_METHOD_NOT_ALLOWED, $request, context: [ 'area' => $view->area()->value, 'view' => $view->uid(), ]); @@ -263,17 +304,37 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): return null; } + private function mutationDeniedResponse(Request $request, BackendViewDefinition $view): ?Response + { + $feature = $view->accessFeature(); + + if (!is_string($feature) || $this->adminAcl->isMutable($feature, $this->actor())) { + return null; + } + + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => $view->area()->value, + 'view' => $view->uid(), + 'access_feature' => $feature, + ]); + } + /** * @param array $context */ private function auditFormSubmission(string $action, FormSubmissionResult $result, array $context = []): void { - $settingKeys = array_keys($result->values()); + $settingKeys = array_filter( + array_keys($result->values()), + static fn (string $key): bool => !str_starts_with($key, '_'), + ); sort($settingKeys); + $auditContext = $result->value('_audit'); try { $this->auditLogger->log($this->actor(), $action, [ ...$context, + ...(is_array($auditContext) ? $auditContext : []), 'result_status' => 'success', 'setting_keys' => $settingKeys, ]); diff --git a/src/Core/AdminAcl/AdminAclSettingsFormHandler.php b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php new file mode 100644 index 00000000..d476253d --- /dev/null +++ b/src/Core/AdminAcl/AdminAclSettingsFormHandler.php @@ -0,0 +1,182 @@ + $submitted + */ + public function submit(array $submitted, ?string $modifiedBy = null): FormSubmissionResult + { + $values = $submitted['acl'] ?? []; + + if (!is_array($values)) { + return new FormSubmissionResult($submitted, ['__form' => [FormErrorKey::INVALID]]); + } + + $currentDefinitions = $this->registry->definitions(AdminPermissionSurface::Admin); + $currentIdentifiers = array_fill_keys(array_map( + static fn (AdminFeatureDefinition $definition): string => $definition->identifier(), + $currentDefinitions, + ), true); + $previousOverrides = $this->store->overrides(); + $overrides = []; + + foreach ($previousOverrides as $feature => $override) { + if (!isset($currentIdentifiers[$feature])) { + $overrides[$feature] = $override; + } + } + + foreach ($currentDefinitions as $definition) { + if (!$definition->configurable()) { + continue; + } + + $row = $values[$definition->identifier()] ?? []; + if (!is_array($row)) { + continue; + } + + $state = AdminPermissionState::fromMixed($row['state'] ?? null, $definition->defaultState()); + $groups = $this->groupOverrides($row, $definition->surface()); + + $overrides[$definition->identifier()] = [ + 'state' => $state->value, + 'groups' => $groups, + ]; + } + + if (!$this->store->save($overrides, $modifiedBy)) { + return new FormSubmissionResult($submitted, ['__form' => [FormErrorKey::SAVE_FAILED]]); + } + + $this->registry->resetCache(); + + return new FormSubmissionResult([ + 'acl' => $overrides, + '_audit' => [ + 'changed_features' => $this->changedFeatures($previousOverrides, $overrides, $currentIdentifiers), + ], + ], []); + } + + /** + * @param array $row + * + * @return array + */ + private function groupOverrides(array $row, AdminPermissionSurface $surface): array + { + $submittedGroups = $row['groups'] ?? []; + + if (!is_array($submittedGroups)) { + return []; + } + + $allowed = array_fill_keys(array_map( + static fn (array $group): string => $group['identifier'], + $this->policy->availableGroups($surface), + ), true); + $groups = []; + + foreach ($submittedGroups as $identifier => $state) { + if (!is_string($identifier) || !isset($allowed[$identifier])) { + continue; + } + + $groupState = is_string($state) ? AdminPermissionState::tryFrom($state) : null; + if ($groupState instanceof AdminPermissionState) { + $groups[$identifier] = $groupState->value; + } + } + + ksort($groups); + + return $groups; + } + + /** + * @param array> $previous + * @param array> $next + * @param array $currentIdentifiers + * + * @return list, next_groups: array}> + */ + private function changedFeatures(array $previous, array $next, array $currentIdentifiers): array + { + $features = array_values(array_unique([...array_keys($previous), ...array_keys($next)])); + sort($features); + $changed = []; + + foreach ($features as $feature) { + if (!is_string($feature) || !isset($currentIdentifiers[$feature])) { + continue; + } + + $previousRow = $previous[$feature] ?? []; + $nextRow = $next[$feature] ?? []; + $previousGroups = $this->auditGroups($previousRow['groups'] ?? []); + $nextGroups = $this->auditGroups($nextRow['groups'] ?? []); + $previousState = is_string($previousRow['state'] ?? null) ? $previousRow['state'] : null; + $nextState = is_string($nextRow['state'] ?? null) ? $nextRow['state'] : null; + + if ($previousState === $nextState && $previousGroups === $nextGroups) { + continue; + } + + $changed[] = [ + 'feature' => $feature, + 'previous_state' => $previousState, + 'next_state' => $nextState, + 'previous_groups' => $previousGroups, + 'next_groups' => $nextGroups, + ]; + } + + return $changed; + } + + /** + * @param mixed $groups + * + * @return array + */ + private function auditGroups(mixed $groups): array + { + return is_array($groups) ? $this->normalizeGroups($groups) : []; + } + + /** + * @param array $groups + * + * @return array + */ + private function normalizeGroups(array $groups): array + { + $normalized = []; + + foreach ($groups as $identifier => $state) { + if (is_string($identifier) && is_string($state)) { + $normalized[$identifier] = $state; + } + } + + ksort($normalized); + + return $normalized; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureAccessPolicy.php b/src/Core/AdminAcl/AdminFeatureAccessPolicy.php new file mode 100644 index 00000000..2072494d --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureAccessPolicy.php @@ -0,0 +1,207 @@ +> + */ + private array $allowedGroupsBySurface = []; + + public function __construct( + private readonly AdminFeatureRegistry $registry, + private readonly AdminFeatureOverrideStore $overrides, + private readonly EntityManagerInterface $entityManager, + private readonly ?CacheInterface $cache = null, + ) { + } + + public function state(string $feature, AccessActor $actor): AdminPermissionState + { + $definition = $this->registry->find($feature); + + if (!$definition instanceof AdminFeatureDefinition) { + return AdminPermissionState::Denied; + } + + if (!$this->parentFeaturesVisible($feature, $actor)) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() < $definition->surface()->gateAccessLevel()) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() >= AccessLevel::OWNER) { + return $definition->ownerState(); + } + + $override = $definition->configurable() ? ($this->overrides->overrides()[$feature] ?? []) : []; + return $this->stateWithGroupOverrides($definition, $actor, $override); + } + + private function parentFeaturesVisible(string $feature, AccessActor $actor): bool + { + $parts = explode('.', $feature); + + while (count($parts) > 2) { + array_pop($parts); + $parent = implode('.', $parts); + $definition = $this->registry->find($parent); + + if ($definition instanceof AdminFeatureDefinition && !$this->stateWithoutParentGate($parent, $actor)->isVisible()) { + return false; + } + } + + return true; + } + + private function stateWithoutParentGate(string $feature, AccessActor $actor): AdminPermissionState + { + $definition = $this->registry->find($feature); + + if (!$definition instanceof AdminFeatureDefinition) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() < $definition->surface()->gateAccessLevel()) { + return AdminPermissionState::Denied; + } + + if ($actor->accessLevel() >= AccessLevel::OWNER) { + return $definition->ownerState(); + } + + $override = $definition->configurable() ? ($this->overrides->overrides()[$feature] ?? []) : []; + return $this->stateWithGroupOverrides($definition, $actor, $override); + } + + /** + * @param array $override + */ + private function stateWithGroupOverrides(AdminFeatureDefinition $definition, AccessActor $actor, array $override): AdminPermissionState + { + $roleState = AdminPermissionState::fromMixed($override['state'] ?? null, $definition->defaultState()); + $groups = is_array($override['groups'] ?? null) ? $override['groups'] : []; + $allowedGroups = $this->allowedGroupIdentifiers($definition->surface()); + $effectiveGroupState = null; + + foreach ($groups as $identifier => $submittedState) { + if ( + is_string($identifier) + && in_array($identifier, $allowedGroups, true) + && $actor->hasGroupIdentifier($identifier) + ) { + $candidate = AdminPermissionState::fromMixed($submittedState); + $effectiveGroupState = null === $effectiveGroupState ? $candidate : AdminPermissionState::max($effectiveGroupState, $candidate); + } + } + + return $effectiveGroupState ?? $roleState; + } + + public function isVisible(string $feature, AccessActor $actor): bool + { + return $this->state($feature, $actor)->isVisible(); + } + + public function isMutable(string $feature, AccessActor $actor): bool + { + return $this->state($feature, $actor)->isMutable(); + } + + /** + * @return list + */ + public function availableGroups(AdminPermissionSurface $surface): array + { + $key = $this->groupsCacheKey($surface); + + if (null !== $this->cache) { + try { + return $this->cache->get( + $key, + function (CacheItemInterface $item) use ($surface): array { + $item->expiresAfter(300); + + return $this->loadAvailableGroups($surface); + }, + ); + } catch (Throwable) { + return $this->loadAvailableGroups($surface); + } + } + + return $this->loadAvailableGroups($surface); + } + + public function resetCache(): void + { + $this->allowedGroupsBySurface = []; + + foreach (AdminPermissionSurface::cases() as $surface) { + try { + $this->cache?->delete($this->groupsCacheKey($surface)); + } catch (Throwable) { + } + } + } + + /** + * @return list + */ + private function loadAvailableGroups(AdminPermissionSurface $surface): array + { + $groups = $this->entityManager->createQueryBuilder() + ->select('aclGroup') + ->from(AclGroup::class, 'aclGroup') + ->andWhere('aclGroup.minRole >= :minRole') + ->setParameter('minRole', $surface->gateAccessLevel()) + ->orderBy('aclGroup.identifier', 'ASC') + ->getQuery() + ->getResult(); + + return array_values(array_map( + static fn (AclGroup $group): array => [ + 'identifier' => $group->identifier(), + 'name' => $group->name(), + 'min_role' => $group->minRole(), + ], + $groups, + )); + } + + private function groupsCacheKey(AdminPermissionSurface $surface): string + { + return 'admin_acl.available_groups.'.$surface->value.'.v1'; + } + + /** + * @return list + */ + private function allowedGroupIdentifiers(AdminPermissionSurface $surface): array + { + $key = $surface->value; + + if (!isset($this->allowedGroupsBySurface[$key])) { + $this->allowedGroupsBySurface[$key] = array_map( + static fn (array $group): string => $group['identifier'], + $this->availableGroups($surface), + ); + } + + return $this->allowedGroupsBySurface[$key]; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureDefaults.php b/src/Core/AdminAcl/AdminFeatureDefaults.php new file mode 100644 index 00000000..657f151b --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureDefaults.php @@ -0,0 +1,42 @@ +}> + */ + public function overrides(): array + { + return [ + 'admin.settings.logging' => $this->row(AdminPermissionState::Visible), + 'admin.settings.statistics' => $this->row(AdminPermissionState::Mutable), + 'admin.settings.statistics.geoip' => $this->row(AdminPermissionState::Visible), + 'admin.settings.api' => $this->row(AdminPermissionState::Denied), + 'admin.settings.scheduler' => $this->row(AdminPermissionState::Visible), + 'admin.logs' => $this->row(AdminPermissionState::Visible), + 'admin.packages' => $this->row(AdminPermissionState::Visible), + 'admin.operations' => $this->row(AdminPermissionState::Visible), + 'admin.actions.maintenance' => $this->row(AdminPermissionState::Mutable), + 'admin.scheduler' => $this->row(AdminPermissionState::Visible), + 'admin.settings.packages' => $this->row(AdminPermissionState::Visible), + 'admin.users' => $this->row(AdminPermissionState::Mutable), + 'admin.users.acl' => $this->row(AdminPermissionState::Mutable), + 'admin.users.review' => $this->row(AdminPermissionState::Mutable), + ]; + } + + /** + * @return array{state: string, groups: array} + */ + private function row(AdminPermissionState $state): array + { + return [ + 'state' => $state->value, + 'groups' => [], + ]; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureDefinition.php b/src/Core/AdminAcl/AdminFeatureDefinition.php new file mode 100644 index 00000000..251c1c7c --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureDefinition.php @@ -0,0 +1,65 @@ +identifier; + } + + public function surface(): AdminPermissionSurface + { + return AdminPermissionSurface::fromFeatureIdentifier($this->identifier); + } + + public function labelKey(): string + { + return $this->labelKey; + } + + public function descriptionKey(): string + { + return $this->descriptionKey; + } + + public function categoryKey(): string + { + return $this->categoryKey; + } + + public function defaultState(): AdminPermissionState + { + return $this->defaultState; + } + + public function ownerState(): AdminPermissionState + { + return $this->ownerState; + } + + public function configurable(): bool + { + return $this->configurable; + } + + public function sortOrder(): int + { + return $this->sortOrder; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureOverrideStore.php b/src/Core/AdminAcl/AdminFeatureOverrideStore.php new file mode 100644 index 00000000..bd924b1d --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureOverrideStore.php @@ -0,0 +1,139 @@ +}>|null + */ + private ?array $overrides = null; + + public function __construct( + private readonly Config $config, + private readonly ?AdminFeatureDefaults $defaults = null, + private readonly ?CacheInterface $cache = null, + ) + { + } + + /** + * @return array}> + */ + public function overrides(): array + { + if (null !== $this->overrides) { + return $this->overrides; + } + + if (null !== $this->cache) { + try { + return $this->overrides = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadOverrides(); + }, + ); + } catch (Throwable) { + return $this->overrides = $this->loadOverrides(); + } + } + + return $this->overrides = $this->loadOverrides(); + } + + /** + * @return array}> + */ + private function loadOverrides(): array + { + $value = $this->config->get(self::CONFIG_KEY, $this->defaultOverrides()); + + $overrides = $this->defaultOverrides(); + + if (!is_array($value)) { + return $overrides; + } + + foreach ($value as $feature => $override) { + if (!is_string($feature) || !is_array($override)) { + continue; + } + + $groups = $override['groups'] ?? []; + $overrides[$feature] = [ + 'state' => is_string($override['state'] ?? null) ? $override['state'] : AdminPermissionState::Denied->value, + 'groups' => is_array($groups) ? $this->normalizeGroups($groups) : [], + ]; + } + + return $overrides; + } + + /** + * @return array}> + */ + public function defaultOverrides(): array + { + return $this->defaults?->overrides() ?? []; + } + + /** + * @param array}> $overrides + */ + public function save(array $overrides, ?string $modifiedBy = null): bool + { + $saved = $this->config->set(self::CONFIG_KEY, $overrides, ConfigValueType::Json, modifiedBy: $modifiedBy); + + if ($saved) { + $this->resetCache(); + } + + return $saved; + } + + public function resetCache(): void + { + $this->overrides = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { + } + } + + /** + * @param array $groups + * + * @return array + */ + private function normalizeGroups(array $groups): array + { + $normalized = []; + + foreach ($groups as $identifier => $state) { + if (is_string($identifier) && is_string($state) && null !== AdminPermissionState::tryFrom($state)) { + $normalized[$identifier] = $state; + } + } + + ksort($normalized); + + return $normalized; + } +} diff --git a/src/Core/AdminAcl/AdminFeatureProviderInterface.php b/src/Core/AdminAcl/AdminFeatureProviderInterface.php new file mode 100644 index 00000000..27bf0d33 --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureProviderInterface.php @@ -0,0 +1,13 @@ + + */ + public function adminFeatures(): array; +} diff --git a/src/Core/AdminAcl/AdminFeatureRegistry.php b/src/Core/AdminAcl/AdminFeatureRegistry.php new file mode 100644 index 00000000..77a6d1c5 --- /dev/null +++ b/src/Core/AdminAcl/AdminFeatureRegistry.php @@ -0,0 +1,141 @@ +|null + */ + private ?array $allDefinitions = null; + + /** + * @var array|null + */ + private ?array $definitionsByIdentifier = null; + + /** + * @param iterable $providers + */ + public function __construct(private readonly iterable $providers, private readonly ?CacheInterface $cache = null) + { + } + + /** + * @return list + */ + public function definitions(?AdminPermissionSurface $surface = null): array + { + $definitions = array_filter( + $this->allDefinitions(), + static fn (AdminFeatureDefinition $definition): bool => null === $surface || $definition->surface() === $surface, + ); + + usort( + $definitions, + static fn (AdminFeatureDefinition $left, AdminFeatureDefinition $right): int => [ + $left->surface()->value, + $left->sortOrder(), + $left->identifier(), + ] <=> [ + $right->surface()->value, + $right->sortOrder(), + $right->identifier(), + ], + ); + + return array_values($definitions); + } + + public function find(string $identifier): ?AdminFeatureDefinition + { + return $this->definitionMap()[$identifier] ?? null; + } + + /** + * @return list + */ + private function allDefinitions(): array + { + if (null !== $this->allDefinitions) { + return $this->allDefinitions; + } + + if (null !== $this->cache) { + try { + return $this->allDefinitions = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadDefinitions(); + }, + ); + } catch (Throwable) { + return $this->allDefinitions = $this->loadDefinitions(); + } + } + + return $this->allDefinitions = $this->loadDefinitions(); + } + + public function resetCache(): void + { + $this->allDefinitions = null; + $this->definitionsByIdentifier = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { + } + } + + /** + * @return array + */ + private function definitionMap(): array + { + if (null !== $this->definitionsByIdentifier) { + return $this->definitionsByIdentifier; + } + + $map = []; + foreach ($this->allDefinitions() as $definition) { + $map[$definition->identifier()] = $definition; + } + + return $this->definitionsByIdentifier = $map; + } + + /** + * @return list + */ + private function loadDefinitions(): array + { + $definitions = []; + $seen = []; + + foreach ($this->providers as $provider) { + foreach ($provider->adminFeatures() as $definition) { + if (isset($seen[$definition->identifier()])) { + continue; + } + + $seen[$definition->identifier()] = true; + $definitions[] = $definition; + } + } + + return $definitions; + } +} diff --git a/src/Core/AdminAcl/AdminPermissionState.php b/src/Core/AdminAcl/AdminPermissionState.php new file mode 100644 index 00000000..ad198128 --- /dev/null +++ b/src/Core/AdminAcl/AdminPermissionState.php @@ -0,0 +1,41 @@ + 0, + self::Visible => 1, + self::Mutable => 2, + }; + } + + public function isVisible(): bool + { + return $this->rank() >= self::Visible->rank(); + } + + public function isMutable(): bool + { + return self::Mutable === $this; + } + + public static function fromMixed(mixed $value, self $default = self::Denied): self + { + return is_string($value) ? (self::tryFrom($value) ?? $default) : $default; + } + + public static function max(self $left, self $right): self + { + return $left->rank() >= $right->rank() ? $left : $right; + } +} diff --git a/src/Core/AdminAcl/AdminPermissionSurface.php b/src/Core/AdminAcl/AdminPermissionSurface.php new file mode 100644 index 00000000..1f4a9ef9 --- /dev/null +++ b/src/Core/AdminAcl/AdminPermissionSurface.php @@ -0,0 +1,39 @@ + AccessLevel::ADMIN, + self::Editor => AccessLevel::AUTHOR, + self::Frontend => AccessLevel::PUBLIC, + }; + } + + public function labelKey(): string + { + return 'admin.acl.surfaces.'.$this->value; + } + + public static function fromFeatureIdentifier(string $identifier): self + { + $prefix = strtok($identifier, '.'); + + return match ($prefix) { + 'editor' => self::Editor, + 'frontend' => self::Frontend, + default => self::Admin, + }; + } +} diff --git a/src/Core/AdminAcl/CoreAdminFeatureProvider.php b/src/Core/AdminAcl/CoreAdminFeatureProvider.php new file mode 100644 index 00000000..1d662b5f --- /dev/null +++ b/src/Core/AdminAcl/CoreAdminFeatureProvider.php @@ -0,0 +1,148 @@ + + */ + public function adminFeatures(): array + { + return [ + new AdminFeatureDefinition( + 'admin.settings.security', + 'admin.acl.features.admin_settings_security.label', + 'admin.acl.features.admin_settings_security.description', + 'admin.acl.categories.settings', + configurable: false, + sortOrder: 10, + ), + new AdminFeatureDefinition( + 'admin.settings.logging', + 'admin.acl.features.admin_settings_logging.label', + 'admin.acl.features.admin_settings_logging.description', + 'admin.acl.categories.settings', + sortOrder: 20, + ), + new AdminFeatureDefinition( + 'admin.settings.statistics', + 'admin.acl.features.admin_settings_statistics.label', + 'admin.acl.features.admin_settings_statistics.description', + 'admin.acl.categories.settings', + sortOrder: 30, + ), + new AdminFeatureDefinition( + 'admin.settings.statistics.geoip', + 'admin.acl.features.admin_settings_statistics_geoip.label', + 'admin.acl.features.admin_settings_statistics_geoip.description', + 'admin.acl.categories.settings', + sortOrder: 40, + ), + new AdminFeatureDefinition( + 'admin.settings.api', + 'admin.acl.features.admin_settings_api.label', + 'admin.acl.features.admin_settings_api.description', + 'admin.acl.categories.settings', + sortOrder: 50, + ), + new AdminFeatureDefinition( + 'admin.settings.scheduler', + 'admin.acl.features.admin_settings_scheduler.label', + 'admin.acl.features.admin_settings_scheduler.description', + 'admin.acl.categories.settings', + sortOrder: 60, + ), + new AdminFeatureDefinition( + 'admin.settings.packages', + 'admin.acl.features.admin_settings_packages.label', + 'admin.acl.features.admin_settings_packages.description', + 'admin.acl.categories.settings', + sortOrder: 70, + ), + new AdminFeatureDefinition( + 'admin.logs', + 'admin.acl.features.admin_logs.label', + 'admin.acl.features.admin_logs.description', + 'admin.acl.categories.diagnostics', + sortOrder: 100, + ), + new AdminFeatureDefinition( + 'admin.packages', + 'admin.acl.features.admin_packages.label', + 'admin.acl.features.admin_packages.description', + 'admin.acl.categories.packages', + sortOrder: 110, + ), + new AdminFeatureDefinition( + 'admin.packages.self_update', + 'admin.acl.features.admin_packages_self_update.label', + 'admin.acl.features.admin_packages_self_update.description', + 'admin.acl.categories.packages', + configurable: false, + sortOrder: 120, + ), + new AdminFeatureDefinition( + 'admin.backup_restore', + 'admin.acl.features.admin_backup_restore.label', + 'admin.acl.features.admin_backup_restore.description', + 'admin.acl.categories.system', + defaultState: AdminPermissionState::Visible, + configurable: false, + sortOrder: 130, + ), + new AdminFeatureDefinition( + 'admin.operations', + 'admin.acl.features.admin_operations.label', + 'admin.acl.features.admin_operations.description', + 'admin.acl.categories.operations', + sortOrder: 140, + ), + new AdminFeatureDefinition( + 'admin.actions.maintenance', + 'admin.acl.features.admin_actions_maintenance.label', + 'admin.acl.features.admin_actions_maintenance.description', + 'admin.acl.categories.operations', + sortOrder: 150, + ), + new AdminFeatureDefinition( + 'admin.scheduler', + 'admin.acl.features.admin_scheduler.label', + 'admin.acl.features.admin_scheduler.description', + 'admin.acl.categories.operations', + sortOrder: 160, + ), + new AdminFeatureDefinition( + 'admin.users', + 'admin.acl.features.admin_users.label', + 'admin.acl.features.admin_users.description', + 'admin.acl.categories.users', + sortOrder: 170, + ), + new AdminFeatureDefinition( + 'admin.users.acl', + 'admin.acl.features.admin_users_acl.label', + 'admin.acl.features.admin_users_acl.description', + 'admin.acl.categories.users', + sortOrder: 180, + ), + new AdminFeatureDefinition( + 'admin.users.review', + 'admin.acl.features.admin_users_review.label', + 'admin.acl.features.admin_users_review.description', + 'admin.acl.categories.users', + sortOrder: 190, + ), + new AdminFeatureDefinition( + 'admin.support', + 'admin.acl.features.admin_support.label', + 'admin.acl.features.admin_support.description', + 'admin.acl.categories.system', + configurable: false, + sortOrder: 200, + ), + ]; + } +} diff --git a/src/Core/Config/Api/SettingsApiHandler.php b/src/Core/Config/Api/SettingsApiHandler.php index b9ef356f..beeb9ac9 100644 --- a/src/Core/Config/Api/SettingsApiHandler.php +++ b/src/Core/Config/Api/SettingsApiHandler.php @@ -12,6 +12,7 @@ use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Message\CommonMessageCode; @@ -43,16 +44,17 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + $actor = $this->actor($request); $section = $this->sectionFromPath($request->getPathInfo()); if ($request->isMethod(Request::METHOD_PATCH)) { return null === $section ? $this->notFound($request, null) - : $this->updateSection($request, $section); + : $this->updateSection($request, $section, $actor); } $settings = null === $section - ? $this->readModel->sections() - : $this->readModel->settings($section); + ? $this->readModel->sections($actor) + : $this->readModel->settings($section, $actor); if (null !== $section && [] === $settings) { return $this->notFound($request, $section); } @@ -75,9 +77,9 @@ private function sectionFromPath(string $path): ?string return '' === $section ? null : $section; } - private function updateSection(Request $request, string $section): Response + private function updateSection(Request $request, string $section, AccessActor $actor): Response { - $currentValues = $this->readModel->values($section); + $currentValues = $this->readModel->values($section, $actor); if ([] === $currentValues) { return $this->notFound($request, $section); } @@ -102,13 +104,13 @@ private function updateSection(Request $request, string $section): Response $result = $this->formHandler->submit($section, [ ...$currentValues, ...$values, - ], $context?->actor()->username()); + ], $context?->actor()->username(), $actor); if (!$result->isValid()) { return $this->validationFailed($request, $result->errors(), ['section' => $section]); } - $settings = $this->readModel->settings($section); + $settings = $this->readModel->settings($section, $actor); return $this->responder->data($settings, meta: [ 'count' => count($settings), @@ -141,6 +143,11 @@ private function invalidRequest(Request $request, string $reason): Response ); } + private function actor(Request $request): AccessActor + { + return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + } + /** * @param array $errors * @param array $context diff --git a/src/Core/Config/Api/SettingsApiReadModel.php b/src/Core/Config/Api/SettingsApiReadModel.php index fdce31b4..514736eb 100644 --- a/src/Core/Config/Api/SettingsApiReadModel.php +++ b/src/Core/Config/Api/SettingsApiReadModel.php @@ -4,7 +4,11 @@ namespace App\Core\Config\Api; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Config\Config; +use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreSettingsRegistry; final readonly class SettingsApiReadModel @@ -12,17 +16,23 @@ public function __construct( private CoreSettingsRegistry $settings, private Config $config, + private ?AdminFeatureAccessPolicy $adminAcl = null, ) { } /** * @return list> */ - public function sections(): array + public function sections(?AccessActor $actor = null): array { $sections = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { + if (!$this->definitionVisible($definition, $actor)) { + continue; + } + $field = $definition->formField(); if (false === ($field->metadata()['persist'] ?? true)) { continue; @@ -48,16 +58,21 @@ public function sections(): array /** * @return list> */ - public function settings(?string $section = null): array + public function settings(?string $section = null, ?AccessActor $actor = null): array { $resources = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); foreach ($this->settings->allDefinitions() as $definition) { if (null !== $section && $definition->section() !== $section) { continue; } - $field = $definition->formField(); + if (!$this->definitionVisible($definition, $actor)) { + continue; + } + + $field = $this->decorateDefinition($definition, $actor)->formField(); if (false === ($field->metadata()['persist'] ?? true)) { continue; @@ -69,8 +84,8 @@ public function settings(?string $section = null): array 'attributes' => [ 'section' => $definition->section(), 'key' => $field->name(), - 'value' => $this->config->get($field->name(), $field->defaultValue()), - 'default_value' => $field->defaultValue(), + 'value' => $this->apiValue($field->metadata(), $this->config->get($field->name(), $field->defaultValue())), + 'default_value' => $this->apiValue($field->metadata(), $field->defaultValue()), 'value_type' => $field->valueType()->value, 'input_type' => $field->inputType()->value, 'label_key' => $field->label(), @@ -89,17 +104,80 @@ public function settings(?string $section = null): array /** * @return array */ - public function values(string $section): array + public function values(string $section, ?AccessActor $actor = null): array { $values = []; + $actor ??= AccessActor::fromAccess(AccessLevel::ADMIN); + + foreach ($this->settings->allDefinitions() as $definition) { + if ($definition->section() !== $section) { + continue; + } - foreach ($this->settings($section) as $resource) { - $id = $resource['id'] ?? null; - if (is_string($id)) { - $values[$id] = $resource['attributes']['value'] ?? null; + if (!$this->definitionMutable($definition, $actor)) { + continue; + } + + $field = $definition->formField(); + if (false === ($field->metadata()['persist'] ?? true)) { + continue; } + + $values[$field->name()] = true === ($field->metadata()['sensitive'] ?? false) + ? '' + : $this->config->get($field->name(), $field->defaultValue()); } return $values; } + + /** + * @param array $metadata + */ + private function apiValue(array $metadata, mixed $value): mixed + { + if (true !== ($metadata['sensitive'] ?? false)) { + return $value; + } + + return is_string($value) && '' !== trim($value) ? '[protected]' : ''; + } + + private function decorateDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (!is_string($feature) || null === $this->adminAcl) { + return $definition; + } + + $state = $this->adminAcl->state($feature, $actor); + + return $definition->withMetadata([ + 'access_state' => $state->value, + 'read_only' => !$state->isMutable(), + ]); + } + + private function definitionVisible(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isVisible($feature, $actor); + } + + return $definition->allows($actor); + } + + private function definitionMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } } diff --git a/src/Core/Config/Settings/CoreConfigDefaultProvider.php b/src/Core/Config/Settings/CoreConfigDefaultProvider.php index 7f98b5e2..1e1f2a03 100644 --- a/src/Core/Config/Settings/CoreConfigDefaultProvider.php +++ b/src/Core/Config/Settings/CoreConfigDefaultProvider.php @@ -4,6 +4,8 @@ namespace App\Core\Config\Settings; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\ConfigDefaultProviderInterface; final class CoreConfigDefaultProvider implements ConfigDefaultProviderInterface @@ -13,8 +15,10 @@ final class CoreConfigDefaultProvider implements ConfigDefaultProviderInterface */ private ?array $defaults = null; - public function __construct(private readonly CoreSettingsRegistry $registry) - { + public function __construct( + private readonly CoreSettingsRegistry $registry, + private readonly ?AdminFeatureDefaults $adminFeatureDefaults = null, + ) { } public function hasDefault(string $key): bool @@ -46,6 +50,8 @@ private function defaults(): array $defaults[$definition->key()] = $definition->defaultValue(); } + $defaults[AdminFeatureOverrideStore::CONFIG_KEY] = ($this->adminFeatureDefaults ?? new AdminFeatureDefaults())->overrides(); + return $this->defaults = $defaults; } } diff --git a/src/Core/Config/Settings/CoreSettingDefinition.php b/src/Core/Config/Settings/CoreSettingDefinition.php index 600721a2..27f416fb 100644 --- a/src/Core/Config/Settings/CoreSettingDefinition.php +++ b/src/Core/Config/Settings/CoreSettingDefinition.php @@ -4,6 +4,9 @@ namespace App\Core\Config\Settings; +use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; +use App\Core\Access\AccessRule; use App\Core\Config\ConfigValueType; use App\Form\FormFieldDefinition; use App\Form\FormInputType; @@ -58,6 +61,53 @@ public function metadata(): array return $this->metadata; } + /** + * @param array $metadata + */ + public function withMetadata(array $metadata): self + { + return new self( + $this->section, + $this->key, + $this->label, + $this->defaultValue, + $this->valueType, + $this->inputType, + $this->help, + $this->options, + $this->validation, + [...$this->metadata, ...$metadata], + $this->sortOrder, + ); + } + + public function minimumAccessLevel(): int + { + $level = $this->metadata['minimum_access_level'] ?? AccessLevel::ADMIN; + + return is_int($level) ? AccessLevel::assert($level) : AccessLevel::ADMIN; + } + + public function allowsAccessLevel(int $accessLevel): bool + { + return $this->allows(AccessActor::fromAccess($accessLevel)); + } + + public function accessRule(): AccessRule + { + $groups = $this->metadata['access_groups'] ?? []; + + return AccessRule::from( + $this->minimumAccessLevel(), + is_array($groups) ? $groups : [], + ); + } + + public function allows(AccessActor $actor): bool + { + return $this->accessRule()->allows($actor); + } + public function formField(): FormFieldDefinition { return new FormFieldDefinition( diff --git a/src/Core/Config/Settings/CoreSettingsFormHandler.php b/src/Core/Config/Settings/CoreSettingsFormHandler.php index db9353b3..929dd172 100644 --- a/src/Core/Config/Settings/CoreSettingsFormHandler.php +++ b/src/Core/Config/Settings/CoreSettingsFormHandler.php @@ -5,33 +5,40 @@ namespace App\Core\Config\Settings; use App\Api\ApiFeaturePolicy; -use App\Core\Config\Config; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\Config\Config; use App\Core\Validation\EmailAddress; use App\Entity\AclGroup; use App\Form\FormErrorKey; use App\Form\FormFieldDefinition; use App\Form\FormSubmissionHandler; use App\Form\FormSubmissionResult; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; use Doctrine\ORM\EntityManagerInterface; final readonly class CoreSettingsFormHandler { + private const PROTECTED_VALUE = '[protected]'; + public function __construct( private CoreSettingsRegistry $registry, private Config $config, private FormSubmissionHandler $submissionHandler, private EntityManagerInterface $entityManager, + private ?SuspiciousProbePathMatcher $probePathMatcher = null, + private ?AdminFeatureAccessPolicy $adminAcl = null, ) { } /** * @param array $submitted */ - public function submit(string $section, array $submitted, ?string $modifiedBy = null): FormSubmissionResult + public function submit(string $section, array $submitted, ?string $modifiedBy = null, ?AccessActor $actor = null): FormSubmissionResult { - $definitions = $this->registry->definitions($section); + $definitions = $this->definitionsForActor($section, $actor ?? AccessActor::fromAccess(AccessLevel::ADMIN)); $result = $this->submissionHandler->submit( array_map(static fn (CoreSettingDefinition $definition): FormFieldDefinition => $definition->formField(), $definitions), $submitted, @@ -50,16 +57,56 @@ public function submit(string $section, array $submitted, ?string $modifiedBy = continue; } - if (!$this->config->set($definition->key(), $result->value($definition->key()), $definition->valueType(), modifiedBy: $modifiedBy)) { + $metadata = $definition->metadata(); + if ( + true === ($metadata['sensitive'] ?? false) + && $this->isUnchangedSensitiveValue($result->value($definition->key())) + ) { + continue; + } + + if (!$this->config->set( + $definition->key(), + $result->value($definition->key()), + $definition->valueType(), + sensitive: true === ($metadata['sensitive'] ?? false), + modifiedBy: $modifiedBy, + )) { return new FormSubmissionResult($result->values(), [ '__form' => [FormErrorKey::SAVE_FAILED], ]); } + + if (SuspiciousProbePathMatcher::PATTERNS_KEY === $definition->key()) { + $this->probePathMatcher?->resetCache(); + } } return $result; } + /** + * @return list + */ + private function definitionsForActor(string $section, AccessActor $actor): array + { + return array_values(array_filter( + $this->registry->definitions($section), + fn (CoreSettingDefinition $definition): bool => $this->definitionMutable($definition, $actor), + )); + } + + private function definitionMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } + private function validateDomainSettings(string $section, FormSubmissionResult $result): ?FormSubmissionResult { if ('api' === $section) { @@ -156,4 +203,10 @@ private function isValidOptionalEmail(mixed $email): bool return is_string($email) && ('' === trim($email) || EmailAddress::isValid($email)); } + + private function isUnchangedSensitiveValue(mixed $value): bool + { + return null === $value + || (is_string($value) && in_array(trim($value), ['', self::PROTECTED_VALUE], true)); + } } diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index c5a9f109..ef6adc12 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -5,11 +5,17 @@ namespace App\Core\Config\Settings; use App\Api\ApiFeaturePolicy; +use App\Core\Access\AccessLevel; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; +use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; @@ -70,10 +76,27 @@ public function allDefinitions(): array new CoreSettingDefinition('mail', 'mail.from_address', 'admin.settings.fields.mail_from_address.label', 'admin@localhost', ConfigValueType::String, validation: ['max_length' => 180], sortOrder: 20), new CoreSettingDefinition('mail', 'mail.from_name', 'admin.settings.fields.mail_from_name.label', $this->appName(), ConfigValueType::String, validation: ['max_length' => 120], sortOrder: 30), - new CoreSettingDefinition('security', 'security.captcha.enabled', 'admin.settings.fields.captcha_enabled.label', false, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('security', 'security.captcha.provider', 'admin.settings.fields.captcha_provider.label', 'none', ConfigValueType::String, FormInputType::Select, options: ['none' => 'admin.settings.options.captcha.none'], validation: ['required' => true], sortOrder: 20), - new CoreSettingDefinition('security', 'security.captcha.preview', 'admin.settings.fields.captcha_preview.label', null, ConfigValueType::String, FormInputType::Captcha, metadata: ['persist' => false], sortOrder: 30), - new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, sortOrder: 40), + new CoreSettingDefinition('security', 'security.captcha.enabled', 'admin.settings.fields.captcha_enabled.label', false, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 10), + new CoreSettingDefinition('security', 'security.captcha.provider', 'admin.settings.fields.captcha_provider.label', 'none', ConfigValueType::String, FormInputType::Select, options: ['none' => 'admin.settings.options.captcha.none'], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 20), + new CoreSettingDefinition('security', 'security.captcha.preview', 'admin.settings.fields.captcha_preview.label', null, ConfigValueType::String, FormInputType::Captcha, metadata: [ + 'persist' => false, + 'access_feature' => 'admin.settings.security', + ], sortOrder: 30), + new CoreSettingDefinition('security', RateLimitPolicyCatalogue::MODE_KEY, 'admin.settings.fields.rate_limit_mode.label', RateLimitProfile::Standard->value, ConfigValueType::String, FormInputType::Select, options: [ + RateLimitProfile::Off->value => 'admin.settings.options.rate_limit_mode.off', + RateLimitProfile::Standard->value => 'admin.settings.options.rate_limit_mode.standard', + RateLimitProfile::Strict->value => 'admin.settings.options.rate_limit_mode.strict', + RateLimitProfile::Panic->value => 'admin.settings.options.rate_limit_mode.panic', + ], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 35), + new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 40), new CoreSettingDefinition('security', ConfigAuditLogPolicy::EVENTS_KEY, 'admin.settings.fields.audit_events.label', ConfigAuditLogPolicy::DEFAULT_CATEGORIES, ConfigValueType::Json, FormInputType::MultiSelect, options: [ ConfigAuditLogPolicy::CATEGORY_AUTHENTICATION => 'admin.settings.options.audit.authentication', ConfigAuditLogPolicy::CATEGORY_BACKEND_ACTIONS => 'admin.settings.options.audit.backend_actions', @@ -81,26 +104,85 @@ public function allDefinitions(): array ConfigAuditLogPolicy::CATEGORY_PACKAGES => 'admin.settings.options.audit.packages', ConfigAuditLogPolicy::CATEGORY_SETTINGS => 'admin.settings.options.audit.settings', ConfigAuditLogPolicy::CATEGORY_OTHER => 'admin.settings.options.audit.other', + ], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 50), + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 60), + new CoreSettingDefinition('security', SuspiciousProbePathMatcher::PATTERNS_KEY, 'admin.settings.fields.security_probe_path_patterns.label', SuspiciousProbePathMatcher::defaultPatternText(), ConfigValueType::String, FormInputType::Textarea, help: 'admin.settings.fields.security_probe_path_patterns.help', validation: ['max_length' => 50000], metadata: [ + 'access_feature' => 'admin.settings.security', + ], sortOrder: 70), + + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.message_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.message_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 10), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.audit_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.audit_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 20), + new CoreSettingDefinition('logging', DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'admin.settings.fields.access_log_retention_days.label', DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.access_log_retention_days.help', validation: ['min' => 1, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 'access_feature' => 'admin.settings.logging', + ], sortOrder: 30), + new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.statistics', + ], sortOrder: 10), + new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.statistics', + ], sortOrder: 20), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::ENABLED_KEY, 'admin.settings.fields.geoip_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.geoip_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, + 'minimum_access_level' => AccessLevel::OWNER, + 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', + 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', + ], sortOrder: 30), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'admin.settings.fields.geoip_database_path.label', MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, ConfigValueType::String, help: 'admin.settings.fields.geoip_database_path.help', validation: ['required' => true, 'max_length' => 255], metadata: [ + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 40), + new CoreSettingDefinition('statistics', MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'admin.settings.fields.geoip_license_key.label', '', ConfigValueType::String, FormInputType::Password, help: 'admin.settings.fields.geoip_license_key.help', validation: ['max_length' => 180], metadata: [ + 'access_feature' => 'admin.settings.statistics.geoip', + 'access_configurable' => true, + 'minimum_access_level' => AccessLevel::OWNER, + 'sensitive' => true, + 'help_link_url' => 'https://www.maxmind.com/en/geolite2/signup', + 'help_link_label' => 'admin.settings.fields.geoip_license_link.label', ], sortOrder: 50), - new CoreSettingDefinition('statistics', AccessStatisticsPolicy::ENABLED_KEY, 'admin.settings.fields.statistics_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('statistics', AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'admin.settings.fields.statistics_respect_dnt.label', true, ConfigValueType::Boolean, sortOrder: 20), - - new CoreSettingDefinition('api', ApiFeaturePolicy::ENABLED_KEY, 'admin.settings.fields.api_enabled.label', true, ConfigValueType::Boolean, help: 'admin.settings.fields.api_enabled.help', sortOrder: 10), - new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ENABLED_KEY, 'admin.settings.fields.api_cors_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.api_cors_enabled.help', sortOrder: 20), - new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'admin.settings.fields.api_cors_allowed_origins.label', [], ConfigValueType::Json, help: 'admin.settings.fields.api_cors_allowed_origins.help', sortOrder: 30), + new CoreSettingDefinition('api', ApiFeaturePolicy::ENABLED_KEY, 'admin.settings.fields.api_enabled.label', true, ConfigValueType::Boolean, help: 'admin.settings.fields.api_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 10), + new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ENABLED_KEY, 'admin.settings.fields.api_cors_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.api_cors_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 20), + new CoreSettingDefinition('api', ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'admin.settings.fields.api_cors_allowed_origins.label', [], ConfigValueType::Json, help: 'admin.settings.fields.api_cors_allowed_origins.help', metadata: [ + 'access_feature' => 'admin.settings.api', + ], sortOrder: 30), new CoreSettingDefinition('packages', 'packages.update_check_interval', 'admin.settings.fields.package_update_interval.label', 'daily', ConfigValueType::String, FormInputType::Select, options: [ 'manual' => 'admin.settings.options.interval.manual', 'daily' => 'admin.settings.options.interval.daily', 'weekly' => 'admin.settings.options.interval.weekly', - ], validation: ['required' => true], sortOrder: 10), - new CoreSettingDefinition('packages', 'packages.auto_updates.enabled', 'admin.settings.fields.package_auto_updates.label', false, ConfigValueType::Boolean, sortOrder: 20), + ], validation: ['required' => true], metadata: [ + 'access_feature' => 'admin.settings.packages', + ], sortOrder: 10), + new CoreSettingDefinition('packages', 'packages.auto_updates.enabled', 'admin.settings.fields.package_auto_updates.label', false, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.packages', + ], sortOrder: 20), - new CoreSettingDefinition('scheduler', 'scheduler.enabled', 'admin.settings.fields.scheduler_enabled.label', true, ConfigValueType::Boolean, sortOrder: 10), - new CoreSettingDefinition('scheduler', 'scheduler.get_auth_enabled', 'admin.settings.fields.scheduler_get_auth_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_get_auth_enabled.help', sortOrder: 20), - new CoreSettingDefinition('scheduler', 'scheduler.package_action_queues_enabled', 'admin.settings.fields.scheduler_package_action_queues_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_package_action_queues_enabled.help', sortOrder: 30), - new CoreSettingDefinition('scheduler', 'scheduler.web_trigger_enabled', 'admin.settings.fields.scheduler_web_trigger_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_web_trigger_enabled.help', sortOrder: 40), + new CoreSettingDefinition('scheduler', 'scheduler.enabled', 'admin.settings.fields.scheduler_enabled.label', true, ConfigValueType::Boolean, metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 10), + new CoreSettingDefinition('scheduler', 'scheduler.get_auth_enabled', 'admin.settings.fields.scheduler_get_auth_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_get_auth_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 20), + new CoreSettingDefinition('scheduler', 'scheduler.package_action_queues_enabled', 'admin.settings.fields.scheduler_package_action_queues_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_package_action_queues_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 30), + new CoreSettingDefinition('scheduler', 'scheduler.web_trigger_enabled', 'admin.settings.fields.scheduler_web_trigger_enabled.label', false, ConfigValueType::Boolean, help: 'admin.settings.fields.scheduler_web_trigger_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.scheduler', + ], sortOrder: 40), ]; } diff --git a/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php b/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php new file mode 100644 index 00000000..20050585 --- /dev/null +++ b/src/Core/Geo/GeoIp2MaxMindDatabaseReader.php @@ -0,0 +1,26 @@ +reader->city($ipAddress); + } + + public function metadata(): Metadata + { + return $this->reader->metadata(); + } +} diff --git a/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php b/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php new file mode 100644 index 00000000..f2aa91e5 --- /dev/null +++ b/src/Core/Geo/GeoIp2MaxMindDatabaseReaderFactory.php @@ -0,0 +1,15 @@ +status; + } + + /** + * @return array{ + * provider_key: string, + * status: string, + * database_edition: ?string, + * database_build_date: ?string, + * failure_code: ?string + * } + */ + public function toSafeArray(): array + { + return [ + 'provider_key' => $this->providerKey, + 'status' => $this->status, + 'database_edition' => $this->databaseEdition, + 'database_build_date' => $this->databaseBuildDate, + 'failure_code' => $this->failureCode, + ]; + } +} diff --git a/src/Core/Geo/GeoIpResolver.php b/src/Core/Geo/GeoIpResolver.php new file mode 100644 index 00000000..aa953a6d --- /dev/null +++ b/src/Core/Geo/GeoIpResolver.php @@ -0,0 +1,92 @@ + */ + private array $providers; + + /** + * @param iterable $providers + */ + public function __construct( + iterable $providers = [], + private GeoIpProviderInterface $fallbackProvider = new NullGeoIpProvider(), + ) { + $this->providers = $this->normalizeProviders($providers); + } + + public function resolve(?string $ipAddress): GeoIpResult + { + if (null === $ipAddress || '' === trim($ipAddress)) { + return $this->fallbackProvider->resolve($ipAddress); + } + + $provider = $this->activeProvider(); + if (null === $provider) { + return $this->fallbackProvider->resolve($ipAddress); + } + + try { + return $provider->resolve($ipAddress); + } catch (Throwable) { + return $this->fallbackProvider->resolve($ipAddress); + } + } + + public function status(): GeoIpProviderStatus + { + return $this->activeProvider()?->status() + ?? $this->diagnosticProvider()?->status() + ?? $this->fallbackProvider->status(); + } + + private function activeProvider(): ?GeoIpProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider === $this->fallbackProvider) { + continue; + } + + if ($provider->status()->isReady()) { + return $provider; + } + } + + return null; + } + + private function diagnosticProvider(): ?GeoIpProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider === $this->fallbackProvider) { + continue; + } + + return $provider; + } + + return null; + } + + /** + * @param iterable $providers + * + * @return list + */ + private function normalizeProviders(iterable $providers): array + { + $normalized = []; + + foreach ($providers as $provider) { + $normalized[] = $provider; + } + + return $normalized; + } +} diff --git a/src/Core/Geo/GeoIpResolverInterface.php b/src/Core/Geo/GeoIpResolverInterface.php index 1e293c03..f127188b 100644 --- a/src/Core/Geo/GeoIpResolverInterface.php +++ b/src/Core/Geo/GeoIpResolverInterface.php @@ -7,4 +7,6 @@ interface GeoIpResolverInterface { public function resolve(?string $ipAddress): GeoIpResult; + + public function status(): GeoIpProviderStatus; } diff --git a/src/Core/Geo/GeoIpResult.php b/src/Core/Geo/GeoIpResult.php index 3a225f91..05ddd2ad 100644 --- a/src/Core/Geo/GeoIpResult.php +++ b/src/Core/Geo/GeoIpResult.php @@ -6,12 +6,24 @@ final readonly class GeoIpResult { + public const PLACEHOLDER = 'n/a'; + public const MAX_LABEL_LENGTH = 80; + + public string $city; + public string $state; + public string $country; + public string $continent; + public function __construct( - public string $city = 'n/a', - public string $state = 'n/a', - public string $country = 'n/a', - public string $continent = 'n/a', + string $city = self::PLACEHOLDER, + string $state = self::PLACEHOLDER, + string $country = self::PLACEHOLDER, + string $continent = self::PLACEHOLDER, ) { + $this->city = self::label($city); + $this->state = self::label($state); + $this->country = self::label($country); + $this->continent = self::label($continent); } /** @@ -26,4 +38,19 @@ public function toArray(): array 'continent' => $this->continent, ]; } + + private static function label(string $value): string + { + $value = trim($value); + + if ('' === $value) { + return self::PLACEHOLDER; + } + + if (function_exists('mb_substr')) { + return mb_substr($value, 0, self::MAX_LABEL_LENGTH); + } + + return substr($value, 0, self::MAX_LABEL_LENGTH); + } } diff --git a/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php b/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php new file mode 100644 index 00000000..01e0c7dc --- /dev/null +++ b/src/Core/Geo/HttpMaxMindGeoIpDownloadClient.php @@ -0,0 +1,150 @@ +failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'download'], + ); + } + + try { + $client = $this->httpClient(); + $response = $client->request('GET', $url, [ + 'timeout' => 60.0, + 'max_duration' => 180.0, + ]); + $status = $response->getStatusCode(); + + if ($status >= 200 && $status < 300 && !$this->writeResponse($client, $response, $target)) { + fclose($target); + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'download'], + ); + } + } catch (TransportExceptionInterface) { + fclose($target); + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_SERVER_UNREACHABLE, + GeoIpMessageKey::GEOIP_DOWNLOAD_SERVER_UNREACHABLE, + ['stage' => 'download'], + ); + } + + fclose($target); + + if (401 === $status || 403 === $status) { + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + ['stage' => 'download', 'http_status' => $status], + ); + } + + if ($status < 200 || $status >= 300) { + @unlink($targetPath); + + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_FAILED, + ['stage' => 'download', 'http_status' => $status], + ); + } + + if (!is_file($targetPath) || 0 === filesize($targetPath)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_FAILED, + ['stage' => 'download'], + ); + } + + return WorkflowResult::success(null, ['stage' => 'download']); + } + + /** + * @param resource $target + */ + private function writeResponse(HttpClientInterface $client, ResponseInterface $response, mixed $target): bool + { + foreach ($client->stream($response) as $chunk) { + $content = $chunk->getContent(); + + if ('' === $content) { + continue; + } + + if (!$this->writeAll($target, $content)) { + return false; + } + } + + return fflush($target); + } + + /** + * @param resource $target + */ + private function writeAll(mixed $target, string $content): bool + { + $offset = 0; + $length = strlen($content); + + while ($offset < $length) { + $written = @fwrite($target, substr($content, $offset)); + + if (!is_int($written) || $written <= 0) { + return false; + } + + $offset += $written; + } + + return true; + } + + private function httpClient(): HttpClientInterface + { + return $this->httpClient ?? HttpClient::create(); + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function failure(string $code, string $key, array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error($code, $key, context: $context), + ], $context); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php new file mode 100644 index 00000000..989738b1 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractor.php @@ -0,0 +1,169 @@ +failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + ['stage' => 'extract'], + ); + } + + try { + if (is_file($tarPath)) { + @unlink($tarPath); + } + + if (!$this->tarPathsAreSafe($archivePath, compressed: true)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'reason' => 'unsafe_archive_path'], + ); + } + + $archive = new PharData($archivePath); + $archive->decompress(); + if (!$this->tarPathsAreSafe($tarPath)) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'reason' => 'unsafe_archive_path'], + ); + } + + $tar = new PharData($tarPath); + $tar->extractTo($extractDir, null, true); + } catch (Throwable $error) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_ARCHIVE_INVALID, + ['stage' => 'extract', 'exception' => $error::class], + ); + } finally { + if (is_file($tarPath)) { + @unlink($tarPath); + } + } + + $databasePath = $this->findDatabase($extractDir); + if (null === $databasePath) { + return $this->failure( + GeoIpMessageCode::GEOIP_DOWNLOAD_DATABASE_MISSING, + GeoIpMessageKey::GEOIP_DOWNLOAD_DATABASE_MISSING, + ['stage' => 'extract'], + ); + } + + return WorkflowResult::success(['database_path' => $databasePath], ['stage' => 'extract']); + } + + private function findDatabase(string $extractDir): ?string + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + $path = $file->getPathname(); + if ($file->isFile() && str_ends_with($path, '.mmdb') && is_readable($path)) { + return $path; + } + } + + return null; + } + + private function tarPathsAreSafe(string $tarPath, bool $compressed = false): bool + { + $handle = $compressed ? @gzopen($tarPath, 'rb') : @fopen($tarPath, 'rb'); + if (!is_resource($handle)) { + return false; + } + + try { + while (true) { + $header = $compressed ? gzread($handle, 512) : fread($handle, 512); + if (!is_string($header) || '' === $header) { + break; + } + + if (512 !== strlen($header)) { + return false; + } + + if (str_repeat("\0", 512) === $header) { + return true; + } + + $name = rtrim(substr($header, 0, 100), "\0"); + $prefix = rtrim(substr($header, 345, 155), "\0"); + $path = '' === $prefix ? $name : $prefix.'/'.$name; + if (!$this->pathIsSafe($path)) { + return false; + } + + $type = substr($header, 156, 1); + if (!in_array($type, ["\0", '0', '5'], true)) { + return false; + } + + $size = octdec(trim(rtrim(substr($header, 124, 12), "\0 ")) ?: '0'); + $skip = $size > 0 ? (int) (ceil($size / 512) * 512) : 0; + while ($skip > 0) { + $chunk = $compressed ? gzread($handle, min(8192, $skip)) : fread($handle, min(8192, $skip)); + if (!is_string($chunk) || '' === $chunk) { + return false; + } + + $skip -= strlen($chunk); + } + } + + return true; + } finally { + $compressed ? gzclose($handle) : fclose($handle); + } + } + + private function pathIsSafe(string $path): bool + { + $path = str_replace('\\', '/', $path); + + return '' !== trim($path) + && !str_starts_with($path, '/') + && !str_starts_with($path, '//') + && 1 !== preg_match('/^[A-Za-z]:/', $path) + && !str_contains('/'.$path.'/', '/../') + && !str_contains($path, "\0"); + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function failure(string $code, string $key, array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error($code, $key, context: $context), + ], $context); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php b/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php new file mode 100644 index 00000000..9e710469 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpArchiveExtractorInterface.php @@ -0,0 +1,15 @@ + + */ + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult; +} diff --git a/src/Core/Geo/MaxMindGeoIpConfig.php b/src/Core/Geo/MaxMindGeoIpConfig.php new file mode 100644 index 00000000..735139b5 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpConfig.php @@ -0,0 +1,91 @@ +config->get(self::ENABLED_KEY, false); + } + + public function databasePath(): string + { + $path = $this->config->get(self::DATABASE_PATH_KEY, self::DEFAULT_DATABASE_PATH); + + return is_string($path) && '' !== trim($path) ? trim($path) : self::DEFAULT_DATABASE_PATH; + } + + /** + * @return list + */ + public function locales(): array + { + $defaultLanguage = $this->config->get('localization.default_language', 'en'); + $defaultLanguage = is_string($defaultLanguage) ? trim($defaultLanguage) : 'en'; + + if (1 !== preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $defaultLanguage)) { + $defaultLanguage = 'en'; + } + + return 'en' === $defaultLanguage ? ['en'] : [$defaultLanguage, 'en']; + } + + public function databaseAbsolutePath(string $projectDir): ?string + { + $relativePath = str_replace('\\', '/', $this->databasePath()); + + if ( + '' === trim($relativePath) + || str_contains($relativePath, "\0") + || str_starts_with($relativePath, '/') + || str_starts_with($relativePath, '//') + || str_starts_with($relativePath, '\\\\') + || 1 === preg_match('/^[A-Za-z]:/', $relativePath) + || str_contains('/'.$relativePath.'/', '/../') + ) { + return null; + } + + return rtrim($projectDir, DIRECTORY_SEPARATOR.'/\\') + .DIRECTORY_SEPARATOR + .str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/')); + } + + public function licenseKey(): string + { + $licenseKey = $this->config->get(self::LICENSE_KEY_KEY, ''); + + return is_string($licenseKey) ? trim($licenseKey) : ''; + } + + public function hasLicenseKey(): bool + { + return '' !== $this->licenseKey(); + } + + public function downloadUrl(): string + { + return sprintf( + 'https://download.maxmind.com/app/geoip_download?edition_id=%s&license_key=%s&suffix=tar.gz', + rawurlencode(self::DATABASE_EDITION), + rawurlencode($this->licenseKey()), + ); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php b/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php new file mode 100644 index 00000000..8fe4a26c --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDatabaseReaderFactoryInterface.php @@ -0,0 +1,13 @@ + $locales + */ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface; +} diff --git a/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php b/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php new file mode 100644 index 00000000..0ade426d --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDatabaseReaderInterface.php @@ -0,0 +1,15 @@ + + */ + public function update(string $trigger): WorkflowResult + { + $context = [ + 'edition' => MaxMindGeoIpConfig::DATABASE_EDITION, + 'database_path' => $this->config->databasePath(), + 'trigger' => $this->trigger($trigger), + ]; + + if (!$this->config->hasLicenseKey()) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, + context: ['stage' => 'preflight', ...$context], + ), + ], ['stage' => 'preflight', ...$context]); + } + + $targetPath = $this->config->databaseAbsolutePath($this->projectDir); + if (null === $targetPath) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + context: ['stage' => 'preflight', 'reason' => 'invalid_database_path', ...$context], + ), + ], ['stage' => 'preflight', 'reason' => 'invalid_database_path', ...$context]); + } + + $targetDir = dirname($targetPath); + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { + return $this->writeFailure(['stage' => 'preflight', 'reason' => 'directory_create_failed', ...$context]); + } + + $workspace = $this->workspace($targetDir); + if (!is_dir($workspace) && !mkdir($workspace, 0775, true) && !is_dir($workspace)) { + return $this->writeFailure(['stage' => 'preflight', 'reason' => 'workspace_create_failed', ...$context]); + } + + try { + $archivePath = $workspace.DIRECTORY_SEPARATOR.'maxmind.tar.gz'; + $download = $this->downloadClient->download($this->config->downloadUrl(), $archivePath); + if (!$download->isSuccess()) { + return $this->forwardFailure($download, $context); + } + + $extract = $this->archiveExtractor->extractDatabase($archivePath, $workspace); + if (!$extract->isSuccess()) { + return $this->forwardFailure($extract, $context); + } + + $candidate = $extract->value()['database_path'] ?? null; + if (!is_string($candidate) || !$this->databaseLooksValid($candidate)) { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_DATABASE_INVALID, + GeoIpMessageKey::GEOIP_DOWNLOAD_DATABASE_INVALID, + context: ['stage' => 'validate', ...$context], + ), + ], ['stage' => 'validate', ...$context]); + } + + if (!$this->replaceDatabase($candidate, $targetPath)) { + return $this->writeFailure(['stage' => 'replace', ...$context]); + } + } finally { + $this->removeDirectory($workspace); + } + + return WorkflowResult::success(['database_path' => $this->config->databasePath()], $context, [ + Message::success(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, [ + '%path%' => $this->config->databasePath(), + ], $context), + ]); + } + + private function databaseLooksValid(string $candidate): bool + { + if (!is_file($candidate) || !is_readable($candidate)) { + return false; + } + + try { + $metadata = $this->readerFactory->open($candidate, $this->config->locales())->metadata(); + } catch (Throwable) { + return false; + } + + return is_string($metadata->databaseType) + && str_contains($metadata->databaseType, 'City'); + } + + private function replaceDatabase(string $candidate, string $targetPath): bool + { + $targetDir = dirname($targetPath); + $temporaryTarget = tempnam($targetDir, '.geoip2-'); + if (!is_string($temporaryTarget)) { + return false; + } + + if (!@copy($candidate, $temporaryTarget)) { + @unlink($temporaryTarget); + + return false; + } + + @chmod($temporaryTarget, 0644); + + if (@rename($temporaryTarget, $targetPath)) { + @chmod($targetPath, 0644); + + return true; + } + + if (!is_file($targetPath)) { + @unlink($temporaryTarget); + + return false; + } + + $backupPath = $targetPath.'.previous-'.bin2hex(random_bytes(4)); + if (!@rename($targetPath, $backupPath)) { + @unlink($temporaryTarget); + + return false; + } + + if (@rename($temporaryTarget, $targetPath)) { + @chmod($targetPath, 0644); + @unlink($backupPath); + + return true; + } + + @rename($backupPath, $targetPath); + @unlink($temporaryTarget); + + return false; + } + + /** + * @param WorkflowResult $result + * @param array $context + * + * @return WorkflowResult + */ + private function forwardFailure(WorkflowResult $result, array $context): WorkflowResult + { + return WorkflowResult::failed($result->issues(), [ + ...$context, + ...$result->context(), + ], $result->messages()); + } + + /** + * @param array $context + * + * @return WorkflowResult + */ + private function writeFailure(array $context): WorkflowResult + { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_WRITE_FAILED, + GeoIpMessageKey::GEOIP_DOWNLOAD_WRITE_FAILED, + context: $context, + ), + ], $context); + } + + private function workspace(string $targetDir): string + { + return $targetDir.DIRECTORY_SEPARATOR.'.update-'.bin2hex(random_bytes(8)); + } + + private function trigger(string $trigger): string + { + return '' !== trim($trigger) ? trim($trigger) : 'system'; + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + $item->isDir() && !$item->isLink() + ? @rmdir($item->getPathname()) + : @unlink($item->getPathname()); + } + + @rmdir($path); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php b/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php new file mode 100644 index 00000000..a798cdf5 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpDownloadClientInterface.php @@ -0,0 +1,15 @@ + + */ + public function download(string $url, string $targetPath): WorkflowResult; +} diff --git a/src/Core/Geo/MaxMindGeoIpProvider.php b/src/Core/Geo/MaxMindGeoIpProvider.php new file mode 100644 index 00000000..7f1d1ca6 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpProvider.php @@ -0,0 +1,129 @@ +status) { + return $this->status; + } + + if (!$this->config->enabled()) { + return $this->status = GeoIpProviderStatus::disabled($this->key()); + } + + $databasePath = $this->databasePath(); + if (null === $databasePath) { + return $this->status = GeoIpProviderStatus::unconfigured($this->key(), 'invalid_database_path'); + } + + if (!is_file($databasePath) || !is_readable($databasePath)) { + return $this->status = GeoIpProviderStatus::unconfigured($this->key(), 'database_missing'); + } + + try { + $metadata = $this->reader()->metadata(); + } catch (Throwable) { + return $this->status = GeoIpProviderStatus::unavailable($this->key(), 'database_unreadable'); + } + + if (!$this->databaseSupportsCityLookups($metadata->databaseType ?? null)) { + return $this->status = GeoIpProviderStatus::unavailable($this->key(), 'database_unsupported'); + } + + return $this->status = GeoIpProviderStatus::ready( + $this->key(), + databaseEdition: is_string($metadata->databaseType) ? $metadata->databaseType : null, + databaseBuildDate: is_int($metadata->buildEpoch) ? $this->formatBuildDate($metadata->buildEpoch) : null, + ); + } + + public function resolve(?string $ipAddress): GeoIpResult + { + if (!$this->status()->isReady() || !$this->isPublicIp($ipAddress)) { + return new GeoIpResult(); + } + + try { + return $this->resultFromCity($this->reader()->city((string) $ipAddress)); + } catch (Throwable) { + return new GeoIpResult(); + } + } + + private function reader(): MaxMindGeoIpDatabaseReaderInterface + { + if (null !== $this->reader) { + return $this->reader; + } + + $databasePath = $this->databasePath(); + if (null === $databasePath) { + throw new \RuntimeException('GeoIP database path is unavailable.'); + } + + return $this->reader = $this->readerFactory->open($databasePath, $this->config->locales()); + } + + private function databasePath(): ?string + { + return $this->config->databaseAbsolutePath($this->projectDir); + } + + private function isPublicIp(?string $ipAddress): bool + { + return is_string($ipAddress) + && false !== filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + } + + private function resultFromCity(City $city): GeoIpResult + { + return new GeoIpResult( + $this->locationValue($city->city->name), + $this->locationValue($city->mostSpecificSubdivision->name ?? $city->mostSpecificSubdivision->isoCode), + $this->locationValue($city->country->name ?? $city->country->isoCode ?? $city->registeredCountry->name ?? $city->registeredCountry->isoCode), + $this->locationValue($city->continent->name ?? $city->continent->code), + ); + } + + private function locationValue(?string $value): string + { + return is_string($value) && '' !== trim($value) ? trim($value) : GeoIpResult::PLACEHOLDER; + } + + private function databaseSupportsCityLookups(mixed $databaseType): bool + { + return is_string($databaseType) && str_contains($databaseType, 'City'); + } + + private function formatBuildDate(int $buildEpoch): string + { + return (new DateTimeImmutable('@'.$buildEpoch)) + ->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d'); + } +} diff --git a/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php b/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php new file mode 100644 index 00000000..aacdea27 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpSchedulerProvider.php @@ -0,0 +1,59 @@ + + */ + public function schedulerTasks(): array + { + return [ + new SchedulerTaskDefinition( + self::TASK_IDENTIFIER, + 'admin.scheduler.tasks.geoip2_database_update.label', + 'admin.scheduler.tasks.geoip2_database_update.description', + 'system', + SchedulerTaskType::Callable, + self::CALLABLE_TARGET, + '0 3 * * *', + true, + ), + ]; + } + + public function schedulerCallable(string $target): ?callable + { + if (self::CALLABLE_TARGET !== $target) { + return null; + } + + return function (): SchedulerTaskExecution { + $result = $this->updater->update('scheduler'); + $messages = [ + ...$result->issues(), + ...$result->messages(), + ]; + + return $result->isSuccess() + ? SchedulerTaskExecution::success($result->context(), $messages) + : SchedulerTaskExecution::failed($result->context(), $messages); + }; + } +} diff --git a/src/Core/Geo/MaxMindGeoIpUpdateAction.php b/src/Core/Geo/MaxMindGeoIpUpdateAction.php new file mode 100644 index 00000000..98cfb5f6 --- /dev/null +++ b/src/Core/Geo/MaxMindGeoIpUpdateAction.php @@ -0,0 +1,41 @@ +type(), $this->label(), DryRunRisk::Medium, context: [ + 'trigger' => $this->trigger, + ]); + } + + public function execute(): WorkflowResult + { + return $this->updater->update($this->trigger); + } +} diff --git a/src/Core/Geo/NullGeoIpProvider.php b/src/Core/Geo/NullGeoIpProvider.php new file mode 100644 index 00000000..6fbe984a --- /dev/null +++ b/src/Core/Geo/NullGeoIpProvider.php @@ -0,0 +1,25 @@ +geoIpResolver->resolve($this->visitorIdGenerator->sourceIp($request)); $path = $this->accessRequestMetadata->sanitizedPath($request); - $this->logger->info('access.request', [ + $context = [ 'request_id' => $this->accessRequestMetadata->requestId($request), 'correlation_id' => $this->accessRequestMetadata->correlationId($request), 'method' => $request->getMethod(), @@ -60,7 +61,10 @@ public function log(Request $request, Response $response): void 'state' => $geoIp->state, 'country' => $geoIp->country, 'continent' => $geoIp->continent, - ]); + ]; + + $this->logger->info('access.request', $context); + $this->databaseLogProjector?->recordAccess($context); } private function userAgent(Request $request): string @@ -113,6 +117,6 @@ private function isSensitiveKey(string $key): bool $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); return 'auth' === $normalized - || 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|code|signature|signed|session|csrf|nonce|reset|invite)/', $normalized); + || 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key|code|signature|signed|session|csrf|nonce|reset|invite)/', $normalized); } } diff --git a/src/Core/Log/AccessRequestMetadata.php b/src/Core/Log/AccessRequestMetadata.php index baa6cb06..61a46ba7 100644 --- a/src/Core/Log/AccessRequestMetadata.php +++ b/src/Core/Log/AccessRequestMetadata.php @@ -4,6 +4,8 @@ namespace App\Core\Log; +use App\Content\Routing\ContentRouteLocalization; +use App\Core\Routing\RequestPathResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,6 +19,12 @@ private const MIN_REQUEST_ID_LENGTH = 8; private const REQUEST_ID_PATTERN = '/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/'; private const REDACTED_SEGMENT = '[redacted]'; + private RequestPathResolver $paths; + + public function __construct(?ContentRouteLocalization $routeLocalization = null, ?RequestPathResolver $paths = null) + { + $this->paths = $paths ?? new RequestPathResolver($routeLocalization); + } public function markStarted(Request $request): void { @@ -71,13 +79,13 @@ public function durationMs(Request $request): ?int public function surface(Request $request): string { - $path = $request->getPathInfo(); + $segments = $this->segments($request); return match (true) { - str_starts_with($path, '/admin') => 'admin', - str_starts_with($path, '/editor') => 'editor', - str_starts_with($path, '/api') => 'api', - str_starts_with($path, '/setup') => 'setup', + $this->matchesSegments($segments, 'admin') => 'admin', + $this->matchesSegments($segments, 'editor') => 'editor', + $this->matchesSegments($segments, 'api') => 'api', + $this->matchesSegments($segments, 'setup') => 'setup', default => 'public', }; } @@ -179,6 +187,28 @@ public function trace(Request $request, string $visitorId): array ]; } + /** + * @return list + */ + private function segments(Request $request): array + { + return $this->paths->segments($request); + } + + /** + * @param list $pathSegments + */ + private function matchesSegments(array $pathSegments, string ...$segments): bool + { + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== $segment) { + return false; + } + } + + return [] !== $segments; + } + /** * @return list */ diff --git a/src/Core/Log/AdminLogBrowser.php b/src/Core/Log/AdminLogBrowser.php new file mode 100644 index 00000000..da5ff5d4 --- /dev/null +++ b/src/Core/Log/AdminLogBrowser.php @@ -0,0 +1,71 @@ + ['label' => 'admin.logs.sources.application']]; + + public function __construct( + private DatabaseLogBrowser $databaseBrowser, + private LogFileBrowser $fileBrowser, + ) { + } + + /** + * @param array $query + * + * @return array + */ + public function browse(array $query): array + { + $source = $query['source'] ?? null; + if ('application' === $source) { + $view = $this->fileBrowser->browse([...$query, 'source' => 'application']); + $view['sources'] = $this->sourceOptions(); + $view['capabilities'] = $this->capabilities('application'); + + return $view; + } + + $view = $this->databaseBrowser->browse($query); + $view['sources'] = $this->sourceOptions(); + + return $view; + } + + /** + * @return array|null + */ + public function entry(string $source, string $id): ?array + { + return 'application' === $source + ? $this->fileBrowser->entry($source, $id) + : $this->databaseBrowser->entry($source, $id); + } + + /** + * @return list + */ + public function sourceOptions(): array + { + return [ + ['key' => 'application', 'label' => self::APPLICATION_SOURCE['application']['label']], + ...$this->databaseBrowser->sourceOptions(), + ]; + } + + /** + * @return array{level_filter: bool, audit_action_filter: bool, signal_reason_filter: bool} + */ + private function capabilities(string $source): array + { + return [ + 'level_filter' => 'application' === $source, + 'audit_action_filter' => false, + 'signal_reason_filter' => false, + ]; + } +} diff --git a/src/Core/Log/AuditLogger.php b/src/Core/Log/AuditLogger.php index ab474ea9..be0428af 100644 --- a/src/Core/Log/AuditLogger.php +++ b/src/Core/Log/AuditLogger.php @@ -19,6 +19,7 @@ public function __construct( private ?RequestStack $requestStack = null, private ?AccessRequestMetadata $accessRequestMetadata = null, private ?VisitorIdGenerator $visitorIdGenerator = null, + private ?DatabaseLogProjector $databaseLogProjector = null, ) { } @@ -31,13 +32,16 @@ public function log(AccessActor $actor, string $action, array $context = []): vo return; } - $this->logger->info($action, [ + $payload = [ 'user' => $actor->username() ?? 'anonymous', 'user_uid' => $actor->userUid(), 'user_access_level' => $actor->accessLevel(), 'action' => $action, 'context' => $this->normalize($this->withRequestTrace($context)), - ]); + ]; + + $this->logger->info($action, $payload); + $this->databaseLogProjector?->recordAudit($payload); } /** @@ -94,6 +98,6 @@ private function isSensitiveKey(string $key): bool { $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); - return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key)/', $normalized); + return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key)/', $normalized); } } diff --git a/src/Core/Log/DatabaseLogBrowser.php b/src/Core/Log/DatabaseLogBrowser.php new file mode 100644 index 00000000..eb607257 --- /dev/null +++ b/src/Core/Log/DatabaseLogBrowser.php @@ -0,0 +1,419 @@ + ['label' => 'admin.logs.sources.message', 'table' => 'message_log_entry'], + 'audit' => ['label' => 'admin.logs.sources.audit', 'table' => 'audit_log_entry'], + 'access' => ['label' => 'admin.logs.sources.access', 'table' => 'access_log_entry'], + 'security_signal' => ['label' => 'admin.logs.sources.security_signal', 'table' => 'security_signal_event'], + ]; + + private DatabaseLogRetentionPolicy $retentionPolicy; + + public function __construct( + private Connection $connection, + private LogEntryFilter $entryFilter = new LogEntryFilter(), + private LogPagination $pagination = new LogPagination(), + private ClockInterface $clock = new NativeClock(), + ?DatabaseLogRetentionPolicy $retentionPolicy = null, + ) { + $this->retentionPolicy = $retentionPolicy ?? new DatabaseLogRetentionPolicy($connection); + } + + /** + * @param array $query + * + * @return array + */ + public function browse(array $query): array + { + $source = $this->source($query['source'] ?? null); + $filters = $this->entryFilter->filters($query); + $filters = $this->entryFilter->filtersForSource( + $filters, + $this->supportsLevelFilter($source), + in_array($source, ['audit', 'security_signal'], true), + ); + $criteria = $this->criteria($source, $filters); + $matched = $this->count($source, $criteria); + $pagination = $this->pagination->pagination($filters, $matched); + $filters['page'] = $pagination['page']; + $entries = $this->entries($source, $criteria, $filters); + + return [ + 'sources' => $this->sourceOptions(), + 'selected_source' => $source, + 'capabilities' => $this->capabilities($source), + 'filters' => $filters, + 'entries' => $entries, + 'files' => [], + 'pagination' => $pagination, + 'per_page_options' => $this->pagination->perPageOptions(), + 'time_window_options' => $this->pagination->timeWindowOptions(), + 'match_options' => $this->pagination->matchOptions(), + ]; + } + + /** + * @return array|null + */ + public function entry(string $source, string $id): ?array + { + $source = $this->source($source); + $where = ['uid = ?']; + $params = [$id]; + + if ('security_signal' === $source) { + $where[] = 'expires_at > ?'; + $params[] = $this->now(); + $where[] = 'occurred_at >= ?'; + $params[] = $this->retentionCutoff($source); + } elseif (in_array($source, ['message', 'audit', 'access'], true)) { + $where[] = 'occurred_at >= ?'; + $params[] = $this->retentionCutoff($source); + } + + $row = $this->connection->fetchAssociative(sprintf( + 'SELECT * FROM %s WHERE %s', + self::SOURCES[$source]['table'], + implode(' AND ', $where), + ), $params); + + return is_array($row) ? $this->present($source, $row) : null; + } + + /** + * @return list + */ + public function sourceOptions(): array + { + $options = []; + + foreach (self::SOURCES as $key => $source) { + $options[] = ['key' => $key, 'label' => $source['label']]; + } + + return $options; + } + + private function source(mixed $source): string + { + return is_string($source) && isset(self::SOURCES[$source]) ? $source : 'message'; + } + + /** + * @return array{level_filter: bool, audit_action_filter: bool, signal_reason_filter: bool} + */ + private function capabilities(string $source): array + { + return [ + 'level_filter' => $this->supportsLevelFilter($source), + 'audit_action_filter' => 'audit' === $source, + 'signal_reason_filter' => 'security_signal' === $source, + ]; + } + + private function supportsLevelFilter(string $source): bool + { + return in_array($source, ['message', 'security_signal'], true); + } + + /** + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return array{where: list, params: list} + */ + private function criteria(string $source, array $filters): array + { + $where = ['occurred_at >= ?']; + $params = [$this->cutoff($source, $filters['time_window'])]; + + if ($this->supportsLevelFilter($source) && [] !== $filters['levels']) { + $levelColumn = 'security_signal' === $source ? 'severity' : 'level'; + $where[] = $levelColumn.' IN ('.implode(', ', array_fill(0, count($filters['levels']), '?')).')'; + array_push($params, ...$filters['levels']); + } + + if ('audit' === $source && '' !== $filters['audit_action']) { + $where[] = 'action = ?'; + $params[] = $filters['audit_action']; + } + + if ('security_signal' === $source && '' !== $filters['audit_action']) { + $where[] = 'reason_code = ?'; + $params[] = $filters['audit_action']; + } + + if ('security_signal' === $source) { + $where[] = 'expires_at > ?'; + $params[] = $this->now(); + } + + if ('' !== $filters['search']) { + $columns = match ($source) { + 'access' => ['context', 'request_id', 'correlation_id', 'path', 'requested_path', 'route', 'resolved_route', 'client_ip', 'proxy_client_ip', 'visitor_id', 'host', 'user_agent', 'referrer_host'], + 'audit' => ['context', 'action', 'user_name', 'user_uid', 'request_id', 'visitor_id', 'requested_path', 'resolved_route'], + 'security_signal' => ['context', 'signal_type', 'reason_code', 'subject_type', 'subject_identifier', 'request_id', 'visitor_id', 'path', 'route'], + default => ['context', 'message', 'code'], + }; + $operator = 'equals' === $filters['match'] ? '= ?' : "LIKE ? ESCAPE '!'"; + $needle = mb_strtolower($filters['search']); + $needle = 'equals' === $filters['match'] ? $needle : '%'.$this->escapeLikeNeedle($needle).'%'; + $where[] = '('.implode(' OR ', array_map(fn (string $column): string => $this->caseInsensitiveSearchExpression($column).' '.$operator, $columns)).')'; + + foreach ($columns as $_) { + $params[] = $needle; + } + } + + return ['where' => $where, 'params' => $params]; + } + + /** + * @param array{where: list, params: list} $criteria + */ + private function count(string $source, array $criteria): int + { + $value = $this->connection->fetchOne(sprintf( + 'SELECT COUNT(*) FROM %s WHERE %s', + self::SOURCES[$source]['table'], + implode(' AND ', $criteria['where']), + ), $criteria['params']); + + return is_numeric($value) ? (int) $value : 0; + } + + /** + * @param array{where: list, params: list} $criteria + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return list> + */ + private function entries(string $source, array $criteria, array $filters): array + { + $limit = $filters['per_page']; + $offset = ($filters['page'] - 1) * $filters['per_page']; + $sql = sprintf( + 'SELECT * FROM %s WHERE %s ORDER BY occurred_at DESC, uid DESC LIMIT %d OFFSET %d', + self::SOURCES[$source]['table'], + implode(' AND ', $criteria['where']), + $limit, + $offset, + ); + + return array_map( + fn (array $row): array => $this->present($source, $row), + $this->connection->fetchAllAssociative($sql, $criteria['params']), + ); + } + + /** + * @param array $row + * + * @return array + */ + private function present(string $source, array $row): array + { + $context = $this->decode($row['context'] ?? '{}'); + $context = $this->displayContext($source, $row, $context); + $message = $this->message($source, $row); + $entry = [ + 'id' => (string) $row['uid'], + 'timestamp' => (string) ($row['occurred_at'] ?? ''), + 'channel' => $source, + 'level' => (string) ($row['level'] ?? ('security_signal' === $source ? $row['severity'] ?? 'INFO' : '')), + 'message' => $message, + 'context' => $context, + 'context_json' => json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '', + 'raw_context' => (string) ($row['context'] ?? ''), + 'file' => '', + 'raw' => '', + 'source' => $source, + 'summary' => $this->summary($source, $row), + ]; + + return $entry; + } + + /** + * @param array $row + * @param array $context + * + * @return array + */ + private function displayContext(string $source, array $row, array $context): array + { + return match ($source) { + 'access' => [ + ...$context, + 'request_id' => $row['request_id'] ?? 'n/a', + 'correlation_id' => $row['correlation_id'] ?? 'n/a', + 'method' => $row['method'] ?? 'n/a', + 'path' => $row['path'] ?? 'n/a', + 'requested_path' => $row['requested_path'] ?? $row['path'] ?? 'n/a', + 'route' => $row['route'] ?? 'n/a', + 'resolved_route' => $row['resolved_route'] ?? $row['route'] ?? 'n/a', + 'surface' => $row['surface'] ?? 'n/a', + 'http_status' => $row['http_status'] ?? null, + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'client_ip' => $row['client_ip'] ?? 'n/a', + 'proxy_client_ip' => $row['proxy_client_ip'] ?? 'n/a', + 'host' => $row['host'] ?? 'n/a', + 'user_agent' => $row['user_agent'] ?? 'n/a', + 'referrer_host' => $row['referrer_host'] ?? 'n/a', + 'city' => $row['city'] ?? 'n/a', + 'state' => $row['state'] ?? 'n/a', + 'country' => $row['country'] ?? 'n/a', + 'continent' => $row['continent'] ?? 'n/a', + ], + 'audit' => [ + ...$context, + 'user' => $row['user_name'] ?? 'anonymous', + 'user_uid' => $row['user_uid'] ?? null, + 'user_access_level' => $row['user_access_level'] ?? 0, + 'action' => $row['action'] ?? 'n/a', + 'request_id' => $row['request_id'] ?? 'n/a', + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'requested_path' => $row['requested_path'] ?? 'n/a', + 'resolved_route' => $row['resolved_route'] ?? 'n/a', + ], + 'security_signal' => [ + ...$context, + 'signal_type' => $row['signal_type'] ?? 'n/a', + 'reason_code' => $row['reason_code'] ?? 'n/a', + 'severity' => $row['severity'] ?? 'INFO', + 'confidence' => $row['confidence'] ?? 0, + 'subject_type' => $row['subject_type'] ?? 'n/a', + 'subject_identifier' => $row['subject_identifier'] ?? 'n/a', + 'ip_derived' => (bool) ($row['ip_derived'] ?? false), + 'request_family' => $row['request_family'] ?? 'n/a', + 'request_intent' => $row['request_intent'] ?? 'n/a', + 'request_id' => $row['request_id'] ?? 'n/a', + 'visitor_id' => $row['visitor_id'] ?? 'n/a', + 'path' => $row['path'] ?? 'n/a', + 'route' => $row['route'] ?? 'n/a', + 'http_status' => $row['http_status'] ?? null, + 'expires_at' => $row['expires_at'] ?? null, + ], + default => $context, + }; + } + + /** + * @param array $row + */ + private function message(string $source, array $row): string + { + return match ($source) { + 'access' => 'access.request', + 'audit' => (string) ($row['action'] ?? 'n/a'), + 'security_signal' => (string) ($row['reason_code'] ?? $row['signal_type'] ?? 'n/a'), + default => (string) ($row['message'] ?? 'n/a'), + }; + } + + /** + * @param array $row + */ + private function summary(string $source, array $row): string + { + return match ($source) { + 'access' => trim(($row['method'] ?? 'n/a').' '.($row['requested_path'] ?? $row['path'] ?? 'n/a')), + 'audit' => (string) ($row['action'] ?? 'n/a'), + 'security_signal' => trim(($row['signal_type'] ?? 'n/a').': '.($row['reason_code'] ?? 'n/a')), + default => (string) ($row['message'] ?? 'n/a'), + }; + } + + /** + * @return array + */ + private function decode(mixed $encoded): array + { + if (!is_string($encoded) || '' === $encoded) { + return []; + } + + $decoded = json_decode($encoded, true); + + return is_array($decoded) ? $decoded : []; + } + + private function cutoff(string $source, string $window): string + { + $modifier = match ($window) { + '1h' => '-1 hour', + '7d' => '-7 days', + '30d' => '-30 days', + default => '-24 hours', + }; + $cutoff = $this->clock->now()->modify($modifier); + + if (in_array($source, ['message', 'audit', 'access', 'security_signal'], true)) { + $retentionCutoff = $this->clock->now()->modify($this->retentionModifier($source)); + if ($retentionCutoff > $cutoff) { + $cutoff = $retentionCutoff; + } + } + + return $cutoff->format('Y-m-d H:i:s'); + } + + private function escapeLikeNeedle(string $needle): string + { + return strtr($needle, [ + '!' => '!!', + '%' => '!%', + '_' => '!_', + ]); + } + + private function retentionCutoff(string $source): string + { + return $this->clock->now()->modify($this->retentionModifier($source))->format('Y-m-d H:i:s'); + } + + private function retentionModifier(string $source): string + { + if ('security_signal' === $source) { + return sprintf('-%d days', $this->retentionPolicy->retentionDaysForSignal()); + } + + return sprintf('-%d days', $this->retentionPolicy->retentionDaysForSource($source)); + } + + private function now(): string + { + return $this->clock->now()->format('Y-m-d H:i:s'); + } + + private function searchExpression(string $column): string + { + if ('context' !== $column) { + return $column; + } + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof AbstractMySQLPlatform) { + return 'CAST(context AS CHAR)'; + } + + return 'CAST(context AS TEXT)'; + } + + private function caseInsensitiveSearchExpression(string $column): string + { + return 'LOWER('.$this->searchExpression($column).')'; + } +} diff --git a/src/Core/Log/DatabaseLogProjector.php b/src/Core/Log/DatabaseLogProjector.php new file mode 100644 index 00000000..2cdee06c --- /dev/null +++ b/src/Core/Log/DatabaseLogProjector.php @@ -0,0 +1,160 @@ + $context + */ + public function recordMessage(string $level, string $message, array $context): void + { + $this->write('message', 'message_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'level' => $this->short($level, 16), + 'message' => $this->short($message, 255), + 'code' => $this->optionalShort($context['code'] ?? null, 160), + 'context' => $this->json($context), + ]); + } + + /** + * @param array $context + */ + public function recordAudit(array $context): void + { + $nested = is_array($context['context'] ?? null) ? $context['context'] : []; + + $this->write('audit', 'audit_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'user_name' => $this->short($context['user'] ?? 'anonymous', 180), + 'user_uid' => $this->optionalShort($context['user_uid'] ?? null, 36), + 'user_access_level' => is_numeric($context['user_access_level'] ?? null) ? (int) $context['user_access_level'] : 0, + 'action' => $this->short($context['action'] ?? self::PLACEHOLDER, 160), + 'request_id' => $this->short($nested['request_id'] ?? self::PLACEHOLDER, 64), + 'visitor_id' => $this->short($nested['visitor_id'] ?? self::PLACEHOLDER, 64), + 'requested_path' => $this->short($nested['requested_path'] ?? self::PLACEHOLDER, 1024), + 'resolved_route' => $this->short($nested['resolved_route'] ?? self::PLACEHOLDER, 190), + 'context' => $this->json($nested), + ]); + } + + /** + * @param array $context + */ + public function recordAccess(array $context): void + { + $this->write('access', 'access_log_entry', [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $this->now(), + 'request_id' => $this->short($context['request_id'] ?? self::PLACEHOLDER, 64), + 'correlation_id' => $this->short($context['correlation_id'] ?? self::PLACEHOLDER, 64), + 'method' => $this->short($context['method'] ?? self::PLACEHOLDER, 16), + 'path' => $this->short($context['path'] ?? self::PLACEHOLDER, 1024), + 'requested_path' => $this->short($context['requested_path'] ?? $context['path'] ?? self::PLACEHOLDER, 1024), + 'route' => $this->short($context['route'] ?? self::PLACEHOLDER, 190), + 'resolved_route' => $this->short($context['resolved_route'] ?? $context['route'] ?? self::PLACEHOLDER, 190), + 'surface' => $this->short($context['surface'] ?? self::PLACEHOLDER, 40), + 'query_string' => $this->short($context['query_string'] ?? '', 1024), + 'http_status' => is_numeric($context['http_status'] ?? null) ? (int) $context['http_status'] : 0, + 'duration_ms' => is_numeric($context['duration_ms'] ?? null) ? (int) $context['duration_ms'] : null, + 'visitor_id' => $this->short($context['visitor_id'] ?? self::PLACEHOLDER, 64), + 'scheme' => $this->short($context['scheme'] ?? self::PLACEHOLDER, 10), + 'host' => $this->short($context['host'] ?? self::PLACEHOLDER, 255), + 'client_ip' => $this->short($context['client_ip'] ?? $context['ip'] ?? self::PLACEHOLDER, 45), + 'proxy_client_ip' => $this->short($context['proxy_client_ip'] ?? self::PLACEHOLDER, 45), + 'user_agent' => $this->short($context['user_agent'] ?? self::PLACEHOLDER, 500), + 'referrer' => $this->short($context['referrer'] ?? self::PLACEHOLDER, 1024), + 'referrer_host' => $this->short($context['referrer_host'] ?? self::PLACEHOLDER, 255), + 'accept_language' => $this->short($context['accept_language'] ?? self::PLACEHOLDER, 255), + 'preferred_language' => $this->short($context['preferred_language'] ?? self::PLACEHOLDER, 20), + 'request_content_type' => $this->short($context['request_content_type'] ?? self::PLACEHOLDER, 120), + 'response_content_type' => $this->short($context['response_content_type'] ?? self::PLACEHOLDER, 120), + 'response_size' => is_numeric($context['response_size'] ?? null) ? (int) $context['response_size'] : null, + 'city' => $this->short($context['city'] ?? self::PLACEHOLDER, 80), + 'state' => $this->short($context['state'] ?? self::PLACEHOLDER, 80), + 'country' => $this->short($context['country'] ?? self::PLACEHOLDER, 80), + 'continent' => $this->short($context['continent'] ?? self::PLACEHOLDER, 80), + 'context' => $this->json($context), + ]); + } + + /** + * @param array $values + */ + private function write(string $source, string $table, array $values): void + { + if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { + return; + } + + try { + $this->connection->insert($table, $values); + $this->purge($source, $table); + } catch (Throwable) { + return; + } + } + + private function purge(string $source, string $table): void + { + $cutoff = $this->clock->now()->sub(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSource($source).'D')); + $this->connection->executeStatement('DELETE FROM '.$table.' WHERE occurred_at < ?', [ + $cutoff->format('Y-m-d H:i:s'), + ]); + } + + private function now(): string + { + return $this->clock->now()->format('Y-m-d H:i:s'); + } + + private function short(mixed $value, int $length): string + { + $value = is_scalar($value) ? (string) $value : self::PLACEHOLDER; + $value = trim($value); + + return mb_substr('' === $value ? self::PLACEHOLDER : $value, 0, $length); + } + + private function optionalShort(mixed $value, int $length): ?string + { + if (null === $value || '' === trim((string) $value)) { + return null; + } + + return $this->short($value, $length); + } + + private function json(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable) { + return '{}'; + } + } +} diff --git a/src/Core/Log/DatabaseLogRetentionPolicy.php b/src/Core/Log/DatabaseLogRetentionPolicy.php new file mode 100644 index 00000000..3077e7b4 --- /dev/null +++ b/src/Core/Log/DatabaseLogRetentionPolicy.php @@ -0,0 +1,61 @@ + $this->days(self::AUDIT_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + 'access' => $this->days(self::ACCESS_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + 'message' => $this->days(self::MESSAGE_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS), + default => self::DEFAULT_LOG_RETENTION_DAYS, + }; + } + + public function retentionDaysForSignal(): int + { + return $this->days(self::SECURITY_SIGNAL_RETENTION_DAYS_KEY, self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS); + } + + private function days(string $key, int $default): int + { + try { + $encoded = $this->connection->fetchOne('SELECT value FROM config_entry WHERE config_key = ?', [$key]); + } catch (Throwable) { + return $default; + } + + if (!is_string($encoded)) { + return $default; + } + + try { + $value = json_decode($encoded, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + return $default; + } + + $days = is_int($value) ? $value : (is_numeric($value) ? (int) $value : $default); + + return max(1, min(self::MAX_RETENTION_DAYS, $days)); + } +} diff --git a/src/Core/Log/LogEntryFilter.php b/src/Core/Log/LogEntryFilter.php index 9618ac4c..79e0f12e 100644 --- a/src/Core/Log/LogEntryFilter.php +++ b/src/Core/Log/LogEntryFilter.php @@ -7,16 +7,21 @@ final readonly class LogEntryFilter { private const DEFAULT_PER_PAGE = 50; + private const DEFAULT_LEVELS = ['NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']; + private const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']; /** * @param array $query * - * @return array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} + * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} */ public function filters(array $query): array { + $levels = $this->levels($query['level'] ?? $query['levels'] ?? null); + return [ - 'level' => $this->level($query['level'] ?? null), + 'level' => 1 === count($levels) ? $levels[0] : '', + 'levels' => $levels, 'search' => $this->search($query['q'] ?? null), 'match' => $this->match($query['match'] ?? null), 'time_window' => $this->timeWindow($query['time_window'] ?? null), @@ -26,13 +31,32 @@ public function filters(array $query): array ]; } + /** + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} + */ + public function filtersForSource(array $filters, bool $supportsLevelFilter, bool $supportsAuditActionFilter): array + { + if (!$supportsLevelFilter) { + $filters['level'] = ''; + $filters['levels'] = []; + } + + if (!$supportsAuditActionFilter) { + $filters['audit_action'] = ''; + } + + return $filters; + } + /** * @param array $entry - * @param array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters */ public function matches(array $entry, array $filters): bool { - if ('' !== $filters['level'] && $entry['level'] !== $filters['level']) { + if ([] !== $filters['levels'] && !in_array($entry['level'], $filters['levels'], true)) { return false; } @@ -75,17 +99,24 @@ public function matchesTimeWindow(array $entry, string $window): bool return $timestamp >= $cutoff; } - private function level(mixed $level): string + /** + * @return list + */ + private function levels(mixed $level): array { - if (!is_string($level)) { - return ''; + if (null === $level || '' === $level || [] === $level) { + return self::DEFAULT_LEVELS; } - $level = strtoupper(trim($level)); + $levels = is_array($level) ? $level : [$level]; + $levels = array_values(array_unique(array_filter(array_map( + static fn (mixed $candidate): string => is_string($candidate) ? strtoupper(trim($candidate)) : '', + $levels, + )))); + + $levels = array_values(array_filter($levels, static fn (string $candidate): bool => in_array($candidate, self::LEVELS, true))); - return in_array($level, ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'], true) - ? $level - : ''; + return [] === $levels ? self::DEFAULT_LEVELS : $levels; } private function search(mixed $search): string @@ -103,15 +134,15 @@ private function timeWindow(mixed $window): string return in_array($window, ['1h', '24h', '7d', '30d'], true) ? $window : '24h'; } - private function perPage(mixed $perPage): int|string + private function perPage(mixed $perPage): int { if ('all' === $perPage) { - return 'all'; + return 500; } $perPage = is_numeric($perPage) ? (int) $perPage : self::DEFAULT_PER_PAGE; - return in_array($perPage, [25, 50, 100, 150], true) ? $perPage : self::DEFAULT_PER_PAGE; + return in_array($perPage, [25, 50, 100, 150, 500], true) ? $perPage : self::DEFAULT_PER_PAGE; } private function page(mixed $page): int diff --git a/src/Core/Log/LogFileBrowser.php b/src/Core/Log/LogFileBrowser.php index 43db955f..dd41a6c8 100644 --- a/src/Core/Log/LogFileBrowser.php +++ b/src/Core/Log/LogFileBrowser.php @@ -27,11 +27,61 @@ public function browse(array $query): array { $source = $this->sourceRegistry->source($query['source'] ?? null); $filters = $this->entryFilter->filters($query); + $filters = $this->entryFilter->filtersForSource( + $filters, + !in_array($source, ['access', 'audit'], true), + 'audit' === $source, + ); $files = $this->sourceRegistry->files($this->logDir, $this->environment, $source); + $matched = $this->countMatches($source, $files, $filters); + $pagination = $this->pagination->pagination($filters, $matched); + $filters['page'] = $pagination['page']; + $entries = $this->readPage($source, $files, $filters); + + return [ + 'sources' => $this->sourceRegistry->sourceOptions(), + 'selected_source' => $source, + 'filters' => $filters, + 'entries' => $entries, + 'files' => array_map('basename', $files), + 'pagination' => $pagination, + 'per_page_options' => $this->pagination->perPageOptions(), + 'time_window_options' => $this->pagination->timeWindowOptions(), + 'match_options' => $this->pagination->matchOptions(), + ]; + } + + /** + * @param list $files + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + */ + private function countMatches(string $source, array $files, array $filters): int + { + $matched = 0; + + foreach ($files as $file) { + foreach ($this->lineReader->readLines($file) as $line) { + if ($this->entryFilter->matches($this->entryPresenter->enrich($source, $this->lineParser->parse($line, $file)), $filters)) { + ++$matched; + } + } + } + + return $matched; + } + + /** + * @param list $files + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters + * + * @return list> + */ + private function readPage(string $source, array $files, array $filters): array + { $entries = []; $matched = 0; - $offset = 'all' === $filters['per_page'] ? 0 : ($filters['page'] - 1) * (int) $filters['per_page']; - $limit = 'all' === $filters['per_page'] ? PHP_INT_MAX : (int) $filters['per_page']; + $offset = ($filters['page'] - 1) * $filters['per_page']; + $limit = $filters['per_page']; foreach ($files as $file) { foreach ($this->lineReader->readLines($file) as $line) { @@ -47,23 +97,15 @@ public function browse(array $query): array continue; } - if (count($entries) < $limit) { - $entries[] = $entry; + if (count($entries) >= $limit) { + return $entries; } + + $entries[] = $entry; } } - return [ - 'sources' => $this->sourceRegistry->sourceOptions(), - 'selected_source' => $source, - 'filters' => $filters, - 'entries' => $entries, - 'files' => array_map('basename', $files), - 'pagination' => $this->pagination->pagination($filters, $matched), - 'per_page_options' => $this->pagination->perPageOptions(), - 'time_window_options' => $this->pagination->timeWindowOptions(), - 'match_options' => $this->pagination->matchOptions(), - ]; + return $entries; } /** diff --git a/src/Core/Log/LogPagination.php b/src/Core/Log/LogPagination.php index 485d2bd0..e14a3fd7 100644 --- a/src/Core/Log/LogPagination.php +++ b/src/Core/Log/LogPagination.php @@ -7,26 +7,13 @@ final readonly class LogPagination { /** - * @param array{level: string, search: string, match: string, time_window: string, audit_action: string, per_page: int|string, page: int} $filters + * @param array{level: string, levels: list, search: string, match: string, time_window: string, audit_action: string, per_page: int, page: int} $filters * - * @return array{page: int, per_page: int|string, total: int, total_pages: int, has_previous: bool, has_next: bool, previous_page: int, next_page: int} + * @return array{page: int, per_page: int, total: int, total_pages: int, has_previous: bool, has_next: bool, previous_page: int, next_page: int} */ public function pagination(array $filters, int $matched): array { - if ('all' === $filters['per_page']) { - return [ - 'page' => 1, - 'per_page' => 'all', - 'total' => $matched, - 'total_pages' => 1, - 'has_previous' => false, - 'has_next' => false, - 'previous_page' => 1, - 'next_page' => 1, - ]; - } - - $perPage = (int) $filters['per_page']; + $perPage = $filters['per_page']; $totalPages = max(1, (int) ceil($matched / $perPage)); $page = min($filters['page'], $totalPages); @@ -43,7 +30,7 @@ public function pagination(array $filters, int $matched): array } /** - * @return list + * @return list */ public function perPageOptions(): array { @@ -52,7 +39,7 @@ public function perPageOptions(): array ['key' => 50, 'label' => '50'], ['key' => 100, 'label' => '100'], ['key' => 150, 'label' => '150'], - ['key' => 'all', 'label' => 'admin.logs.filters.all_entries'], + ['key' => 500, 'label' => '500'], ]; } diff --git a/src/Core/Log/MonologMessageLogger.php b/src/Core/Log/MonologMessageLogger.php index a6188771..0fd64b07 100644 --- a/src/Core/Log/MonologMessageLogger.php +++ b/src/Core/Log/MonologMessageLogger.php @@ -21,7 +21,10 @@ final class MonologMessageLogger implements MessageLoggerInterface */ private array $seenSignatures = []; - public function __construct(private readonly LoggerInterface $logger) + public function __construct( + private readonly LoggerInterface $logger, + private readonly ?DatabaseLogProjector $databaseLogProjector = null, + ) { } @@ -56,7 +59,9 @@ public function logBatch(iterable $records): void } try { - $this->logger->log($this->psrLevel($message->level()), $message->translationKey(), $context); + $level = $this->psrLevel($message->level()); + $this->logger->log($level, $message->translationKey(), $context); + $this->databaseLogProjector?->recordMessage(strtoupper($level), $message->translationKey(), $context); $this->seenSignatures[$signature] = true; } catch (Throwable) { continue; @@ -157,6 +162,6 @@ private function isSensitiveKey(string $key): bool { $normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '_', $key)); - return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key)/', $normalized); + return 1 === preg_match('/(?:password|secret|token|credential|authorization|cookie|hmac|encrypted|api_key|private_key|license_key)/', $normalized); } } diff --git a/src/Core/Messenger/DeferredMessengerDrainSubscriber.php b/src/Core/Messenger/DeferredMessengerDrainSubscriber.php index 745667fa..719fed7f 100644 --- a/src/Core/Messenger/DeferredMessengerDrainSubscriber.php +++ b/src/Core/Messenger/DeferredMessengerDrainSubscriber.php @@ -4,14 +4,17 @@ namespace App\Core\Messenger; +use App\Core\Routing\PathScopeMatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; final readonly class DeferredMessengerDrainSubscriber implements EventSubscriberInterface { - public function __construct(private DeferredMessengerDrain $drain) - { + public function __construct( + private DeferredMessengerDrain $drain, + private PathScopeMatcher $paths = new PathScopeMatcher(), + ) { } /** @@ -31,7 +34,7 @@ public function onKernelTerminate(TerminateEvent $event): void } $request = $event->getRequest(); - if ('scheduler_cron_run' === $request->attributes->get('_route') || str_starts_with($request->getPathInfo(), '/cron/run')) { + if ('scheduler_cron_run' === $request->attributes->get('_route') || $this->paths->matchesExactSegments($request->getPathInfo(), 'cron', 'run')) { return; } diff --git a/src/Core/Operation/Live/LiveOperationQueueFactory.php b/src/Core/Operation/Live/LiveOperationQueueFactory.php index e42f4e8a..a068d82e 100644 --- a/src/Core/Operation/Live/LiveOperationQueueFactory.php +++ b/src/Core/Operation/Live/LiveOperationQueueFactory.php @@ -20,6 +20,7 @@ public const PACKAGE_INSTALL_APPLY = 'package.install.apply'; public const ACL_GROUP_APPLY = 'acl.group.apply'; public const SETUP_APPLY = 'setup.apply'; + public const GEOIP_DATABASE_UPDATE = 'geoip.database_update'; /** * @param iterable $providers diff --git a/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php b/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php new file mode 100644 index 00000000..fc3fa54f --- /dev/null +++ b/src/Core/Operation/Live/MaxMindGeoIpLiveOperationProvider.php @@ -0,0 +1,63 @@ + $payload + * + * @return WorkflowResult + */ + public function create(array $payload = []): WorkflowResult + { + $trigger = $this->trigger($payload); + + return WorkflowResult::success(ActionQueue::create('geoip database update', [ + new MaxMindGeoIpUpdateAction($this->updater, $trigger), + ], context: [ + 'operation' => $this->operation(), + 'environment' => $this->environment($payload), + 'trigger' => $trigger, + ])); + } + + /** + * @param array $payload + */ + private function environment(array $payload): string + { + $environment = $payload['environment'] ?? $this->kernel->getEnvironment(); + + return is_string($environment) && '' !== trim($environment) ? trim($environment) : $this->kernel->getEnvironment(); + } + + /** + * @param array $payload + */ + private function trigger(array $payload): string + { + $trigger = $payload['trigger'] ?? 'live_operation'; + + return is_string($trigger) && '' !== trim($trigger) ? trim($trigger) : 'live_operation'; + } +} diff --git a/src/Core/Package/Api/PackageApiHandler.php b/src/Core/Package/Api/PackageApiHandler.php index 56e3959d..04042502 100644 --- a/src/Core/Package/Api/PackageApiHandler.php +++ b/src/Core/Package/Api/PackageApiHandler.php @@ -9,10 +9,13 @@ use App\Api\Admin\LiveOperationApiResourceFactory; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; +use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Backend\PackageLifecycleAdmin; +use App\Core\Access\AccessActor; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationQueueFactory; @@ -22,6 +25,8 @@ final readonly class PackageApiHandler implements ApiEndpointHandlerInterface { + private const PACKAGE_LIFECYCLE_FEATURE = 'admin.packages'; + public function __construct( private PackageApiReadModel $readModel, private PackageLifecycleAdmin $lifecycleAdmin, @@ -29,6 +34,7 @@ public function __construct( private LiveOperationApiResourceFactory $operationResources, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -44,6 +50,20 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + if (!$this->adminAcl->isVisible(self::PACKAGE_LIFECYCLE_FEATURE, $actor)) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'packagesRead', + ], [ + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_hidden', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + $packageSlug = $this->packageSlugFromPath($request->getPathInfo()); $packageName = null === $packageSlug ? null : $this->readModel->packageNameForSlug($packageSlug); if (null !== $packageSlug && null === $packageName) { @@ -73,11 +93,29 @@ private function package(Request $request, string $packageName): Response return $this->notFound($request, $packageName); } - return $this->responder->data($this->packageResource($package)); + return $this->responder->data($this->packageResource($request, $package)); } private function lifecycle(Request $request, string $packageName, string $action): Response { + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + $state = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $actor); + + if (!$state->isVisible()) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), + ], [ + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_hidden', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + $review = $this->lifecycleAdmin->review($packageName, $action); if (null === $review['package']) { return $this->notFound($request, $packageName); @@ -95,6 +133,21 @@ private function lifecycle(Request $request, string $packageName, string $action ]); } + if (!$state->isMutable()) { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), + ], [ + 'package' => $packageName, + 'action' => $action, + 'feature' => self::PACKAGE_LIFECYCLE_FEATURE, + 'reason' => 'feature_read_only', + ]), + Response::HTTP_FORBIDDEN, + $request, + ); + } + if (!$this->reviewAllowsConfirmation($review)) { return $this->operationUnavailable($request, 'package'.str_replace(' ', '', ucwords(str_replace('-', ' ', $action))), [ 'package' => $packageName, @@ -132,10 +185,11 @@ private function lifecycle(Request $request, string $packageName, string $action * * @return array */ - private function packageResource(array $package): array + private function packageResource(Request $request, array $package): array { $packageName = (string) $package['package_name']; $packageSlug = $this->readModel->packageSlug($packageName); + $actor = ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); return [ 'type' => 'package', @@ -144,33 +198,41 @@ private function packageResource(array $package): array ...$package, 'package_slug' => $packageSlug, 'api_path' => '/api/v1/admin/packages/'.$packageSlug, - 'api_actions' => $this->apiActions($package['actions'] ?? [], $packageSlug), + 'api_actions' => $this->apiActionsForPackage($package, $packageSlug, $actor), ], ]; } /** - * @param mixed $actions + * @param array $package * * @return list> */ - private function apiActions(mixed $actions, string $packageSlug): array + private function apiActionsForPackage(array $package, string $packageSlug, AccessActor $actor): array { - if (!is_array($actions)) { + $state = $this->adminAcl->state(self::PACKAGE_LIFECYCLE_FEATURE, $actor); + + if (!$state->isVisible() || true === ($package['immutable'] ?? false)) { return []; } + $status = (string) ($package['status'] ?? ''); + $actions = match ($status) { + 'inactive' => ['activate', 'delete'], + 'active' => ['deactivate', 'delete'], + 'faulty' => ['reset-fault', 'delete'], + 'removed' => ['purge'], + default => [], + }; + $resources = []; foreach ($actions as $action) { - if (!is_array($action) || !is_string($action['id'] ?? null)) { - continue; - } - $resources[] = [ - ...$action, + 'id' => $action, 'method' => Request::METHOD_POST, - 'api_path' => '/api/v1/admin/packages/'.$packageSlug.'/'.$action['id'], + 'api_path' => '/api/v1/admin/packages/'.$packageSlug.'/'.$action, 'requires_confirmation' => true, + 'disabled' => !$state->isMutable(), ]; } diff --git a/src/Core/Package/PackageAdminOverview.php b/src/Core/Package/PackageAdminOverview.php index 353175c1..a3c549c2 100644 --- a/src/Core/Package/PackageAdminOverview.php +++ b/src/Core/Package/PackageAdminOverview.php @@ -4,10 +4,15 @@ namespace App\Core\Package; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Package\Settings\PackageSettingRegistry; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class PackageAdminOverview { @@ -15,6 +20,8 @@ public function __construct( private EntityManagerInterface $entityManager, private PackageSettingRegistry $settingRegistry, private SystemPackageMetadataProvider $systemPackageMetadata, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -119,31 +126,38 @@ private function detailPath(string $packageName): string */ private function actions(ExtensionPackage $package): array { + $state = $this->adminAcl->state('admin.packages', $this->actor()); + + if (!$state->isVisible()) { + return []; + } + $stateActions = match ($package->status()) { - ExtensionPackageStatus::Inactive => [$this->action($package, 'activate', 'primary')], - ExtensionPackageStatus::Active => [$this->action($package, 'deactivate', 'secondary')], - ExtensionPackageStatus::Faulty => [$this->action($package, 'reset-fault', 'secondary')], + ExtensionPackageStatus::Inactive => [$this->action($package, 'activate', 'primary', $state)], + ExtensionPackageStatus::Active => [$this->action($package, 'deactivate', 'secondary', $state)], + ExtensionPackageStatus::Faulty => [$this->action($package, 'reset-fault', 'secondary', $state)], ExtensionPackageStatus::Removed => [], }; $cleanupActions = ExtensionPackageStatus::Removed === $package->status() - ? [$this->action($package, 'purge', 'danger')] + ? [$this->action($package, 'purge', 'danger', $state)] : []; if (ExtensionPackageStatus::Removed !== $package->status()) { - $cleanupActions[] = $this->action($package, 'delete', 'danger'); + $cleanupActions[] = $this->action($package, 'delete', 'danger', $state); } return [...$stateActions, ...$cleanupActions]; } - private function action(ExtensionPackage $package, string $action, string $variant): array + private function action(ExtensionPackage $package, string $action, string $variant, AdminPermissionState $state): array { return [ 'id' => $action, 'label_key' => 'admin.packages.lifecycle.'.str_replace('-', '_', $action).'.label', 'path' => $this->detailPath($package->packageName()).'/'.$action, 'variant' => $variant, + 'disabled' => !$state->isMutable(), ]; } @@ -166,4 +180,11 @@ private function statusTone(ExtensionPackageStatus $status): string ExtensionPackageStatus::Faulty => 'error', }; } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } } diff --git a/src/Core/Package/PackageLifecycleCleanupRunner.php b/src/Core/Package/PackageLifecycleCleanupRunner.php index e6e3852d..f1d26f60 100644 --- a/src/Core/Package/PackageLifecycleCleanupRunner.php +++ b/src/Core/Package/PackageLifecycleCleanupRunner.php @@ -4,6 +4,8 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Core\Package\PackageMessageCode; @@ -13,8 +15,11 @@ final readonly class PackageLifecycleCleanupRunner implements PackageLifecycleCleanupRunnerInterface { - public function __construct(private Settings\PackageSettings $packageSettings) - { + public function __construct( + private Settings\PackageSettings $packageSettings, + private AdminFeatureOverrideStore $adminFeatureOverrideStore, + private ?AdminFeatureRegistry $adminFeatureRegistry = null, + ) { } /** @@ -32,6 +37,15 @@ public function cleanup(ExtensionPackage $package): WorkflowResult ]; } + if ($this->removePackageAclOverride($package->packageName())) { + $actions[] = [ + 'action' => 'delete_package_acl_override', + 'count' => 1, + ]; + } + + $this->adminFeatureRegistry?->resetCache(); + return WorkflowResult::success([ 'package' => $package->packageName(), 'actions' => $actions, @@ -48,4 +62,18 @@ public function cleanup(ExtensionPackage $package): WorkflowResult ), ]); } + + private function removePackageAclOverride(string $packageName): bool + { + $feature = 'admin.settings.packages.'.$packageName; + $overrides = $this->adminFeatureOverrideStore->overrides(); + + if (!isset($overrides[$feature])) { + return false; + } + + unset($overrides[$feature]); + + return $this->adminFeatureOverrideStore->save($overrides, 'package_lifecycle_cleanup'); + } } diff --git a/src/Core/Package/PackageLifecycleFinalizer.php b/src/Core/Package/PackageLifecycleFinalizer.php index 6397ca0f..5f428439 100644 --- a/src/Core/Package/PackageLifecycleFinalizer.php +++ b/src/Core/Package/PackageLifecycleFinalizer.php @@ -4,6 +4,7 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Package\PackageMessageCode; use App\Core\Package\PackageMessageKey; @@ -16,6 +17,7 @@ public function __construct( private EntityManagerInterface $entityManager, private PackageLifecycleStore $store, private PackageLifecycleAssetRebuilderInterface $assetRebuilder, + private ?AdminFeatureRegistry $adminFeatureRegistry = null, ) { } @@ -33,6 +35,7 @@ public function finalize(array $snapshots, array $changes, array $messages, stri } $this->entityManager->flush(); + $this->adminFeatureRegistry?->resetCache(); if (!$rebuildAssets) { return $this->success($changes, false, false, $messages); @@ -55,6 +58,7 @@ public function finalize(array $snapshots, array $changes, array $messages, stri $this->store->restoreStatuses($snapshots); $this->entityManager->flush(); + $this->adminFeatureRegistry?->resetCache(); return WorkflowResult::failed($rebuild->issues(), [ 'changes' => $changes, diff --git a/src/Core/Package/PackageRegistrySyncFinalizer.php b/src/Core/Package/PackageRegistrySyncFinalizer.php index 3c9df984..711e82c9 100644 --- a/src/Core/Package/PackageRegistrySyncFinalizer.php +++ b/src/Core/Package/PackageRegistrySyncFinalizer.php @@ -4,6 +4,7 @@ namespace App\Core\Package; +use App\Core\AdminAcl\AdminFeatureRegistry; use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Core\Workflow\WorkflowResult; @@ -16,6 +17,7 @@ public function __construct( private ?PackageAssetRebuildDispatcher $assetRebuildDispatcher = null, private ?PackageLifecycleAssetRebuilderInterface $assetRebuildFallback = null, private string $environment = 'test', + private ?AdminFeatureRegistry $adminFeatureRegistry = null, ) { } @@ -29,6 +31,9 @@ public function __construct( public function finalize(array $changes, array $messages, array $assetRebuildTriggers): WorkflowResult { $this->entityManager->flush(); + if ([] !== $changes) { + $this->adminFeatureRegistry?->resetCache(); + } $assetRebuild = null; if ([] !== $assetRebuildTriggers) { diff --git a/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php b/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php new file mode 100644 index 00000000..7763de17 --- /dev/null +++ b/src/Core/Package/Settings/PackageSettingsAdminFeatureProvider.php @@ -0,0 +1,38 @@ + + */ + public function adminFeatures(): array + { + $features = []; + $sortOrder = 1000; + + foreach ($this->registry->packagesWithDefinitions() as $packageName => $metadata) { + $features[] = new AdminFeatureDefinition( + 'admin.settings.packages.'.$packageName, + (string) $metadata['label'], + 'admin.acl.features.admin_settings_package.description', + 'admin.acl.categories.package_settings', + defaultState: AdminPermissionState::Mutable, + sortOrder: $sortOrder++, + ); + } + + return $features; + } +} diff --git a/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php b/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php index b35daf25..de4df774 100644 --- a/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php +++ b/src/Core/Package/Settings/PackageSettingsBackendViewProvider.php @@ -36,6 +36,7 @@ public function backendViews(): array context: [ 'package_name' => $packageName, 'description' => $metadata['description'], + 'access_feature' => 'admin.settings.packages.'.$packageName, ], ); ++$sortOrder; diff --git a/src/Core/Package/Settings/PackageSettingsFormHandler.php b/src/Core/Package/Settings/PackageSettingsFormHandler.php index 1416d30e..2f7ea05c 100644 --- a/src/Core/Package/Settings/PackageSettingsFormHandler.php +++ b/src/Core/Package/Settings/PackageSettingsFormHandler.php @@ -10,6 +10,8 @@ final readonly class PackageSettingsFormHandler { + private const PROTECTED_VALUE = '[protected]'; + public function __construct( private PackageSettingRegistry $registry, private PackageSettings $settings, @@ -33,6 +35,13 @@ public function submit(string $packageName, array $submitted, ?string $modifiedB } foreach ($definitions as $definition) { + if ( + true === ($definition->metadata()['sensitive'] ?? false) + && $this->isUnchangedSensitiveValue($result->value($definition->key())) + ) { + continue; + } + if (!$this->settings->set($packageName, $definition->key(), $result->value($definition->key()), $definition->valueType(), $modifiedBy)) { return new FormSubmissionResult($result->values(), [ '__form' => [FormErrorKey::SAVE_FAILED], @@ -42,4 +51,10 @@ public function submit(string $packageName, array $submitted, ?string $modifiedB return $result; } + + private function isUnchangedSensitiveValue(mixed $value): bool + { + return null === $value + || (is_string($value) && in_array(trim($value), ['', self::PROTECTED_VALUE], true)); + } } diff --git a/src/Core/Package/ThemeAdminOverview.php b/src/Core/Package/ThemeAdminOverview.php index 6e669bce..b8609755 100644 --- a/src/Core/Package/ThemeAdminOverview.php +++ b/src/Core/Package/ThemeAdminOverview.php @@ -4,15 +4,22 @@ namespace App\Core\Package; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\ExtensionPackage; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; final readonly class ThemeAdminOverview { public function __construct( private EntityManagerInterface $entityManager, private SystemPackageMetadataProvider $systemPackageMetadata, + private Security $security, + private AdminFeatureAccessPolicy $adminAcl, ) { } @@ -29,11 +36,12 @@ public function sections(): array private function section(string $key, PackageScope $scope, string $systemPath): array { + $state = $this->packageState(); $packages = $this->packages($scope); $activePackage = $this->activePackage($packages); $themes = [ - $this->systemThemeRow($scope, $systemPath, null === $activePackage, $activePackage), - ...array_map($this->packageRow(...), $packages), + $this->systemThemeRow($scope, $systemPath, null === $activePackage, $activePackage, $state), + ...array_map(fn (ExtensionPackage $package): array => $this->packageRow($package, $state), $packages), ]; return [ @@ -71,7 +79,7 @@ private function packages(PackageScope $scope): array return array_values($packages); } - private function packageRow(ExtensionPackage $package): array + private function packageRow(ExtensionPackage $package, AdminPermissionState $state): array { $metadata = $package->metadata(); $label = $this->metadataString($metadata, 'display_name') ?? $package->packageName(); @@ -92,11 +100,11 @@ private function packageRow(ExtensionPackage $package): array 'type_label_key' => 'admin.themes.type.package', 'type_tone' => 'neutral', 'version' => $package->installedVersion() ?? $package->manifestVersion(), - 'quick_action' => $this->quickAction($package), + 'quick_action' => $state->isVisible() ? $this->quickAction($package, $state) : null, ]; } - private function systemThemeRow(PackageScope $scope, string $path, bool $active, ?ExtensionPackage $activePackage): array + private function systemThemeRow(PackageScope $scope, string $path, bool $active, ?ExtensionPackage $activePackage, AdminPermissionState $state): array { $metadata = $this->systemPackageMetadata->metadata(); $version = $metadata['version'] ?? null; @@ -121,7 +129,7 @@ private function systemThemeRow(PackageScope $scope, string $path, bool $active, 'scope' => $scope->value, 'quick_action' => $active ? $this->disabledQuickAction('admin.themes.quick.active', 'secondary') - : (null === $activePackage ? null : $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($activePackage->packageName()).'/deactivate', 'primary')), + : (null === $activePackage || !$state->isVisible() ? null : $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($activePackage->packageName()).'/deactivate', 'primary', !$state->isMutable())), ]; } @@ -144,23 +152,23 @@ private function activePackage(array $packages): ?ExtensionPackage return null; } - private function quickAction(ExtensionPackage $package): ?array + private function quickAction(ExtensionPackage $package, AdminPermissionState $state): ?array { return match ($package->status()) { ExtensionPackageStatus::Active => $this->disabledQuickAction('admin.themes.quick.active', 'secondary'), - ExtensionPackageStatus::Inactive => $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($package->packageName()).'/activate', 'primary'), - ExtensionPackageStatus::Faulty => $this->linkedQuickAction('admin.themes.quick.repair', $this->detailPath($package->packageName()).'/reset-fault', 'secondary'), + ExtensionPackageStatus::Inactive => $this->linkedQuickAction('admin.themes.quick.use', $this->detailPath($package->packageName()).'/activate', 'primary', !$state->isMutable()), + ExtensionPackageStatus::Faulty => $this->linkedQuickAction('admin.themes.quick.repair', $this->detailPath($package->packageName()).'/reset-fault', 'secondary', !$state->isMutable()), ExtensionPackageStatus::Removed => null, }; } - private function linkedQuickAction(string $labelKey, string $path, string $variant): array + private function linkedQuickAction(string $labelKey, string $path, string $variant, bool $disabled = false): array { return [ 'label_key' => $labelKey, 'path' => $path, 'variant' => $variant, - 'disabled' => false, + 'disabled' => $disabled, ]; } @@ -203,4 +211,16 @@ private static function statusSort(ExtensionPackageStatus $status): int ExtensionPackageStatus::Removed => 3, }; } + + private function packageState(): AdminPermissionState + { + return $this->adminAcl->state('admin.packages', $this->actor()); + } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } } diff --git a/src/Core/Routing/PathScopeMatcher.php b/src/Core/Routing/PathScopeMatcher.php new file mode 100644 index 00000000..ebfb59b6 --- /dev/null +++ b/src/Core/Routing/PathScopeMatcher.php @@ -0,0 +1,57 @@ +segments($prefix); + if ([] === $prefixSegments) { + return '/' === $path; + } + + return $this->matchesSegments($path, ...$prefixSegments); + } + + public function matchesAnyPrefix(string $path, string ...$prefixes): bool + { + foreach ($prefixes as $prefix) { + if ($this->matchesPrefix($path, $prefix)) { + return true; + } + } + + return false; + } + + public function matchesSegments(string $path, string ...$segments): bool + { + $pathSegments = $this->segments($path); + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== trim($segment, '/')) { + return false; + } + } + + return [] !== $segments; + } + + public function matchesExactSegments(string $path, string ...$segments): bool + { + return count($this->segments($path)) === count($segments) && $this->matchesSegments($path, ...$segments); + } + + /** + * @return list + */ + private function segments(string $path): array + { + return array_values(array_filter( + explode('/', trim($path, '/')), + static fn (string $segment): bool => '' !== $segment, + )); + } +} diff --git a/src/Core/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php new file mode 100644 index 00000000..b9045806 --- /dev/null +++ b/src/Core/Routing/RequestPathResolver.php @@ -0,0 +1,100 @@ + + */ + private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user']; + + public function __construct(private ?ContentRouteLocalization $routeLocalization = null) + { + } + + /** + * @return list + */ + public function segments(Request $request): array + { + $segments = $this->segmentsFromPath($request->getPathInfo()); + $locale = $this->localePrefix($request, $segments); + + if (is_string($locale) && ($segments[0] ?? null) === $locale) { + array_shift($segments); + } + + return $segments; + } + + public function matches(Request $request, string ...$segments): bool + { + $pathSegments = $this->segments($request); + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== trim($segment, '/')) { + return false; + } + } + + return [] !== $segments; + } + + public function matchesExact(Request $request, string ...$segments): bool + { + return count($this->segments($request)) === count($segments) && $this->matches($request, ...$segments); + } + + /** + * @param list ...$scopes + */ + public function matchesAny(Request $request, array ...$scopes): bool + { + foreach ($scopes as $scope) { + if ($this->matches($request, ...$scope)) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function segmentsFromPath(string $path): array + { + return array_values(array_filter( + explode('/', trim($path, '/')), + static fn (string $segment): bool => '' !== $segment, + )); + } + + /** + * @param list $segments + */ + private function localePrefix(Request $request, array $segments): ?string + { + $firstSegment = $segments[0] ?? ''; + + if ('' === $firstSegment || !in_array($segments[1] ?? '', self::LOCALE_PREFIX_SCOPED_SEGMENTS, true)) { + return null; + } + + $locale = $request->attributes->get('_locale'); + if (is_string($locale) && $firstSegment === $locale) { + return $firstSegment; + } + + if (null !== $this->routeLocalization && $this->routeLocalization->isEnabled() && in_array($firstSegment, $this->routeLocalization->availableLanguages(), true)) { + return $firstSegment; + } + + return null; + } +} diff --git a/src/Core/Statistics/VisitorIdGenerator.php b/src/Core/Statistics/VisitorIdGenerator.php index 61196d67..473e20db 100644 --- a/src/Core/Statistics/VisitorIdGenerator.php +++ b/src/Core/Statistics/VisitorIdGenerator.php @@ -207,6 +207,7 @@ private function fallbackVisitorId(Request $request): string 'visitor-fallback-id', $this->sourceIp($request), $this->normalizedUserAgent($request), + $this->untrustedForwardingEntropy($request), ]), $this->secret, true, @@ -231,12 +232,20 @@ private function fallbackHash(Request $request): string 'visitor-fallback', $this->sourceIp($request), $this->normalizedUserAgent($request), + $this->untrustedForwardingEntropy($request), ]), $this->secret, true, )); } + private function untrustedForwardingEntropy(Request $request): string + { + $chain = $this->proxyIpChain($request); + + return [] === $chain ? self::PLACEHOLDER : implode(',', $chain); + } + private function packCookieValue(string $token): string { return self::COOKIE_VERSION.'.'.$token.'.'.$this->signature($token); diff --git a/src/Database/TablePrefix.php b/src/Database/TablePrefix.php index 17426340..b99c7488 100644 --- a/src/Database/TablePrefix.php +++ b/src/Database/TablePrefix.php @@ -12,10 +12,12 @@ * @var list */ public const TABLES = [ + 'access_log_entry', 'access_statistic_event', 'account_token', 'acl_group', 'api_key', + 'audit_log_entry', 'config_entry', 'content_field_value', 'content_item', @@ -24,9 +26,11 @@ 'content_schema_version', 'extension_package', 'messenger_messages', + 'message_log_entry', 'package_setting_entry', 'scheduler_task', 'scheduler_task_run', + 'security_signal_event', 'site_menu', 'site_menu_item', 'state_marker', diff --git a/src/Debug/RouteRenderOptions.php b/src/Debug/RouteRenderOptions.php index 5e10fdc9..334c335f 100644 --- a/src/Debug/RouteRenderOptions.php +++ b/src/Debug/RouteRenderOptions.php @@ -16,6 +16,8 @@ public function __construct( public bool $setupCompleted = true, public string $host = 'localhost', public bool $secure = false, + /** @var array> */ + public array $headers = [], ) { } } diff --git a/src/Debug/RouteRenderer.php b/src/Debug/RouteRenderer.php index 7c7359af..e25eb6a3 100644 --- a/src/Debug/RouteRenderer.php +++ b/src/Debug/RouteRenderer.php @@ -5,6 +5,7 @@ namespace App\Debug; use App\Api\Http\ApiRequestContext; +use App\Api\Security\ApiRequestMethodPolicy; use App\Database\DatabaseReadyState; use App\Entity\ApiKey; use App\Entity\UserAccount; @@ -28,6 +29,7 @@ public function __construct( private EntityManagerInterface $entityManager, private TokenStorageInterface $tokenStorage, private SessionFactoryInterface $sessionFactory, + private ApiRequestMethodPolicy $apiMethods = new ApiRequestMethodPolicy(), ) { } @@ -37,7 +39,7 @@ public function render(RouteRenderOptions $options): RouteRenderResult $request = $this->createRequest($options); $user = $this->resolveUser($options); - if (!str_starts_with($request->getPathInfo(), '/api/v1')) { + if (!$this->apiMethods->isApiV1Request($request)) { return $this->renderBrowserRequest($request, $options, $user); } @@ -116,7 +118,7 @@ private function renderBrowserRequest(Request $request, RouteRenderOptions $opti private function createRequest(RouteRenderOptions $options): Request { - return Request::create( + $request = Request::create( $this->normalizePath($options->path), strtoupper($options->method), [], @@ -127,6 +129,12 @@ private function createRequest(RouteRenderOptions $options): Request 'HTTPS' => $options->secure ? 'on' : 'off', ], ); + + foreach ($options->headers as $name => $values) { + $request->headers->set($name, $values); + } + + return $request; } private function resolveUser(RouteRenderOptions $options): ?UserAccount diff --git a/src/Form/FormInputType.php b/src/Form/FormInputType.php index e296943c..ad60a0d8 100644 --- a/src/Form/FormInputType.php +++ b/src/Form/FormInputType.php @@ -15,6 +15,7 @@ enum FormInputType: string case Checkbox = 'checkbox'; case Number = 'number'; case Color = 'color'; + case Password = 'password'; case Captcha = 'captcha'; /** diff --git a/src/Navigation/NavigationAccessFilter.php b/src/Navigation/NavigationAccessFilter.php index c54c662c..bda28978 100644 --- a/src/Navigation/NavigationAccessFilter.php +++ b/src/Navigation/NavigationAccessFilter.php @@ -5,9 +5,14 @@ namespace App\Navigation; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; final readonly class NavigationAccessFilter { + public function __construct(private ?AdminFeatureAccessPolicy $adminAcl = null) + { + } + /** * @param list $items * @@ -19,10 +24,15 @@ public function filter(array $items, ?AccessActor $actor): array return $items; } - return array_values(array_filter($items, static function (NavigationItem $item) use ($actor): bool { + return array_values(array_filter($items, function (NavigationItem $item) use ($actor): bool { $minLevel = $item->metadata()['min_access_level'] ?? null; $accessGroups = $item->metadata()['access_groups'] ?? []; $anonymousOnly = $item->metadata()['anonymous_only'] ?? false; + $feature = $item->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl && !$this->adminAcl->isVisible($feature, $actor)) { + return false; + } if (true === $anonymousOnly && null !== $actor->userUid()) { return false; diff --git a/src/Security/Abuse/AbuseRequestInspector.php b/src/Security/Abuse/AbuseRequestInspector.php new file mode 100644 index 00000000..40c573ff --- /dev/null +++ b/src/Security/Abuse/AbuseRequestInspector.php @@ -0,0 +1,31 @@ +intentClassifier->classify($request); + + return [ + 'profile' => $profile, + 'subjects' => $this->subjectResolver->resolve($request), + 'cost' => $this->costCatalogue->costFor($profile), + ]; + } +} diff --git a/src/Security/Abuse/AbuseRequestProfile.php b/src/Security/Abuse/AbuseRequestProfile.php new file mode 100644 index 00000000..00dc29fc --- /dev/null +++ b/src/Security/Abuse/AbuseRequestProfile.php @@ -0,0 +1,70 @@ +family; + } + + public function intent(): RequestIntent + { + return $this->intent; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function route(): string + { + return $this->route; + } + + public function prefetch(): bool + { + return $this->prefetch; + } + + public function suspiciousProbe(): bool + { + return $this->suspiciousProbe; + } + + /** + * @return array{family: string, intent: string, method: string, path: string, route: string, prefetch: bool, suspicious_probe: bool} + */ + public function toArray(): array + { + return [ + 'family' => $this->family->value, + 'intent' => $this->intent->value, + 'method' => $this->method, + 'path' => $this->path, + 'route' => $this->route, + 'prefetch' => $this->prefetch, + 'suspicious_probe' => $this->suspiciousProbe, + ]; + } +} diff --git a/src/Security/Abuse/AbuseSubject.php b/src/Security/Abuse/AbuseSubject.php new file mode 100644 index 00000000..056c1262 --- /dev/null +++ b/src/Security/Abuse/AbuseSubject.php @@ -0,0 +1,55 @@ + $context + */ + public function __construct( + private AbuseSubjectType $type, + private string $identifier, + private bool $ipDerived = false, + private array $context = [], + ) { + } + + public function type(): AbuseSubjectType + { + return $this->type; + } + + public function identifier(): string + { + return $this->identifier; + } + + public function ipDerived(): bool + { + return $this->ipDerived; + } + + /** + * @return array + */ + public function context(): array + { + return $this->context; + } + + /** + * @return array{type: string, identifier: string, ip_derived: bool, context: array} + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'identifier' => $this->identifier, + 'ip_derived' => $this->ipDerived, + 'context' => $this->context, + ]; + } +} diff --git a/src/Security/Abuse/AbuseSubjectResolution.php b/src/Security/Abuse/AbuseSubjectResolution.php new file mode 100644 index 00000000..3fda0d1e --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectResolution.php @@ -0,0 +1,54 @@ + $subjects + */ + public function __construct(private array $subjects) + { + } + + /** + * @return list + */ + public function subjects(): array + { + return $this->subjects; + } + + public function primary(): ?AbuseSubject + { + foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::User, AbuseSubjectType::ApiKey, AbuseSubjectType::IpBucket] as $type) { + $subject = $this->first($type); + if (null !== $subject) { + return $subject; + } + } + + return $this->subjects[0] ?? null; + } + + public function first(AbuseSubjectType $type): ?AbuseSubject + { + foreach ($this->subjects as $subject) { + if ($subject->type() === $type) { + return $subject; + } + } + + return null; + } + + /** + * @return list}> + */ + public function toArray(): array + { + return array_map(static fn (AbuseSubject $subject): array => $subject->toArray(), $this->subjects); + } +} diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php new file mode 100644 index 00000000..ede611c4 --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -0,0 +1,246 @@ +paths = $paths ?? new RequestPathResolver(); + $this->rawPaths = $rawPaths ?? new PathScopeMatcher(); + } + + public function resolve(Request $request): AbuseSubjectResolution + { + $subjects = []; + $visitorId = $this->visitorIdGenerator->generate($request); + $subjects[] = new AbuseSubject(AbuseSubjectType::Visitor, $visitorId); + + $sourceIp = $this->visitorIdGenerator->sourceIp($request); + if (self::PLACEHOLDER !== $sourceIp) { + $subjects[] = new AbuseSubject(AbuseSubjectType::IpBucket, $this->bucket('ip', $sourceIp), true); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('visitor_ip', $visitorId.'|'.$sourceIp), true, [ + 'scope' => 'visitor_ip', + ]); + } + + $user = $this->currentUser($request); + if ($user instanceof UserAccount) { + $subjects[] = new AbuseSubject(AbuseSubjectType::User, $user->uid(), false, [ + 'access_level' => $user->accessLevel(), + ]); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('user_visitor', $user->uid().'|'.$visitorId), false, [ + 'scope' => 'user_visitor', + ]); + } + + $apiContext = ApiRequestContext::fromRequest($request); + if ($apiContext instanceof ApiRequestContext && null !== $apiContext->apiKeyUid()) { + $subjects[] = new AbuseSubject(AbuseSubjectType::ApiKey, $apiContext->apiKeyUid(), false, [ + 'prefix' => $apiContext->apiKeyPrefix(), + 'status' => $apiContext->apiKeyStatus()?->value, + ]); + $subjects[] = new AbuseSubject(AbuseSubjectType::Combined, $this->bucket('api_visitor', $apiContext->apiKeyUid().'|'.$visitorId), false, [ + 'scope' => 'api_visitor', + ]); + } else { + $prefix = $this->submittedApiKeyPrefix($request); + if (null !== $prefix) { + $subjects[] = new AbuseSubject(AbuseSubjectType::ApiKeyPrefix, $prefix); + } + } + + $schedulerCredential = $this->submittedSchedulerCredential($request); + if ($schedulerCredential instanceof AbuseSubject) { + $subjects[] = $schedulerCredential; + } + + $submittedAccount = $this->submittedAccount($request); + if ($submittedAccount instanceof AbuseSubject) { + $subjects[] = $submittedAccount; + } + + return new AbuseSubjectResolution($subjects); + } + + private function currentUser(Request $request): ?UserAccount + { + $apiUser = ApiRequestContext::fromRequest($request)?->user(); + if ($apiUser instanceof UserAccount) { + return $apiUser; + } + + $user = $this->tokenStorage->getToken()?->getUser(); + + return $user instanceof UserAccount ? $user : null; + } + + private function submittedApiKeyPrefix(Request $request): ?string + { + $authorization = $request->headers->get('Authorization'); + if (!is_string($authorization) || 1 !== preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + $dotPosition = strpos($token, '.'); + $prefix = false === $dotPosition ? $token : substr($token, 0, $dotPosition); + + return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; + } + + private function submittedSchedulerCredential(Request $request): ?AbuseSubject + { + if (!$this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run')) { + return null; + } + + $authorization = $request->headers->get('Authorization'); + if (is_string($authorization) && 1 === preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return $this->schedulerCredentialSubject(trim($matches[1])); + } + + $auth = $request->query->get('auth'); + + return is_string($auth) ? $this->schedulerCredentialSubject(trim($auth)) : null; + } + + private function schedulerCredentialSubject(string $token): ?AbuseSubject + { + if ('' === $token) { + return null; + } + + return new AbuseSubject(AbuseSubjectType::SchedulerCredential, $this->bucket('scheduler_credential', substr($token, 0, 128))); + } + + private function submittedAccount(Request $request): ?AbuseSubject + { + $segments = $this->paths->segments($request); + $route = $request->attributes->get('_route'); + $token = $request->attributes->get('token'); + + if ('user_invitation_accept' === $route) { + return $this->submittedTokenSubject('registration_token', $token); + } + + if ('user_password_reset_token' === $route) { + return $this->submittedTokenSubject('password_reset_token', $token); + } + + if ('user_security_review' === $route) { + return $this->submittedTokenSubject('security_review_token', $token); + } + + if ($this->matchesExactSegments($segments, 'user', 'login')) { + return $this->submittedAccountSubject('login', $request->request->get('username')); + } + + if ($this->matchesExactSegments($segments, 'user', 'register')) { + return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); + } + + if ($this->matchesSegments($segments, 'user', 'invitation') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('registration_token', $submittedToken); + } + + if ($this->matchesExactSegments($segments, 'user', 'reset-password')) { + return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); + } + + if ($this->matchesSegments($segments, 'user', 'reset-password') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('password_reset_token', $submittedToken); + } + + if ($this->matchesSegments($segments, 'user', 'security-review') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { + return $this->submittedAccountSubject('security_review_token', $submittedToken); + } + + return null; + } + + private function submittedTokenSubject(string $scope, mixed $token): ?AbuseSubject + { + if (!is_string($token) || 1 !== preg_match('/^[a-f0-9]{64}$/i', $token)) { + return null; + } + + return $this->submittedAccountSubject($scope, $token); + } + + /** + * @param list $segments + */ + private function matchesSegments(array $segments, string ...$expected): bool + { + foreach ($expected as $index => $segment) { + if (($segments[$index] ?? null) !== $segment) { + return false; + } + } + + return [] !== $expected; + } + + /** + * @param list $segments + */ + private function matchesExactSegments(array $segments, string ...$expected): bool + { + return count($segments) === count($expected) && $this->matchesSegments($segments, ...$expected); + } + + /** + * @param list $segments + */ + private function tokenSegment(array $segments, int $index): ?string + { + $token = $segments[$index] ?? null; + + return is_string($token) && count($segments) === $index + 1 && 1 === preg_match('/^[a-f0-9]{64}$/i', $token) ? $token : null; + } + + private function submittedAccountSubject(string $scope, mixed $value, bool $email = false): ?AbuseSubject + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string) $value); + $normalized = $email ? EmailAddress::normalize($normalized) : strtolower($normalized); + $normalized = substr($normalized, 0, 190); + if ('' === $normalized) { + return null; + } + + return new AbuseSubject(AbuseSubjectType::SubmittedAccount, $this->bucket($scope, $normalized), false, [ + 'scope' => $scope, + ]); + } + + private function bucket(string $scope, string $value): string + { + return substr(hash_hmac('sha256', 'abuse.subject.'.$scope.'|'.$value, $this->secret), 0, 40); + } +} diff --git a/src/Security/Abuse/AbuseSubjectType.php b/src/Security/Abuse/AbuseSubjectType.php new file mode 100644 index 00000000..7ae9d974 --- /dev/null +++ b/src/Security/Abuse/AbuseSubjectType.php @@ -0,0 +1,17 @@ +bucketFamily; + } + + public function credits(): int + { + return $this->credits; + } + + public function ordinaryEnforcement(): bool + { + return $this->ordinaryEnforcement; + } +} diff --git a/src/Security/Abuse/ActionCostCatalogue.php b/src/Security/Abuse/ActionCostCatalogue.php new file mode 100644 index 00000000..d84c42d1 --- /dev/null +++ b/src/Security/Abuse/ActionCostCatalogue.php @@ -0,0 +1,105 @@ +intent()) { + RequestIntent::LiveApi => new ActionCost('live_api', 0, false), + RequestIntent::TurboPrefetch => new ActionCost('website_prefetch', 0, false), + RequestIntent::CorsPreflight => new ActionCost('api_preflight', 0, false), + RequestIntent::SuspiciousProbe => new ActionCost('suspicious_probe', 10), + RequestIntent::Login => new ActionCost('login', 1), + RequestIntent::RecoveryLogin => new ActionCost('recovery_login', 1), + RequestIntent::Registration => new ActionCost('registration', 5), + RequestIntent::PasswordReset => new ActionCost('password_reset', 3), + RequestIntent::CaptchaFailure => new ActionCost('captcha_failure', 1), + RequestIntent::Contact => new ActionCost('contact', 3), + RequestIntent::SchedulerTrigger => new ActionCost('scheduler', 1), + RequestIntent::SetupApply => new ActionCost('setup_apply', 8), + RequestIntent::ApiRead => new ActionCost('api_read', 1), + RequestIntent::ApiWrite => new ActionCost('api_write', 5), + RequestIntent::SettingsMutation, + RequestIntent::UserAclMutation, + RequestIntent::PackageAdminOperation, + RequestIntent::BackupRestore, + RequestIntent::ImportOperation, + RequestIntent::AdminOperation => new ActionCost('admin_mutation', 8), + RequestIntent::UploadArchiveValidation => new ActionCost('upload_archive', 8), + RequestIntent::ExportDownload, + RequestIntent::DiagnosticsSupport => new ActionCost('download_diagnostics', 4), + RequestIntent::FormSubmit => new ActionCost('website_form', 2), + default => new ActionCost($this->defaultBucket($profile->family()), 1), + }; + } + + /** + * @return array + */ + public function uniqueCreditsByBucketFamily(): array + { + $credits = []; + $mixedFamilies = []; + + foreach (RequestIntent::cases() as $intent) { + $cost = $this->costFor($this->sampleProfile($intent)); + if (!$cost->ordinaryEnforcement() || $cost->credits() < 1) { + continue; + } + + $family = $cost->bucketFamily(); + if (isset($credits[$family]) && $credits[$family] !== $cost->credits()) { + $mixedFamilies[$family] = true; + unset($credits[$family]); + continue; + } + + if (!isset($mixedFamilies[$family])) { + $credits[$family] = $cost->credits(); + } + } + + return $credits; + } + + private function defaultBucket(RequestFamily $family): string + { + return match ($family) { + RequestFamily::Api => 'api_read', + RequestFamily::Admin, RequestFamily::Editor => 'admin_navigation', + RequestFamily::Setup => 'setup', + default => 'website', + }; + } + + private function sampleProfile(RequestIntent $intent): AbuseRequestProfile + { + return new AbuseRequestProfile( + match ($intent) { + RequestIntent::ApiRead, RequestIntent::ApiWrite, RequestIntent::CorsPreflight, RequestIntent::LiveApi => RequestFamily::Api, + RequestIntent::SchedulerTrigger => RequestFamily::Scheduler, + RequestIntent::SetupApply => RequestFamily::Setup, + RequestIntent::PackageAdminOperation, + RequestIntent::SettingsMutation, + RequestIntent::UserAclMutation, + RequestIntent::UploadArchiveValidation, + RequestIntent::ExportDownload, + RequestIntent::ImportOperation, + RequestIntent::BackupRestore, + RequestIntent::DiagnosticsSupport, + RequestIntent::AdminOperation => RequestFamily::Admin, + default => RequestFamily::Browser, + }, + $intent, + 'GET', + '/', + 'test', + RequestIntent::TurboPrefetch === $intent, + RequestIntent::SuspiciousProbe === $intent, + ); + } +} diff --git a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php new file mode 100644 index 00000000..05de629c --- /dev/null +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -0,0 +1,113 @@ + ['onKernelResponse', -300], + ]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest() || $this->shouldSkip($event->getRequest()->getPathInfo())) { + return; + } + + try { + $inspection = $this->inspector->inspect($event->getRequest()); + $profile = $inspection['profile']; + $signal = $this->signalFor($profile); + + if (null === $signal) { + return; + } + + $subjects = $inspection['subjects']; + $subject = $subjects->primary(); + if (null === $subject) { + return; + } + + $visitor = $subjects->first(AbuseSubjectType::Visitor); + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + $cost = $inspection['cost']; + $this->signalRecorder->record( + $signal['type'], + $signal['reason'], + $subject->type()->value, + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: $signal['severity'], + confidence: $signal['confidence'], + requestFamily: $profile->family()->value, + requestIntent: $profile->intent()->value, + requestId: $this->accessRequestMetadata->requestId($event->getRequest()), + visitorId: $visitor?->identifier() ?? 'n/a', + path: $this->accessRequestMetadata->sanitizedPath($event->getRequest()), + route: $profile->route(), + httpStatus: $event->getResponse()->getStatusCode(), + context: [ + 'ip_bucket' => $ipBucket?->identifier(), + 'cost_bucket' => $cost->bucketFamily(), + 'cost_credits' => $cost->credits(), + 'ordinary_enforcement' => $cost->ordinaryEnforcement(), + ], + ); + } catch (Throwable) { + return; + } + } + + /** + * @return array{type: string, reason: string, severity: string, confidence: int}|null + */ + private function signalFor(AbuseRequestProfile $profile): ?array + { + if ($profile->suspiciousProbe()) { + return [ + 'type' => 'probe', + 'reason' => 'security.signal.suspicious_probe', + 'severity' => 'WARNING', + 'confidence' => 95, + ]; + } + + if ($profile->prefetch() && !in_array($profile->method(), ['GET', 'HEAD'], true)) { + return [ + 'type' => 'prefetch', + 'reason' => 'security.signal.prefetch_unsafe_method', + 'severity' => 'NOTICE', + 'confidence' => 60, + ]; + } + + return null; + } + + private function shouldSkip(string $path): bool + { + return str_starts_with($path, '/_profiler') + || str_starts_with($path, '/_wdt') + || str_starts_with($path, '/assets/') + || str_starts_with($path, '/build/'); + } +} diff --git a/src/Security/Abuse/RequestFamily.php b/src/Security/Abuse/RequestFamily.php new file mode 100644 index 00000000..2d91a7e5 --- /dev/null +++ b/src/Security/Abuse/RequestFamily.php @@ -0,0 +1,17 @@ +paths = $paths ?? new RequestPathResolver($routeLocalization); + $this->rawPaths = $rawPaths ?? new PathScopeMatcher(); + } + + public function classify(Request $request): AbuseRequestProfile + { + $method = strtoupper($request->getMethod()); + $path = $request->getPathInfo(); + $segments = $this->segments($request); + $route = $this->route($request); + $family = $this->family($request, $segments); + $prefetch = $this->isPrefetch($request); + $suspiciousProbe = $this->probePathMatcher->isProbe($path); + + return new AbuseRequestProfile( + $family, + $this->intent($request, $method, $segments, $route, $family, $prefetch, $suspiciousProbe), + $method, + substr($path, 0, 1024), + $route, + $prefetch, + $suspiciousProbe, + ); + } + + private function family(Request $request, array $segments): RequestFamily + { + $rawPath = $request->getPathInfo(); + + return match (true) { + $this->rawPaths->matchesSegments($rawPath, 'api', 'live') => RequestFamily::LiveApi, + $this->rawPaths->matchesSegments($rawPath, 'api') => RequestFamily::Api, + $this->rawPaths->matchesSegments($rawPath, 'cron') => RequestFamily::Scheduler, + $this->rawPaths->matchesSegments($rawPath, 'setup') => RequestFamily::Setup, + $this->matchesSegments($segments, 'admin') => RequestFamily::Admin, + $this->matchesSegments($segments, 'editor') => RequestFamily::Editor, + default => RequestFamily::Browser, + }; + } + + private function intent( + Request $request, + string $method, + array $segments, + string $route, + RequestFamily $family, + bool $prefetch, + bool $suspiciousProbe, + ): RequestIntent { + if ($suspiciousProbe) { + return RequestIntent::SuspiciousProbe; + } + + if (RequestFamily::Scheduler === $family) { + return $this->schedulerTrigger($request) + ? RequestIntent::SchedulerTrigger + : RequestIntent::BrowserNavigation; + } + + if (RequestFamily::LiveApi === $family) { + return RequestIntent::LiveApi; + } + + if (RequestFamily::Api === $family) { + if ('OPTIONS' === $method) { + if ($this->apiMethods->hasAuthorizationHeader($request)) { + return $this->apiIntentForMethod($this->apiMethods->effectiveMethod($request), $segments, $route); + } + + return RequestIntent::CorsPreflight; + } + + if ($this->matchesSegments($segments, 'api', 'v1', 'admin') && !$this->safeMethod($method)) { + return $this->adminMutationIntent($this->apiAdminSegments($segments), $route); + } + + return $this->apiIntentForMethod($method, $segments, $route); + } + + if ('OPTIONS' === $method) { + return RequestIntent::CorsPreflight; + } + + if (RequestFamily::Setup === $family && !$this->safeMethod($method)) { + return $this->setupApply($request, $segments) + ? RequestIntent::SetupApply + : RequestIntent::BrowserNavigation; + } + + $adminReadIntent = RequestFamily::Admin === $family ? $this->adminReadIntent($segments, $route) : null; + if ($adminReadIntent instanceof RequestIntent) { + return $adminReadIntent; + } + + if (RequestFamily::Admin === $family && !$this->safeMethod($method)) { + return $this->adminMutationIntent($segments, $route); + } + + if ($this->recoveryLogin($request, $method, $segments, $route)) { + return RequestIntent::RecoveryLogin; + } + + if ($prefetch && 'GET' === $method) { + return RequestIntent::TurboPrefetch; + } + + return match (true) { + !$this->safeMethod($method) && ($this->routeIs($route, 'user_login') || $this->matchesSegments($segments, 'user', 'login')) => RequestIntent::Login, + !$this->safeMethod($method) && ($this->routeIs($route, 'user_register', 'user_invitation_accept') || $this->matchesSegments($segments, 'user', 'register') || $this->matchesSegments($segments, 'user', 'invitation')) => RequestIntent::Registration, + !$this->safeMethod($method) && ($this->routeIs($route, 'user_reset_password', 'user_password_reset_token', 'user_security_review') || $this->matchesSegments($segments, 'user', 'password-reset') || $this->matchesSegments($segments, 'user', 'reset-password') || $this->matchesSegments($segments, 'user', 'security-review')) => RequestIntent::PasswordReset, + !$this->safeMethod($method) => RequestIntent::FormSubmit, + default => RequestIntent::BrowserNavigation, + }; + } + + private function recoveryLogin(Request $request, string $method, array $segments, string $route): bool + { + return 'GET' === $method + && $this->matchesSegments($segments, 'user', 'login') + && $this->routeIs($route, 'user_login', 'n/a') + && '1' === (string) $request->query->get('bypass', ''); + } + + private function setupApply(Request $request, array $segments): bool + { + return $this->matchesExactSegments($segments, 'setup', 'review') + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + + private function schedulerTrigger(Request $request): bool + { + return $this->rawPaths->matchesExactSegments($request->getPathInfo(), 'cron', 'run'); + } + + private function adminMutationIntent(array $segments, string $route): RequestIntent + { + return match (true) { + $this->matchesSegments($segments, 'admin', 'settings') || $this->routeHasToken($route, 'settings') => RequestIntent::SettingsMutation, + $this->matchesSegments($segments, 'admin', 'users') || $this->routeHasToken($route, 'users') || $this->routeHasToken($route, 'acl') => RequestIntent::UserAclMutation, + $this->hasSegment($segments, 'upload', 'archive', 'media') || $this->routeHasAnyToken($route, 'upload', 'archive', 'media') => RequestIntent::UploadArchiveValidation, + $this->hasSegment($segments, 'export', 'download') || $this->routeHasAnyToken($route, 'export', 'download') => RequestIntent::ExportDownload, + $this->matchesSegments($segments, 'admin', 'packages') || $this->routeHasToken($route, 'package') || $this->routeHasToken($route, 'packages') => RequestIntent::PackageAdminOperation, + $this->hasSegment($segments, 'import') || $this->routeHasToken($route, 'import') => RequestIntent::ImportOperation, + $this->hasSegment($segments, 'backup', 'restore') || $this->routeHasAnyToken($route, 'backup', 'restore') => RequestIntent::BackupRestore, + $this->hasSegment($segments, 'diagnostic', 'diagnostics', 'support') || $this->routeHasAnyToken($route, 'diagnostic', 'diagnostics', 'support') => RequestIntent::DiagnosticsSupport, + default => RequestIntent::AdminOperation, + }; + } + + private function adminReadIntent(array $segments, string $route): ?RequestIntent + { + return match (true) { + $this->hasSegment($segments, 'export', 'download') || $this->routeHasAnyToken($route, 'export', 'download') => RequestIntent::ExportDownload, + $this->hasSegment($segments, 'diagnostic', 'diagnostics', 'support') || $this->routeHasAnyToken($route, 'diagnostic', 'diagnostics', 'support') => RequestIntent::DiagnosticsSupport, + default => null, + }; + } + + private function apiIntentForMethod(string $method, array $segments, string $route): RequestIntent + { + $method = strtoupper($method); + if ($this->matchesSegments($segments, 'api', 'v1', 'admin') && !$this->safeMethod($method)) { + return $this->adminMutationIntent($this->apiAdminSegments($segments), $route); + } + + return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) ? RequestIntent::ApiRead : RequestIntent::ApiWrite; + } + + private function isPrefetch(Request $request): bool + { + foreach (['Sec-Purpose', 'X-Sec-Purpose', 'Purpose'] as $header) { + $value = strtolower((string) $request->headers->get($header, '')); + if (str_contains($value, 'prefetch')) { + return true; + } + } + + return false; + } + + private function safeMethod(string $method): bool + { + return in_array($method, ['GET', 'HEAD', 'OPTIONS'], true); + } + + private function route(Request $request): string + { + $route = $request->attributes->get('_route'); + + return is_string($route) && '' !== $route ? substr($route, 0, 190) : 'n/a'; + } + + private function routeIs(string $route, string ...$routes): bool + { + return in_array($route, $routes, true); + } + + private function routeHasToken(string $route, string $token): bool + { + return in_array($token, $this->routeTokens($route), true); + } + + private function routeHasAnyToken(string $route, string ...$tokens): bool + { + return [] !== array_intersect($tokens, $this->routeTokens($route)); + } + + private function routeTokens(string $route): array + { + return array_values(array_filter( + preg_split('/[^a-z0-9]+/', strtolower($route)) ?: [], + static fn (string $token): bool => '' !== $token, + )); + } + + private function segments(Request $request): array + { + return $this->paths->segments($request); + } + + private function matchesSegments(array $pathSegments, string ...$segments): bool + { + foreach ($segments as $index => $segment) { + if (($pathSegments[$index] ?? null) !== $segment) { + return false; + } + } + + return [] !== $segments; + } + + private function matchesExactSegments(array $pathSegments, string ...$segments): bool + { + return count($pathSegments) === count($segments) && $this->matchesSegments($pathSegments, ...$segments); + } + + private function hasSegment(array $pathSegments, string ...$segments): bool + { + foreach ($segments as $segment) { + if (in_array($segment, $pathSegments, true)) { + return true; + } + } + + return false; + } + + private function apiAdminSegments(array $segments): array + { + return $this->matchesSegments($segments, 'api', 'v1', 'admin') + ? ['admin', ...array_slice($segments, 3)] + : $segments; + } + +} diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php new file mode 100644 index 00000000..1a5aca17 --- /dev/null +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -0,0 +1,127 @@ + $context + */ + public function record( + string $signalType, + string $reasonCode, + string $subjectType, + string $subjectIdentifier, + bool $ipDerived = false, + string $severity = 'INFO', + int $confidence = 50, + string $requestFamily = 'unknown', + string $requestIntent = 'unknown', + string $requestId = self::PLACEHOLDER, + string $visitorId = self::PLACEHOLDER, + string $path = self::PLACEHOLDER, + string $route = self::PLACEHOLDER, + ?int $httpStatus = null, + array $context = [], + ): void { + if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { + return; + } + + $now = $this->clock->now(); + $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal().'D')); + $context = [ + ...$context, + 'signal_type' => $this->short($signalType, 80), + 'reason_code' => $this->short($reasonCode, 120), + 'subject_type' => $this->short($subjectType, 40), + 'subject_identifier' => $this->short($subjectIdentifier, 190), + 'ip_derived' => $ipDerived, + 'request_family' => $this->short($requestFamily, 40), + 'request_intent' => $this->short($requestIntent, 80), + ]; + + try { + $this->connection->insert(self::TABLE, [ + 'uid' => $this->uuidFactory->generate(), + 'occurred_at' => $now->format('Y-m-d H:i:s'), + 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), + 'signal_type' => $context['signal_type'], + 'reason_code' => $context['reason_code'], + 'severity' => $this->severity($severity), + 'confidence' => max(0, min(100, $confidence)), + 'subject_type' => $context['subject_type'], + 'subject_identifier' => $context['subject_identifier'], + 'ip_derived' => $ipDerived ? 1 : 0, + 'request_family' => $context['request_family'], + 'request_intent' => $context['request_intent'], + 'request_id' => $this->short($requestId, 64), + 'visitor_id' => $this->short($visitorId, 64), + 'path' => $this->short($path, 1024), + 'route' => $this->short($route, 190), + 'http_status' => $httpStatus, + 'context' => $this->json($context), + ]); + $this->purgeExpired(); + } catch (Throwable) { + return; + } + } + + public function purgeExpired(): int + { + try { + return $this->connection->executeStatement('DELETE FROM '.self::TABLE.' WHERE expires_at <= ?', [ + $this->clock->now()->format('Y-m-d H:i:s'), + ]); + } catch (Throwable) { + return 0; + } + } + + private function short(mixed $value, int $length): string + { + $value = is_scalar($value) ? trim((string) $value) : self::PLACEHOLDER; + + return mb_substr('' === $value ? self::PLACEHOLDER : $value, 0, $length); + } + + private function severity(string $severity): string + { + $severity = strtoupper(trim($severity)); + + return in_array($severity, ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL'], true) ? $severity : 'INFO'; + } + + private function json(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable) { + return '{}'; + } + } +} diff --git a/src/Security/Abuse/SuspiciousProbePathMatcher.php b/src/Security/Abuse/SuspiciousProbePathMatcher.php new file mode 100644 index 00000000..89c8218d --- /dev/null +++ b/src/Security/Abuse/SuspiciousProbePathMatcher.php @@ -0,0 +1,191 @@ + + */ + public const DEFAULT_PATTERNS = [ + '#/(?:\.env|\.git|\.svn|\.hg)(?:/|$)#i', + '#/(?:wp-admin|wp-login\.php|xmlrpc\.php|phpmyadmin|pma|adminer\.php)(?:/|$)#i', + '#/(?:backup|dump|database|db|site|www|wordpress)[^/]*\.(?:sql|sqlite|db|bak|old|zip|tar|gz|tgz|7z|rar)(?:$|[?\#])#i', + '#/(?:shell|cmd|webshell|wso|c99|r57)\.php(?:$|[?\#])#i', + '#/(?:vendor/phpunit|boaform|cgi-bin|HNAP1|actuator|server-status)(?:/|$)#i', + '#/(?:\.DS_Store|composer\.(?:json|lock)|package-lock\.json)(?:$|[?\#])#i', + ]; + + private const MAX_PATTERN_COUNT = 100; + private const MAX_PATTERN_LENGTH = 500; + private const CACHE_TTL_SECONDS = 300; + + /** + * @param list|null $patterns + */ + public function __construct( + private readonly ?Config $config = null, + private readonly ?array $patterns = null, + private readonly ?CacheInterface $cache = null, + ) { + } + + /** + * @var list|null + */ + private ?array $activePatterns = null; + + public static function defaultPatternText(): string + { + return implode("\n", self::DEFAULT_PATTERNS); + } + + public function isProbe(string $path): bool + { + $path = '/'.ltrim(rawurldecode($path), '/'); + + foreach ($this->activePatterns() as $pattern) { + if (1 === preg_match($pattern, $path)) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function activePatterns(): array + { + if (null !== $this->activePatterns) { + return $this->activePatterns; + } + + if (null !== $this->patterns) { + return $this->activePatterns = $this->normalizePatterns($this->patterns); + } + + if (null !== $this->cache) { + try { + return $this->activePatterns = $this->cache->get( + self::CACHE_KEY, + function (CacheItemInterface $item): array { + $item->expiresAfter(self::CACHE_TTL_SECONDS); + + return $this->loadConfiguredPatterns(); + }, + ); + } catch (Throwable) { + return $this->activePatterns = $this->loadConfiguredPatterns(); + } + } + + return $this->activePatterns = $this->loadConfiguredPatterns(); + } + + public function resetCache(): void + { + $this->activePatterns = null; + + try { + $this->cache?->delete(self::CACHE_KEY); + } catch (Throwable) { + } + } + + /** + * @return list + */ + private function loadConfiguredPatterns(): array + { + $configured = $this->config?->get(self::PATTERNS_KEY, self::defaultPatternText()) ?? self::defaultPatternText(); + + return $this->normalizePatterns($configured); + } + + /** + * @param mixed $patterns + * + * @return list + */ + private function normalizePatterns(mixed $patterns): array + { + $candidates = is_array($patterns) + ? $patterns + : $this->parsePatternText(is_string($patterns) ? $patterns : ''); + $valid = []; + + foreach ($candidates as $candidate) { + if (!is_string($candidate)) { + continue; + } + + $pattern = trim($candidate); + + if ('' === $pattern || self::MAX_PATTERN_LENGTH < strlen($pattern)) { + continue; + } + + if (false === @preg_match($pattern, '/probe-test')) { + continue; + } + + $valid[] = $pattern; + + if (self::MAX_PATTERN_COUNT <= count($valid)) { + break; + } + } + + return [] === $valid ? self::DEFAULT_PATTERNS : array_values(array_unique($valid)); + } + + /** + * @return list + */ + private function parsePatternText(string $text): array + { + $patterns = []; + $lines = preg_split('/\R+/', $text); + + foreach (false === $lines ? [$text] : $lines as $line) { + $line = trim($line); + + if ('' === $line) { + continue; + } + + if (!$this->looksLikeQuotedCsv($line)) { + $patterns[] = $line; + + continue; + } + + foreach (str_getcsv($line, ',', '"', '\\') as $value) { + $value = trim((string) $value); + + if ('' !== $value) { + $patterns[] = $value; + } + } + } + + return $patterns; + } + + private function looksLikeQuotedCsv(string $line): bool + { + return str_contains($line, ',') && str_starts_with(ltrim($line), '"'); + } +} diff --git a/src/Security/AclGroupApplyService.php b/src/Security/AclGroupApplyService.php index 7c1b7cba..bc824fe5 100644 --- a/src/Security/AclGroupApplyService.php +++ b/src/Security/AclGroupApplyService.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Message\MessageLevel; @@ -26,6 +27,7 @@ public function __construct( private EntityManagerInterface $entityManager, private AclGroupImpactService $impactService, private AdminUserAccessPolicy $policy, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, ) { } @@ -88,6 +90,7 @@ private function update(AclGroup $group, AccessActor $actor, array $payload): Wo $floorCleanup = $this->impactService->removeBelowMinRoleReferences($group, $minRole); $group->changeMinRole($minRole); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); return WorkflowResult::success([ 'group' => $group->identifier(), @@ -124,6 +127,7 @@ private function delete(AclGroup $group, AccessActor $actor): WorkflowResult $groupUid = $group->uid(); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); return WorkflowResult::success([ 'group' => $identifier, diff --git a/src/Security/Api/SelfServiceApiHandler.php b/src/Security/Api/SelfServiceApiHandler.php index 7a9f848d..530fd33a 100644 --- a/src/Security/Api/SelfServiceApiHandler.php +++ b/src/Security/Api/SelfServiceApiHandler.php @@ -18,6 +18,7 @@ use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Message\MessageException; +use App\Core\Routing\PathScopeMatcher; use App\Core\Validation\EmailAddress; use App\Entity\ApiKey; use App\Entity\UserAccount; @@ -48,6 +49,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private PathScopeMatcher $paths = new PathScopeMatcher(), ) { } @@ -68,7 +70,7 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $this->notFound($request); } - if (str_starts_with($request->getPathInfo(), '/api/v1/user/api-keys')) { + if ($this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1', 'user', 'api-keys')) { return $this->handleApiKeys($request, $user); } diff --git a/src/Security/Api/UserApiHandler.php b/src/Security/Api/UserApiHandler.php index 96e07edc..492bc3a5 100644 --- a/src/Security/Api/UserApiHandler.php +++ b/src/Security/Api/UserApiHandler.php @@ -8,6 +8,7 @@ use App\Api\ApiMessageKey; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Http\ApiJsonRequestParser; use App\Api\Http\ApiRequestContext; use App\Api\Http\ApiResponder; @@ -34,6 +35,7 @@ public function __construct( private ApiJsonRequestParser $jsonRequests, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -49,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users', 'listAdminUsers')) { + return $denied; + } + $requestedUsername = $this->usernameFromPath($request->getPathInfo()); $user = null === $requestedUsername ? null : $this->user($requestedUsername); if (null !== $requestedUsername && null === $user) { @@ -70,6 +76,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function updateUser(Request $request, UserAccount $user): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users', 'updateAdminUser')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { diff --git a/src/Security/Api/UserGroupApiHandler.php b/src/Security/Api/UserGroupApiHandler.php index 2d4e7ec6..3cd447f2 100644 --- a/src/Security/Api/UserGroupApiHandler.php +++ b/src/Security/Api/UserGroupApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiJsonRequestParser; @@ -14,6 +15,7 @@ use App\Api\Http\ApiResponder; use App\Api\Security\ApiAccessGuard; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; use App\Core\Id\UuidFactory; use App\Core\Log\AuditLoggerInterface; use App\Core\Message\CommonMessageCode; @@ -45,6 +47,8 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -60,6 +64,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users.acl', 'listAdminAclGroups')) { + return $denied; + } + $groupIdentifier = $this->groupIdentifierFromPath($request->getPathInfo()); if (null !== $groupIdentifier) { $group = $this->group($groupIdentifier); @@ -88,6 +96,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function createGroup(Request $request): Response { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'createAdminAclGroup')) { + return $denied; + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { @@ -130,6 +142,7 @@ private function createGroup(Request $request): Response $group = new AclGroup($this->uuidFactory->generate(), $identifier, $name, (int) $minRole); $this->entityManager->persist($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_created', ['group' => $group->identifier()]); return $this->responder->data($this->readModel->resource($group, includeDetail: true), Response::HTTP_CREATED); @@ -142,6 +155,12 @@ private function createGroup(Request $request): Response private function updateGroup(Request $request, AclGroup $group): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'updateAdminAclGroup')) { + return $denied; + } + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { @@ -172,6 +191,7 @@ private function updateGroup(Request $request, AclGroup $group): Response $floorCleanup = $this->impact->removeBelowMinRoleReferences($group, $pending['min_role']); $group->changeMinRole($pending['min_role']); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_updated', [ 'group' => $group->identifier(), 'old_name' => $oldName, @@ -190,6 +210,12 @@ private function updateGroup(Request $request, AclGroup $group): Response private function deleteGroup(Request $request, AclGroup $group): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.acl', 'deleteAdminAclGroup')) { + return $denied; + } + } + try { $payload = $this->jsonRequests->object($request); } catch (JsonException $error) { @@ -215,6 +241,7 @@ private function deleteGroup(Request $request, AclGroup $group): Response $identifier = $group->identifier(); $this->entityManager->remove($group); $this->entityManager->flush(); + $this->adminFeatureAccessPolicy->resetCache(); $this->audit($request, 'acl.group_deleted', [ 'group' => $identifier, 'impact' => $cleanupImpact['summary'], diff --git a/src/Security/Api/UserGroupMembershipApiHandler.php b/src/Security/Api/UserGroupMembershipApiHandler.php index bcf0fd4e..ed4dac97 100644 --- a/src/Security/Api/UserGroupMembershipApiHandler.php +++ b/src/Security/Api/UserGroupMembershipApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiRequestContext; @@ -34,6 +35,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -49,6 +51,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users', 'updateAdminUserGroupMembership')) { + return $denied; + } + $membership = $this->membershipFromPath($request->getPathInfo()); if (null === $membership) { return $this->notFound($request, ['path' => $request->getPathInfo()]); diff --git a/src/Security/Api/UserReviewApiHandler.php b/src/Security/Api/UserReviewApiHandler.php index b93d4b49..86866033 100644 --- a/src/Security/Api/UserReviewApiHandler.php +++ b/src/Security/Api/UserReviewApiHandler.php @@ -6,6 +6,7 @@ use App\Api\ApiMessageCode; use App\Api\ApiMessageKey; +use App\Api\Admin\AdminFeatureApiGuard; use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointHandlerInterface; use App\Api\Http\ApiListQueryNormalizer; @@ -53,6 +54,7 @@ public function __construct( private AuditLoggerInterface $auditLogger, private ApiAccessGuard $accessGuard, private ApiResponder $responder, + private AdminFeatureApiGuard $featureGuard, ) { } @@ -68,6 +70,10 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo return $denied; } + if ($denied = $this->featureGuard->denyUnlessVisible($request, 'admin.users.review', 'listAdminUserReviews')) { + return $denied; + } + $tokenAction = $this->tokenActionFromPath($request->getPathInfo()); if (null !== $tokenAction) { return $this->reviewTokenAction($request, $tokenAction['token_uid'], $tokenAction['action']); @@ -87,6 +93,12 @@ public function handle(Request $request, ApiEndpointDefinition $endpoint): Respo private function reviewAction(Request $request, string $username, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.review', 'reviewAdminUser')) { + return $denied; + } + } + $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); if (!$user instanceof UserAccount) { return $this->notFound($request, $username); @@ -117,6 +129,12 @@ private function reviewAction(Request $request, string $username, string $action private function reviewTokenAction(Request $request, string $tokenUid, string $action): Response { + if ($request->query->getBoolean('confirm')) { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.users.review', 'reviewAdminUserToken')) { + return $denied; + } + } + $token = $this->entityManager->find(AccountToken::class, $tokenUid); if (!$token instanceof AccountToken) { return $this->notFound($request, $tokenUid); diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php new file mode 100644 index 00000000..3b0b304a --- /dev/null +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -0,0 +1,56 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + LoginFailureEvent::class => 'onLoginFailure', + LoginSuccessEvent::class => 'onLoginSuccess', + ]; + } + + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $this->resets->resetLoginAttempts($event->getRequest()); + } + + public function onLoginFailure(LoginFailureEvent $event): void + { + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { + return; + } + + $result = $this->enforcer->check($request, RateLimitEnforcementStage::AuthenticationFailure); + if ($result->isAllowed()) { + return; + } + + $event->setResponse($this->responses->tooManyRequests($request, $result)); + } + + private function enabledForRequest(?string $testOptIn): bool + { + return 'test' !== $this->environment || '1' === $testOptIn; + } +} diff --git a/src/Security/RateLimit/RateLimitBucketDescriptor.php b/src/Security/RateLimit/RateLimitBucketDescriptor.php new file mode 100644 index 00000000..27ce1c32 --- /dev/null +++ b/src/Security/RateLimit/RateLimitBucketDescriptor.php @@ -0,0 +1,139 @@ + $stages + */ + public function __construct( + private string $name, + private string $bucketFamily, + private int $limit, + private int $windowSeconds, + private string $diagnosticsLabel, + private bool $profileScalable = true, + private ?int $retryAfterFloorSeconds = null, + private bool $resettable = false, + private int $minimumLimit = 1, + private ?RateLimitSubjectPolicy $subjectPolicy = null, + private array $stages = [RateLimitEnforcementStage::All], + ) { + } + + public function name(): string + { + return $this->name; + } + + public function bucketFamily(): string + { + return $this->bucketFamily; + } + + public function limit(): int + { + return $this->limit; + } + + public function windowSeconds(): int + { + return $this->windowSeconds; + } + + public function diagnosticsLabel(): string + { + return $this->diagnosticsLabel; + } + + public function retryAfterFloorSeconds(): ?int + { + return $this->retryAfterFloorSeconds; + } + + public function resettable(): bool + { + return $this->resettable; + } + + public function minimumLimit(): int + { + return $this->minimumLimit; + } + + public function subjectPolicy(): RateLimitSubjectPolicy + { + return $this->subjectPolicy ?? new RateLimitSubjectPolicy([]); + } + + public function handlesStage(RateLimitEnforcementStage $stage): bool + { + return RateLimitEnforcementStage::All === $stage + || in_array(RateLimitEnforcementStage::All, $this->stages, true) + || in_array($stage, $this->stages, true); + } + + public function scaled(RateLimitProfile $profile): self + { + if (!$this->profileScalable || RateLimitProfile::Standard === $profile || RateLimitProfile::Off === $profile) { + return $this; + } + + return new self( + $this->name, + $this->bucketFamily, + max($this->minimumLimit, (int) floor($this->limit * $profile->capacityMultiplier())), + max(1, (int) ceil($this->windowSeconds * $profile->windowMultiplier())), + $this->diagnosticsLabel, + $this->profileScalable, + null === $this->retryAfterFloorSeconds + ? null + : max(1, (int) ceil($this->retryAfterFloorSeconds * $profile->retryAfterMultiplier())), + $this->resettable, + $this->minimumLimit, + $this->subjectPolicy, + $this->stages, + ); + } + + public function withWindowSeconds(int $windowSeconds): self + { + return new self( + $this->name, + $this->bucketFamily, + $this->limit, + max(1, $windowSeconds), + $this->diagnosticsLabel, + $this->profileScalable, + $this->retryAfterFloorSeconds, + $this->resettable, + $this->minimumLimit, + $this->subjectPolicy, + $this->stages, + ); + } + + public function withCapacityMultiplier(int $multiplier): self + { + if ($multiplier <= 1) { + return $this; + } + + return new self( + $this->name, + $this->bucketFamily, + $this->limit * $multiplier, + $this->windowSeconds, + $this->diagnosticsLabel, + $this->profileScalable, + $this->retryAfterFloorSeconds, + $this->resettable, + $this->minimumLimit * $multiplier, + $this->subjectPolicy, + $this->stages, + ); + } +} diff --git a/src/Security/RateLimit/RateLimitCheckResult.php b/src/Security/RateLimit/RateLimitCheckResult.php new file mode 100644 index 00000000..b50e1373 --- /dev/null +++ b/src/Security/RateLimit/RateLimitCheckResult.php @@ -0,0 +1,57 @@ +allowed; + } + + public function suspiciousProbe(): bool + { + return $this->suspiciousProbe; + } + + public function storageDegraded(): bool + { + return $this->storageDegraded; + } + + public function retryAfterSeconds(): ?int + { + return $this->retryAfterSeconds; + } + + public function diagnosticsLabel(): ?string + { + return $this->diagnosticsLabel; + } +} diff --git a/src/Security/RateLimit/RateLimitEnforcementStage.php b/src/Security/RateLimit/RateLimitEnforcementStage.php new file mode 100644 index 00000000..fb9f1f73 --- /dev/null +++ b/src/Security/RateLimit/RateLimitEnforcementStage.php @@ -0,0 +1,18 @@ +inspector->inspect($request); + $profile = $inspection['profile']; + $subjectResolution = $inspection['subjects']; + $cost = $inspection['cost']; + $mode = RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); + + if ($profile->suspiciousProbe()) { + if (!in_array($stage, [RateLimitEnforcementStage::All, RateLimitEnforcementStage::SuspiciousProbe], true)) { + return RateLimitCheckResult::allow(); + } + + return $this->checkSuspiciousProbe($profile, $subjectResolution, $cost, $mode); + } + + if (!$mode->consumesLimiterStorage() || !$cost->ordinaryEnforcement() || $this->isOwnerExempt($request, $profile, $subjectResolution, $cost, $stage)) { + return RateLimitCheckResult::allow(); + } + + return $this->consume($profile, $subjectResolution, $cost, $mode, $stage); + } + + private function checkSuspiciousProbe(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode): RateLimitCheckResult + { + if (!$mode->consumesLimiterStorage()) { + return RateLimitCheckResult::blockSuspiciousProbe(); + } + + $result = $this->consume($profile, $subjects, $cost, $mode, RateLimitEnforcementStage::SuspiciousProbe); + + return RateLimitCheckResult::blockSuspiciousProbe($result->storageDegraded()); + } + + private function consume(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): RateLimitCheckResult + { + try { + $plannedConsumes = []; + $credits = max(1, $cost->credits()); + + foreach ($this->descriptors($profile, $subjects, $cost, $mode, $stage) as $descriptor) { + $descriptor = $descriptor->withCapacityMultiplier($this->subjects->authenticatedMultiplier($descriptor, $subjects)); + + foreach ($this->subjects->subjectKeys($descriptor, $subjects) as $subjectKey) { + $plannedConsumes[] = [$descriptor, $subjectKey, $credits]; + } + } + + foreach ($plannedConsumes as [$descriptor, $subjectKey, $credits]) { + $retryAfter = $this->limiters->accepts($descriptor, $subjectKey, $credits); + if ($retryAfter instanceof \DateTimeImmutable) { + return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); + } + } + + foreach ($plannedConsumes as [$descriptor, $subjectKey, $credits]) { + $retryAfter = $this->limiters->consume($descriptor, $subjectKey, $credits); + if ($retryAfter instanceof \DateTimeImmutable) { + return RateLimitCheckResult::reject($this->retryAfterSeconds($descriptor, $retryAfter), $descriptor->diagnosticsLabel()); + } + } + } catch (\Throwable $exception) { + $this->reportDegradedConsume($profile, $mode, $exception); + + return RateLimitCheckResult::allow(storageDegraded: true); + } + + return RateLimitCheckResult::allow(); + } + + /** + * @return list + */ + private function descriptors(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array + { + $primaryFamily = $this->bucketFamily($cost, $subjects); + $families = []; + $primaryDescriptors = $this->descriptorsForFamily($primaryFamily, $mode, $stage); + + if ($stage->consumesWebsiteFamily() && $this->shouldConsumeWebsiteFamily($profile, $primaryFamily)) { + $families[] = 'website'; + } + + $descriptors = $primaryDescriptors; + foreach (array_values(array_unique($families)) as $family) { + array_push($descriptors, ...$this->descriptorsForFamily($family, $mode, $stage)); + } + + return $descriptors; + } + + /** + * @return list + */ + private function descriptorsForFamily(string $family, RateLimitProfile $mode, RateLimitEnforcementStage $stage): array + { + return array_values(array_filter( + $this->catalogue->descriptorsForFamily($family, $mode), + static fn (RateLimitBucketDescriptor $descriptor): bool => $descriptor->handlesStage($stage), + )); + } + + private function bucketFamily(ActionCost $cost, AbuseSubjectResolution $subjects): string + { + if ('api_read' === $cost->bucketFamily() && !$subjects->first(AbuseSubjectType::ApiKey)) { + return 'api_public_read'; + } + + return $cost->bucketFamily(); + } + + private function shouldConsumeWebsiteFamily(AbuseRequestProfile $profile, string $bucketFamily): bool + { + if (!in_array($profile->family(), [RequestFamily::Browser, RequestFamily::Admin, RequestFamily::Editor], true)) { + return false; + } + + if (RequestIntent::TurboPrefetch === $profile->intent()) { + return false; + } + + return !in_array($bucketFamily, ['website', 'website_prefetch', 'recovery_login'], true); + } + + private function retryAfterSeconds(RateLimitBucketDescriptor $descriptor, \DateTimeImmutable $retryAfter): int + { + $seconds = max(1, $retryAfter->getTimestamp() - time()); + $floor = $descriptor->retryAfterFloorSeconds(); + + return null === $floor ? $seconds : max($seconds, $floor); + } + + private function isOwnerExempt(Request $request, AbuseRequestProfile $profile, AbuseSubjectResolution $subjects, ActionCost $cost, RateLimitEnforcementStage $stage): bool + { + if (RateLimitEnforcementStage::AuthenticationFailure === $stage) { + return false; + } + + if (RequestFamily::Scheduler === $profile->family() || 'scheduler' === $cost->bucketFamily()) { + return false; + } + + if (RequestFamily::Api === $profile->family() && !$this->apiMethods->isSafeEffectiveMethod($request) && $this->readOnlyApiKey($request)) { + return false; + } + + return $this->subjects->hasOwner($subjects); + } + + private function readOnlyApiKey(Request $request): bool + { + return ApiKeyStatus::ReadOnly === ApiRequestContext::fromRequest($request)?->apiKeyStatus(); + } + + private function reportDegradedConsume(AbuseRequestProfile $profile, RateLimitProfile $mode, \Throwable $exception): void + { + $context = [ + 'profile' => $mode->value, + 'intent' => $profile->intent()->value, + 'family' => $profile->family()->value, + 'exception_class' => $exception::class, + ]; + + $this->messages->report( + Message::warning(SecurityMessageCode::RATE_LIMIT_STORAGE_DEGRADED, SecurityMessageKey::RATE_LIMIT_STORAGE_DEGRADED, context: $context), + ['operation' => 'security.rate_limit.consume'], + ); + } +} diff --git a/src/Security/RateLimit/RateLimitLimiterFactory.php b/src/Security/RateLimit/RateLimitLimiterFactory.php new file mode 100644 index 00000000..ab1efca6 --- /dev/null +++ b/src/Security/RateLimit/RateLimitLimiterFactory.php @@ -0,0 +1,62 @@ + */ + private array $factories = []; + + public function __construct( + private readonly CacheItemPoolInterface $cachePool, + private readonly ?LockFactory $lockFactory = null, + ) { + } + + public function consume(RateLimitBucketDescriptor $descriptor, string $subjectKey, int $credits): \DateTimeImmutable|true + { + $limit = $this->factory($descriptor)->create($subjectKey)->consume($credits); + + return $limit->isAccepted() ? true : $limit->getRetryAfter(); + } + + public function accepts(RateLimitBucketDescriptor $descriptor, string $subjectKey, int $credits): \DateTimeImmutable|true + { + $limit = $this->factory($descriptor)->create($subjectKey)->consume(0); + + return $limit->getRemainingTokens() >= $credits ? true : $limit->getRetryAfter(); + } + + public function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): void + { + $this->factory($descriptor)->create($subjectKey)->reset(); + } + + private function factory(RateLimitBucketDescriptor $descriptor): RateLimiterFactory + { + $key = implode('|', [ + $descriptor->name(), + (string) $descriptor->limit(), + (string) $descriptor->windowSeconds(), + ]); + + return $this->factories[$key] ??= new RateLimiterFactory([ + 'id' => implode('.', [ + 'system.rate', + $descriptor->name(), + (string) $descriptor->limit(), + (string) $descriptor->windowSeconds(), + ]), + 'policy' => 'fixed_window', + 'limit' => $descriptor->limit(), + 'interval' => $descriptor->windowSeconds().' seconds', + ], new CacheStorage($this->cachePool), $this->lockFactory); + } +} diff --git a/src/Security/RateLimit/RateLimitPolicyCatalogue.php b/src/Security/RateLimit/RateLimitPolicyCatalogue.php new file mode 100644 index 00000000..7b10e921 --- /dev/null +++ b/src/Security/RateLimit/RateLimitPolicyCatalogue.php @@ -0,0 +1,232 @@ + true, + 'suspicious_probe' => true, + ]; + private const WEBSITE_COMPANION_FAMILIES = [ + 'website_form', + 'registration', + 'password_reset', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + ]; + + /** + * @var array + */ + private array $creditCosts; + + public function __construct(?ActionCostCatalogue $actionCosts = null) + { + $this->creditCosts = ($actionCosts ?? new ActionCostCatalogue())->uniqueCreditsByBucketFamily(); + } + + /** + * @return list + */ + public function descriptors(RateLimitProfile $profile = RateLimitProfile::Standard): array + { + return array_map( + fn (RateLimitBucketDescriptor $descriptor): RateLimitBucketDescriptor => $this->profileDescriptor($descriptor, $profile), + $this->standardDescriptors(), + ); + } + + /** + * @return list + */ + public function descriptorsForFamily(string $bucketFamily, RateLimitProfile $profile = RateLimitProfile::Standard): array + { + return array_values(array_filter( + $this->descriptors($profile), + static fn (RateLimitBucketDescriptor $descriptor): bool => $descriptor->bucketFamily() === $bucketFamily, + )); + } + + public function descriptor(string $name, RateLimitProfile $profile = RateLimitProfile::Standard): ?RateLimitBucketDescriptor + { + foreach ($this->descriptors($profile) as $descriptor) { + if ($descriptor->name() === $name) { + return $descriptor; + } + } + + return null; + } + + /** + * @return list + */ + private function standardDescriptors(): array + { + return [ + $this->bucket('login.failure', 'login', 5, 900, 'security.rate.login', resettable: true), + $this->bucket('recovery.login.minute', 'recovery_login', 2, 60, 'security.rate.recovery_login', false, 1800), + $this->bucket('recovery.login.hour', 'recovery_login', 10, 3600, 'security.rate.recovery_login', false, 1800), + $this->bucket('registration.hour', 'registration', 3, 3600, 'security.rate.registration'), + $this->bucket('registration.day', 'registration', 10, 86400, 'security.rate.registration'), + $this->bucket('password_reset.hour', 'password_reset', 3, 3600, 'security.rate.password_reset'), + $this->bucket('password_reset.day', 'password_reset', 10, 86400, 'security.rate.password_reset'), + $this->bucket('captcha.failure', 'captcha_failure', 5, 600, 'security.rate.captcha_failure', resettable: true), + $this->bucket('website.deliberate.burst', 'website', 30, 60, 'security.rate.website_burst'), + $this->bucket('website.deliberate.sustained', 'website', 300, 1800, 'security.rate.website_sustained'), + $this->bucket('website.form', 'website_form', 5, 600, 'security.rate.website_form'), + $this->bucket('website.prefetch.minute', 'website_prefetch', 120, 60, 'security.rate.prefetch_observation'), + $this->bucket('website.prefetch.sustained', 'website_prefetch', 600, 1800, 'security.rate.prefetch_observation'), + $this->bucket('api.read', 'api_read', 600, 60, 'security.rate.api_read'), + $this->bucket('api.public_read', 'api_public_read', 120, 60, 'security.rate.api_public_read'), + $this->bucket('api.write', 'api_write', 60, 60, 'security.rate.api_write'), + $this->bucket('scheduler.interval', 'scheduler', 1, 60, 'security.rate.scheduler', false), + $this->bucket('setup.apply', 'setup_apply', 5, 900, 'security.rate.setup_apply'), + $this->bucket('admin.mutation', 'admin_mutation', 30, 300, 'security.rate.admin_mutation'), + $this->bucket('upload_archive.validation', 'upload_archive', 20, 600, 'security.rate.upload_archive'), + $this->bucket('download_diagnostics', 'download_diagnostics', 30, 600, 'security.rate.download_diagnostics'), + $this->bucket('suspicious.probe', 'suspicious_probe', 1, 600, 'security.rate.suspicious_probe'), + ]; + } + + private function profileDescriptor(RateLimitBucketDescriptor $descriptor, RateLimitProfile $profile): RateLimitBucketDescriptor + { + if ('scheduler.interval' === $descriptor->name()) { + return match ($profile) { + RateLimitProfile::Strict => $descriptor->withWindowSeconds(900), + RateLimitProfile::Panic => $descriptor->withWindowSeconds(3600), + default => $descriptor, + }; + } + + return $descriptor->scaled($profile); + } + + private function bucket( + string $name, + string $family, + int $actionLimit, + int $windowSeconds, + string $diagnosticsLabel, + bool $profileScalable = true, + ?int $retryAfterFloorSeconds = null, + bool $resettable = false, + ): RateLimitBucketDescriptor { + $cost = $this->creditCostForFamily($family); + $minimumLimit = $this->minimumLimitForFamily($family, $cost); + + return new RateLimitBucketDescriptor( + $name, + $family, + max(1, $actionLimit) * $cost, + $windowSeconds, + $diagnosticsLabel, + $profileScalable, + $retryAfterFloorSeconds, + $resettable, + $minimumLimit, + $this->subjectPolicyForFamily($family), + $this->stagesForFamily($family), + ); + } + + private function creditCostForFamily(string $family): int + { + if ('api_public_read' === $family) { + return max(1, $this->creditCosts['api_read'] ?? 1); + } + + return max(1, $this->creditCosts[$family] ?? 1); + } + + private function minimumLimitForFamily(string $family, int $cost): int + { + $minimumCost = $cost; + if ('website' === $family) { + foreach (self::WEBSITE_COMPANION_FAMILIES as $companion) { + $minimumCost = max($minimumCost, $this->creditCostForFamily($companion)); + } + } + + $actions = isset(self::SINGLE_ACTION_FLOOR_FAMILIES[$family]) ? 1 : self::MIN_ACTIONS_PER_DERIVED_PROFILE; + + return $actions * $minimumCost; + } + + private function subjectPolicyForFamily(string $family): RateLimitSubjectPolicy + { + $apiSubjects = [ + AbuseSubjectType::ApiKey, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, + ]; + $defaultSubjects = [ + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::ApiKey, + AbuseSubjectType::ApiKeyPrefix, + AbuseSubjectType::IpBucket, + ]; + + if ('scheduler' === $family) { + return new RateLimitSubjectPolicy( + [AbuseSubjectType::SchedulerCredential, AbuseSubjectType::IpBucket, AbuseSubjectType::Visitor], + ipSecondary: true, + ipSecondaryWithAuthenticatedSubject: true, + ); + } + + return new RateLimitSubjectPolicy( + in_array($family, ['api_read', 'api_public_read', 'api_write', 'admin_mutation', 'upload_archive', 'download_diagnostics'], true) + ? $apiSubjects + : $defaultSubjects, + submittedAccountScope: in_array($family, ['login', 'recovery_login', 'registration', 'password_reset'], true), + ipSecondary: in_array($family, [ + 'website', + 'website_form', + 'login', + 'recovery_login', + 'registration', + 'password_reset', + 'captcha_failure', + 'setup_apply', + 'suspicious_probe', + 'api_read', + 'api_write', + 'api_public_read', + 'admin_mutation', + 'upload_archive', + 'download_diagnostics', + ], true), + authenticatedMultiplier: in_array($family, ['website', 'api_read', 'api_public_read'], true), + ); + } + + /** + * @return list + */ + private function stagesForFamily(string $family): array + { + return match ($family) { + 'suspicious_probe' => [RateLimitEnforcementStage::SuspiciousProbe], + 'login' => [RateLimitEnforcementStage::AuthenticationFailure], + 'recovery_login' => [RateLimitEnforcementStage::Ordinary], + 'api_read', 'api_public_read', 'api_write', 'admin_mutation', 'upload_archive', 'download_diagnostics' => [ + RateLimitEnforcementStage::Ordinary, + RateLimitEnforcementStage::AuthenticationFailure, + ], + default => [RateLimitEnforcementStage::Ordinary], + }; + } +} diff --git a/src/Security/RateLimit/RateLimitProfile.php b/src/Security/RateLimit/RateLimitProfile.php new file mode 100644 index 00000000..f86d52f5 --- /dev/null +++ b/src/Security/RateLimit/RateLimitProfile.php @@ -0,0 +1,54 @@ + 1.0, + self::Strict => 0.5, + self::Panic => 0.25, + }; + } + + public function windowMultiplier(): float + { + return match ($this) { + self::Off, self::Standard => 1.0, + self::Strict => 1.5, + self::Panic => 2.0, + }; + } + + public function retryAfterMultiplier(): float + { + return match ($this) { + self::Off, self::Standard => 1.0, + self::Strict => 1.5, + self::Panic => 2.0, + }; + } +} diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php new file mode 100644 index 00000000..1e02152c --- /dev/null +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -0,0 +1,124 @@ +probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); + $this->paths = $paths ?? new PathScopeMatcher(); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [ + ['onKernelRequestProbe', 4096], + ['onKernelRequestOrdinary', 3], + ], + ]; + } + + public function onKernelRequestProbe(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { + return; + } + + if (!$this->probePathMatcher->isProbe($request->getPathInfo())) { + return; + } + + $this->enforcer->check($request, RateLimitEnforcementStage::SuspiciousProbe); + + $event->setResponse($this->responses->invalidRequest($request)); + } + + public function onKernelRequestOrdinary(RequestEvent $event): void + { + if (!$event->isMainRequest() || $event->hasResponse()) { + return; + } + + $request = $event->getRequest(); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing')) || $this->excludedRequest($request)) { + return; + } + + $setupCompleted = $this->setupCompleted(); + if (!$setupCompleted && !$this->setupApplyRequest($request)) { + return; + } + + $this->apply($event, RateLimitEnforcementStage::Ordinary, bareResponse: !$setupCompleted); + } + + private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bool $bareResponse = false): void + { + $request = $event->getRequest(); + $result = $this->enforcer->check($request, $stage); + if ($result->isAllowed()) { + return; + } + + if ($bareResponse) { + $event->setResponse($this->responses->bare($request, Response::HTTP_TOO_MANY_REQUESTS, $result->retryAfterSeconds())); + + return; + } + + $event->setResponse($result->suspiciousProbe() + ? $this->responses->suspiciousProbe($request) + : $this->responses->tooManyRequests($request, $result)); + } + + private function excludedRequest(Request $request): bool + { + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live', '/assets', '/build', '/_profiler', '/_wdt') + || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); + } + + private function setupApplyRequest(Request $request): bool + { + return 'POST' === strtoupper($request->getMethod()) + && $this->paths->matchesExactSegments($request->getPathInfo(), 'setup', 'review') + && 'apply' === (string) $request->request->get('_setup_action', ''); + } + + private function enabledForRequest(?string $testOptIn): bool + { + return 'test' !== $this->environment || '1' === $testOptIn; + } + + private function setupCompleted(): bool + { + return $this->setupCompletionMarker->isComplete($this->projectDir, $this->environment); + } +} diff --git a/src/Security/RateLimit/RateLimitResetService.php b/src/Security/RateLimit/RateLimitResetService.php new file mode 100644 index 00000000..b2be8f27 --- /dev/null +++ b/src/Security/RateLimit/RateLimitResetService.php @@ -0,0 +1,98 @@ +profile(); + $descriptor = $this->catalogue->descriptor('login.failure', $profile); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable() || !$profile->consumesLimiterStorage()) { + return false; + } + + $subjectResolution = $this->inspector->inspect($request)['subjects']; + $reset = false; + + foreach ($this->subjects->subjectKeys($descriptor, $subjectResolution) as $subjectKey) { + $reset = $this->reset($descriptor, $subjectKey) || $reset; + } + + return $reset; + } + + public function resetVerifiedCaptchaFailure(Request $request, ?string $provider, bool $verified): bool + { + $provider = is_string($provider) ? trim($provider) : ''; + $profile = $this->profile(); + if (!$verified || '' === $provider || 'none' === strtolower($provider) || !$profile->consumesLimiterStorage()) { + return false; + } + + $descriptor = $this->catalogue->descriptor('captcha.failure', $profile); + if (!$descriptor instanceof RateLimitBucketDescriptor || !$descriptor->resettable()) { + return false; + } + + $subjectResolution = $this->inspector->inspect($request)['subjects']; + $reset = false; + + foreach ($this->subjects->subjectKeys($descriptor, $subjectResolution) as $subjectKey) { + $reset = $this->reset($descriptor, $subjectKey) || $reset; + } + + return $reset; + } + + private function profile(): RateLimitProfile + { + return RateLimitProfile::fromMixed($this->config->get(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Standard->value)); + } + + private function reset(RateLimitBucketDescriptor $descriptor, string $subjectKey): bool + { + try { + $this->limiters->reset($descriptor, $subjectKey); + + return true; + } catch (\Throwable $exception) { + $this->reportDegradedReset($descriptor, $exception); + + return false; + } + } + + private function reportDegradedReset(RateLimitBucketDescriptor $descriptor, \Throwable $exception): void + { + $context = [ + 'bucket' => $descriptor->diagnosticsLabel(), + 'exception_class' => $exception::class, + ]; + + $this->messages->report( + Message::warning(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, SecurityMessageKey::RATE_LIMIT_RESET_DEGRADED, context: $context), + ['operation' => 'security.rate_limit.reset'], + ); + } +} diff --git a/src/Security/RateLimit/RateLimitResponseRenderer.php b/src/Security/RateLimit/RateLimitResponseRenderer.php new file mode 100644 index 00000000..5437825e --- /dev/null +++ b/src/Security/RateLimit/RateLimitResponseRenderer.php @@ -0,0 +1,105 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public function tooManyRequests(Request $request, RateLimitCheckResult $result): Response + { + $response = $this->jsonSurface($request) + ? $this->apiResponse($request, Response::HTTP_TOO_MANY_REQUESTS) + : $this->httpError->resolve(Response::HTTP_TOO_MANY_REQUESTS, $request, context: $this->context($request)); + + if (null !== $result->retryAfterSeconds()) { + $response->headers->set('Retry-After', (string) $result->retryAfterSeconds()); + } + + return $this->noStore($response); + } + + public function suspiciousProbe(Request $request): Response + { + $response = $this->jsonSurface($request) + ? $this->apiResponse($request, Response::HTTP_BAD_REQUEST) + : $this->httpError->resolve(Response::HTTP_BAD_REQUEST, $request, context: $this->context($request)); + + return $this->noStore($response); + } + + public function invalidRequest(Request $request): Response + { + return $this->httpError->bare(Response::HTTP_BAD_REQUEST, $request, $this->context($request) + [ + 'bare_context' => 'Invalid Request', + ]); + } + + public function bare(Request $request, int $status, ?int $retryAfterSeconds = null): Response + { + $headers = []; + $context = $this->context($request); + if (null !== $retryAfterSeconds) { + $headers['Retry-After'] = (string) $retryAfterSeconds; + $context['bare_context'] = 'retry-after: '.$retryAfterSeconds; + } + + return $this->httpError->bare($status, $request, $context, $headers); + } + + private function apiResponse(Request $request, int $status): Response + { + $message = Response::HTTP_TOO_MANY_REQUESTS === $status + ? Message::warning(SecurityMessageCode::RATE_LIMIT_EXCEEDED, SecurityMessageKey::RATE_LIMIT_EXCEEDED) + : Message::warning(SecurityMessageCode::RATE_LIMIT_REQUEST_REJECTED, SecurityMessageKey::RATE_LIMIT_REQUEST_REJECTED); + + return $this->apiResponder->error( + $message, + $status, + $request, + $this->context($request), + ); + } + + /** + * @return array{request_id: string} + */ + private function context(Request $request): array + { + return ['request_id' => $this->requestMetadata->requestId($request)]; + } + + private function noStore(Response $response): Response + { + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } + + private function jsonSurface(Request $request): bool + { + return $this->paths->matchesSegments($request->getPathInfo(), 'api', 'v1') + || $this->paths->matchesSegments($request->getPathInfo(), 'cron'); + } +} diff --git a/src/Security/RateLimit/RateLimitSubjectPolicy.php b/src/Security/RateLimit/RateLimitSubjectPolicy.php new file mode 100644 index 00000000..49ad7fa7 --- /dev/null +++ b/src/Security/RateLimit/RateLimitSubjectPolicy.php @@ -0,0 +1,50 @@ + $preferredTypes + */ + public function __construct( + private array $preferredTypes, + private bool $submittedAccountScope = false, + private bool $ipSecondary = false, + private bool $ipSecondaryWithAuthenticatedSubject = false, + private bool $authenticatedMultiplier = false, + ) { + } + + /** + * @return list + */ + public function preferredTypes(): array + { + return $this->preferredTypes; + } + + public function submittedAccountScope(): bool + { + return $this->submittedAccountScope; + } + + public function ipSecondary(): bool + { + return $this->ipSecondary; + } + + public function ipSecondaryWithAuthenticatedSubject(): bool + { + return $this->ipSecondaryWithAuthenticatedSubject; + } + + public function authenticatedMultiplier(): bool + { + return $this->authenticatedMultiplier; + } +} diff --git a/src/Security/RateLimit/RateLimitSubjectSelector.php b/src/Security/RateLimit/RateLimitSubjectSelector.php new file mode 100644 index 00000000..8f8dea71 --- /dev/null +++ b/src/Security/RateLimit/RateLimitSubjectSelector.php @@ -0,0 +1,119 @@ + + */ + public function subjectKeys(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): array + { + if ($this->usesSubmittedAccountScope($descriptor)) { + $submittedAccount = $subjects->first(AbuseSubjectType::SubmittedAccount); + if ($submittedAccount instanceof AbuseSubject) { + return $this->subjectKeysFor($descriptor, array_filter([ + $subjects->first(AbuseSubjectType::Visitor), + $subjects->first(AbuseSubjectType::IpBucket), + $submittedAccount, + ])); + } + } + + $primary = $this->primarySubject($descriptor, $subjects); + if (!$primary instanceof AbuseSubject) { + return []; + } + + $keys = [$this->subjectKey($descriptor, $primary)]; + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + if ($ipBucket instanceof AbuseSubject && $this->includeIpSecondary($descriptor, $subjects)) { + $keys[] = $this->subjectKey($descriptor, $ipBucket); + } + + return array_values(array_unique($keys)); + } + + public function hasOwner(AbuseSubjectResolution $subjects): bool + { + $user = $subjects->first(AbuseSubjectType::User); + + return $user instanceof AbuseSubject + && (int) ($user->context()['access_level'] ?? AccessLevel::PUBLIC) >= AccessLevel::OWNER; + } + + public function authenticatedMultiplier(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): int + { + if ($this->hasOwner($subjects) || !$subjects->first(AbuseSubjectType::User) instanceof AbuseSubject) { + return 1; + } + + return $descriptor->subjectPolicy()->authenticatedMultiplier() + ? RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER + : 1; + } + + private function primarySubject(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): ?AbuseSubject + { + foreach ($this->preferredTypes($descriptor) as $type) { + $subject = $subjects->first($type); + if ($subject instanceof AbuseSubject) { + return $subject; + } + } + + return $subjects->primary(); + } + + /** + * @return list + */ + private function preferredTypes(RateLimitBucketDescriptor $descriptor): array + { + return $descriptor->subjectPolicy()->preferredTypes(); + } + + private function includeIpSecondary(RateLimitBucketDescriptor $descriptor, AbuseSubjectResolution $subjects): bool + { + $policy = $descriptor->subjectPolicy(); + if (!$policy->ipSecondary()) { + return false; + } + + return $policy->ipSecondaryWithAuthenticatedSubject() + || (!$subjects->first(AbuseSubjectType::User) instanceof AbuseSubject && !$subjects->first(AbuseSubjectType::ApiKey) instanceof AbuseSubject); + } + + private function usesSubmittedAccountScope(RateLimitBucketDescriptor $descriptor): bool + { + return $descriptor->subjectPolicy()->submittedAccountScope(); + } + + public function subjectKey(RateLimitBucketDescriptor $descriptor, AbuseSubject $subject): string + { + return $descriptor->name().':'.$subject->type()->value.':'.$subject->identifier(); + } + + /** + * @param iterable $subjects + * + * @return list + */ + private function subjectKeysFor(RateLimitBucketDescriptor $descriptor, iterable $subjects): array + { + $keys = []; + foreach ($subjects as $subject) { + $keys[] = $this->subjectKey($descriptor, $subject); + } + + return array_values(array_unique($keys)); + } +} diff --git a/src/Security/SecurityMessageCode.php b/src/Security/SecurityMessageCode.php index 41002052..b413a0ea 100644 --- a/src/Security/SecurityMessageCode.php +++ b/src/Security/SecurityMessageCode.php @@ -21,4 +21,8 @@ final class SecurityMessageCode public const API_KEY_PERMISSION_DENIED = 'api_key.permission_denied'; public const API_KEY_PERMISSION_WRITE_REQUIRED = 'api_key.permission_write_required'; public const API_KEY_PERMISSION_REVOKED = 'api_key.permission_revoked'; + public const RATE_LIMIT_EXCEEDED = 'rate_limit.exceeded'; + public const RATE_LIMIT_REQUEST_REJECTED = 'rate_limit.request_rejected'; + public const RATE_LIMIT_STORAGE_DEGRADED = 'rate_limit.storage_degraded'; + public const RATE_LIMIT_RESET_DEGRADED = 'rate_limit.reset_degraded'; } diff --git a/src/Security/SecurityMessageKey.php b/src/Security/SecurityMessageKey.php index fdbf4042..0cbc8ff8 100644 --- a/src/Security/SecurityMessageKey.php +++ b/src/Security/SecurityMessageKey.php @@ -39,4 +39,8 @@ final class SecurityMessageKey public const API_KEY_PERMISSION_DENIED = 'message.api_key.permission.denied'; public const API_KEY_PERMISSION_WRITE_REQUIRED = 'message.api_key.permission.write_required'; public const API_KEY_PERMISSION_REVOKED = 'message.api_key.permission.revoked'; + public const RATE_LIMIT_EXCEEDED = 'message.rate_limit.exceeded'; + public const RATE_LIMIT_REQUEST_REJECTED = 'message.rate_limit.request_rejected'; + public const RATE_LIMIT_STORAGE_DEGRADED = 'message.rate_limit.storage_degraded'; + public const RATE_LIMIT_RESET_DEGRADED = 'message.rate_limit.reset_degraded'; } diff --git a/src/Security/SessionVisitorBindingSubscriber.php b/src/Security/SessionVisitorBindingSubscriber.php index fbeb3e70..139eccf4 100644 --- a/src/Security/SessionVisitorBindingSubscriber.php +++ b/src/Security/SessionVisitorBindingSubscriber.php @@ -5,9 +5,13 @@ namespace App\Security; use App\Core\Access\AccessActor; +use App\Core\Log\AccessRequestMetadata; use App\Core\Log\AuditLoggerInterface; use App\Core\Statistics\VisitorIdGenerator; use App\Entity\UserAccount; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\Abuse\SecuritySignalRecorder; use DateTimeImmutable; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; @@ -34,6 +38,9 @@ public function __construct( private TokenStorageInterface $tokenStorage, private VisitorIdGenerator $visitorIdGenerator, private AuditLoggerInterface $auditLogger, + private ?AbuseRequestInspector $abuseInspector = null, + private ?SecuritySignalRecorder $securitySignals = null, + private ?AccessRequestMetadata $accessRequestMetadata = null, ) { } @@ -101,6 +108,7 @@ public function onKernelRequest(RequestEvent $event): void 'change_count' => $changeCount, 'changed_at' => $changedAt, ]); + $this->safeSignal($request, $boundVisitorId, $currentVisitorId, $changeCount, $changedAt); $this->tokenStorage->setToken(null); $session->invalidate(); $event->setResponse(new RedirectResponse(self::LOGIN_PATH, Response::HTTP_SEE_OTHER)); @@ -126,6 +134,59 @@ private function safeAudit(UserInterface $user, array $context): void } } + private function safeSignal( + Request $request, + string $previousVisitorId, + string $currentVisitorId, + int $changeCount, + string $changedAt, + ): void { + if (null === $this->abuseInspector || null === $this->securitySignals) { + return; + } + + try { + $inspection = $this->abuseInspector->inspect($request); + $profile = $inspection['profile']; + $subjects = $inspection['subjects']; + $subject = $subjects->first(AbuseSubjectType::User) + ?? $subjects->first(AbuseSubjectType::Visitor) + ?? $subjects->primary(); + + if (null === $subject) { + return; + } + + $visitor = $subjects->first(AbuseSubjectType::Visitor); + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + $this->securitySignals->record( + 'session', + 'security.signal.session_visitor_mismatch', + $subject->type()->value, + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'ERROR', + confidence: 90, + requestFamily: $profile->family()->value, + requestIntent: $profile->intent()->value, + requestId: $this->accessRequestMetadata?->requestId($request) ?? 'n/a', + visitorId: $visitor?->identifier() ?? $currentVisitorId, + path: $this->accessRequestMetadata?->sanitizedPath($request) ?? $profile->path(), + route: $profile->route(), + context: [ + 'previous_visitor_id' => $previousVisitorId, + 'current_visitor_id' => $currentVisitorId, + 'change_count' => $changeCount, + 'changed_at' => $changedAt, + 'ip_bucket' => $ipBucket?->identifier(), + ], + ); + } catch (Throwable) { + return; + } + } + private function actorFromUser(UserInterface $user): AccessActor { if ($user instanceof UserAccount) { diff --git a/src/Setup/SetupConfigSeeder.php b/src/Setup/SetupConfigSeeder.php index 6d92ad07..6023d9a0 100644 --- a/src/Setup/SetupConfigSeeder.php +++ b/src/Setup/SetupConfigSeeder.php @@ -29,7 +29,7 @@ public function seed(string $projectDir, SetupInput $input, string $databaseUrl) foreach ($settings as $setting) { $key = $setting['key']; - if (!$config->set($key, $setting['value'], $setting['type'], modifiedBy: 'setup')) { + if (!$config->set($key, $setting['value'], $setting['type'], sensitive: true === ($setting['sensitive'] ?? false), modifiedBy: 'setup')) { throw SetupStepFailedException::fromMessage(Message::error( ConfigMessageCode::CONFIG_WRITE_FAILED, ConfigMessageKey::CONFIG_WRITE_FAILED, diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 513859a3..bee9453d 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -6,13 +6,18 @@ use App\Api\ApiFeaturePolicy; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\ConfigDefaultProviderInterface; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; +use App\Core\Log\DatabaseLogRetentionPolicy; use App\Content\Routing\ContentRouteLocalization; use App\Core\Statistics\AccessStatisticsPolicy; use App\Localization\LocaleToken; use App\Scheduler\SchedulerSettings; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\UserFlowConfig; final readonly class SetupDefaultSeed @@ -22,7 +27,7 @@ public function __construct(private ?ConfigDefaultProviderInterface $configDefau } /** - * @return list + * @return list */ public function configEntries(SetupInput $input): array { @@ -44,8 +49,16 @@ public function configEntries(SetupInput $input): array ['key' => UserFlowConfig::REGISTRATION_MODE_KEY, 'value' => $this->setting($input, UserFlowConfig::REGISTRATION_MODE_KEY, UserFlowConfig::REGISTRATION_DISABLED), 'type' => ConfigValueType::String], ['key' => ConfigAuditLogPolicy::ENABLED_KEY, 'value' => $this->setting($input, ConfigAuditLogPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => ConfigAuditLogPolicy::EVENTS_KEY, 'value' => $this->setting($input, ConfigAuditLogPolicy::EVENTS_KEY, ConfigAuditLogPolicy::DEFAULT_CATEGORIES), 'type' => ConfigValueType::Json], + ['key' => DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS), 'type' => ConfigValueType::Integer], + ['key' => SuspiciousProbePathMatcher::PATTERNS_KEY, 'value' => $this->setting($input, SuspiciousProbePathMatcher::PATTERNS_KEY, SuspiciousProbePathMatcher::defaultPatternText()), 'type' => ConfigValueType::String], ['key' => AccessStatisticsPolicy::ENABLED_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, 'value' => $this->setting($input, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, true), 'type' => ConfigValueType::Boolean], + ['key' => MaxMindGeoIpConfig::ENABLED_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], + ['key' => MaxMindGeoIpConfig::DATABASE_PATH_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::DATABASE_PATH_KEY, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH), 'type' => ConfigValueType::String], + ['key' => MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'value' => $this->setting($input, MaxMindGeoIpConfig::LICENSE_KEY_KEY, ''), 'type' => ConfigValueType::String, 'sensitive' => true], ['key' => ApiFeaturePolicy::ENABLED_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::ENABLED_KEY, true), 'type' => ConfigValueType::Boolean], ['key' => ApiFeaturePolicy::CORS_ENABLED_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::CORS_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, 'value' => $this->setting($input, ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, []), 'type' => ConfigValueType::Json], @@ -53,6 +66,7 @@ public function configEntries(SetupInput $input): array ['key' => SchedulerSettings::GET_AUTH_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::GET_AUTH_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], ['key' => SchedulerSettings::WEB_TRIGGER_ENABLED_KEY, 'value' => $this->setting($input, SchedulerSettings::WEB_TRIGGER_ENABLED_KEY, false), 'type' => ConfigValueType::Boolean], + ['key' => AdminFeatureOverrideStore::CONFIG_KEY, 'value' => $this->setting($input, AdminFeatureOverrideStore::CONFIG_KEY, (new AdminFeatureDefaults())->overrides()), 'type' => ConfigValueType::Json], ]; } diff --git a/src/Setup/SetupSiteSettings.php b/src/Setup/SetupSiteSettings.php index 182b0437..73479bc1 100644 --- a/src/Setup/SetupSiteSettings.php +++ b/src/Setup/SetupSiteSettings.php @@ -27,9 +27,9 @@ public function fields(): array ConfigValueType::String, FormInputType::Select, options: [ - UserFlowConfig::REGISTRATION_DISABLED => 'admin.settings.options.registration.disabled', - UserFlowConfig::REGISTRATION_ADMIN_APPROVAL => 'admin.settings.options.registration.admin_approval', - UserFlowConfig::REGISTRATION_AUTO_APPROVAL => 'admin.settings.options.registration.auto_approval', + UserFlowConfig::REGISTRATION_DISABLED => 'setup.form.registration_mode.options.disabled', + UserFlowConfig::REGISTRATION_ADMIN_APPROVAL => 'setup.form.registration_mode.options.admin_approval', + UserFlowConfig::REGISTRATION_AUTO_APPROVAL => 'setup.form.registration_mode.options.auto_approval', ], validation: ['required' => true], metadata: ['config_key' => UserFlowConfig::REGISTRATION_MODE_KEY], diff --git a/src/View/Http/HttpErrorRenderer.php b/src/View/Http/HttpErrorRenderer.php index cfd862f0..0f6a1c2c 100644 --- a/src/View/Http/HttpErrorRenderer.php +++ b/src/View/Http/HttpErrorRenderer.php @@ -7,6 +7,8 @@ use App\Content\Read\PublishedContentResolver; use App\Content\Render\ContentFieldsetRenderer; use App\Core\Access\AccessActor; +use App\Core\Log\AccessRequestMetadata; +use App\Setup\SetupCompletionMarker; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,35 +23,52 @@ public function __construct( private PublishedContentResolver $contentResolver, private ContentFieldsetRenderer $fieldsetRenderer, private Security $security, + private SetupCompletionMarker $setupCompletionMarker, + private AccessRequestMetadata $requestMetadata, + private string $projectDir, + private string $environment, private bool $debug = false, ) { } public function notFound(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_NOT_FOUND, $request, $exception); + return $this->resolve(Response::HTTP_NOT_FOUND, $request, exception: $exception); } public function unauthorized(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_UNAUTHORIZED, $request, $exception); + return $this->resolve(Response::HTTP_UNAUTHORIZED, $request, exception: $exception); } public function forbidden(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_FORBIDDEN, $request, $exception); + return $this->resolve(Response::HTTP_FORBIDDEN, $request, exception: $exception); } public function maintenance(Request $request, ?Throwable $exception = null): Response { - return $this->render(Response::HTTP_SERVICE_UNAVAILABLE, $request, $exception); + return $this->resolve(Response::HTTP_SERVICE_UNAVAILABLE, $request, exception: $exception); } /** * @param array $context + * @param array $headers */ - public function render(int $statusCode, Request $request, ?Throwable $exception = null, array $context = []): Response + public function bare(int $statusCode, ?Request $request = null, array $context = [], array $headers = []): Response { + return $this->bareResponse($statusCode, $request, $context, $headers); + } + + /** + * @param array $context + */ + public function resolve(int $statusCode, Request $request, array $context = [], ?Throwable $exception = null, bool $forceBare = false): Response + { + if ($forceBare || $this->preSetupBareStatus($statusCode)) { + return $this->bareResponse($statusCode, $request, $context); + } + $variables = $this->variables($statusCode, $request, $exception, $context); if (Response::HTTP_UNAUTHORIZED === $statusCode && !$this->isAuthenticated()) { @@ -118,7 +137,87 @@ private function renderSystemErrorContent(int $statusCode, Request $request, arr */ private function renderTemplate(string $template, array $variables, int $statusCode): Response { - return new Response($this->twig->render($template, $variables), $statusCode); + $response = new Response($this->twig->render($template, $variables), $statusCode); + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } + + private function preSetupBareStatus(int $statusCode): bool + { + return $this->knownErrorStatus($statusCode) + && !$this->setupCompletionMarker->isComplete($this->projectDir, $this->environment); + } + + /** + * @param array $context + * @param array $headers + */ + private function bareResponse(int $statusCode, ?Request $request = null, array $context = [], array $headers = []): Response + { + return new Response($this->bareHtml($statusCode, $request, $context), $statusCode, [ + ...$headers, + 'Cache-Control' => 'no-store', + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + } + + /** + * @param array $context + */ + private function bareHtml(int $statusCode, ?Request $request, array $context): string + { + $statusText = Response::$statusTexts[$statusCode] ?? 'HTTP Error'; + $contextText = $this->bareContextText($context); + $contextHtml = null === $contextText ? '' : "\n

".$this->escape($contextText).'

'; + + return '' + ."\n".'' + ."\n".'

'.$statusCode.' - '.$this->escape($statusText).'

' + .$contextHtml + ."\n".'
Request-ID: '.$this->escape($this->bareRequestId($request, $context)).'
'; + } + + /** + * @param array $context + */ + private function bareContextText(array $context): ?string + { + $value = $context['bare_context'] ?? null; + if (!is_scalar($value)) { + return null; + } + + $text = trim((string) $value); + + return '' === $text ? null : substr($text, 0, 500); + } + + /** + * @param array $context + */ + private function bareRequestId(?Request $request, array $context): string + { + if ($request instanceof Request) { + return $this->requestMetadata->requestId($request); + } + + $contextRequestId = $context['request_id'] ?? null; + if (is_scalar($contextRequestId) && '' !== trim((string) $contextRequestId)) { + return substr((string) $contextRequestId, 0, 64); + } + + return 'n/a'; + } + + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function knownErrorStatus(int $statusCode): bool + { + return $statusCode >= 400 && $statusCode < 600 && isset(Response::$statusTexts[$statusCode]); } /** diff --git a/src/View/Http/HttpErrorSubscriber.php b/src/View/Http/HttpErrorSubscriber.php index 0341cb52..d6cbd651 100644 --- a/src/View/Http/HttpErrorSubscriber.php +++ b/src/View/Http/HttpErrorSubscriber.php @@ -36,7 +36,7 @@ public function onKernelException(ExceptionEvent $event): void return; } - $response = $this->renderer->render($exception->getStatusCode(), $event->getRequest(), $exception); + $response = $this->renderer->resolve($exception->getStatusCode(), $event->getRequest(), exception: $exception); $response->headers->add($exception->getHeaders()); $event->setResponse($response); } diff --git a/src/View/Twig/AdminSettingsFormViewFactory.php b/src/View/Twig/AdminSettingsFormViewFactory.php new file mode 100644 index 00000000..95ce925f --- /dev/null +++ b/src/View/Twig/AdminSettingsFormViewFactory.php @@ -0,0 +1,288 @@ + + */ + public function coreSettingsForm(string $section): array + { + $definitions = $this->coreSettingDefinitions($section); + $mutableDefinitions = array_values(array_filter( + $definitions, + fn (CoreSettingDefinition $definition): bool => $this->coreSettingMutable($definition, $this->actor()), + )); + $values = []; + $request = $this->requestStack->getCurrentRequest(); + + foreach ($definitions as $definition) { + $value = $this->config->get($definition->key()) ?? $definition->defaultValue(); + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $value = ''; + } + + $values[$definition->key()] = $value; + } + + $values = array_replace($values, $this->requestFormValues($request, $this->sensitiveDefinitionKeys($definitions))); + $errors = $this->requestFormErrors($request); + + return $this->formBuilder->build( + 'admin-settings-'.$section, + 'admin.settings.'.$section.'.title', + array_map(static fn ($definition) => $definition->formField(), $definitions), + $values, + $errors, + $errors['__form'] ?? [], + metadata: [ + 'read_only' => [] === $mutableDefinitions, + ], + )->toArray(); + } + + /** + * @return list> + */ + public function packageSettings(string $packageName): array + { + if (!$this->packageFeatureVisible($packageName)) { + return []; + } + + return $this->packageSettings->viewRows($packageName, $this->packageSettingRegistry); + } + + public function packageSetting(string $packageName, string $key, mixed $default = null): mixed + { + return $this->packageSettings->get($packageName, $key, $default); + } + + /** + * @return array + */ + public function packageSettingsForm(string $packageName): array + { + $request = $this->requestStack->getCurrentRequest(); + $errors = $this->requestFormErrors($request); + $fields = $this->packageSettings->formFields($packageName, $this->packageSettingRegistry); + $mutable = $this->packageFeatureMutable($packageName); + $sensitiveKeys = $this->sensitiveFieldKeys($fields); + $values = array_replace( + array_fill_keys($sensitiveKeys, ''), + $this->requestFormValues($request, $sensitiveKeys), + ); + + $form = $this->formBuilder->build( + 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), + $packageName, + $fields, + $values, + $errors, + $errors['__form'] ?? [], + metadata: [ + 'read_only' => !$mutable, + ], + )->toArray(); + + if (!$mutable) { + foreach ($form['fields'] as $index => $field) { + if (is_array($field)) { + $metadata = is_array($field['metadata'] ?? null) ? $field['metadata'] : []; + $form['fields'][$index]['metadata'] = [...$metadata, 'disabled' => true]; + } + } + } + + return $form; + } + + /** + * @return list + */ + public function packageSettingPackages(): array + { + $packages = []; + + foreach ($this->packageSettingRegistry->packagesWithDefinitions() as $packageName => $metadata) { + if (!$this->packageFeatureVisible($packageName)) { + continue; + } + + $packages[] = [ + 'package_name' => $packageName, + 'label' => $metadata['label'], + 'description' => $metadata['description'], + 'path' => $metadata['path'], + ]; + } + + return $packages; + } + + /** + * @return list + */ + private function coreSettingDefinitions(string $section): array + { + $actor = $this->actor(); + + return array_values(array_filter( + array_map( + fn (CoreSettingDefinition $definition): CoreSettingDefinition => $this->decorateCoreSettingDefinition($definition, $actor), + $this->coreSettingsRegistry->definitions($section), + ), + fn (CoreSettingDefinition $definition): bool => $this->coreSettingVisible($definition, $actor), + )); + } + + private function decorateCoreSettingDefinition(CoreSettingDefinition $definition, AccessActor $actor): CoreSettingDefinition + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (!is_string($feature) || null === $this->adminAcl) { + return $definition; + } + + $state = $this->adminAcl->state($feature, $actor); + + return $definition->withMetadata([ + 'access_state' => $state->value, + 'disabled' => !$state->isMutable(), + ]); + } + + private function coreSettingVisible(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isVisible($feature, $actor); + } + + return $definition->allows($actor); + } + + private function coreSettingMutable(CoreSettingDefinition $definition, AccessActor $actor): bool + { + $feature = $definition->metadata()['access_feature'] ?? null; + + if (is_string($feature) && null !== $this->adminAcl) { + return $this->adminAcl->isMutable($feature, $actor); + } + + return $definition->allows($actor); + } + + private function packageFeatureVisible(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isVisible('admin.settings.packages.'.$packageName, $this->actor()); + } + + private function packageFeatureMutable(string $packageName): bool + { + return null === $this->adminAcl || $this->adminAcl->isMutable('admin.settings.packages.'.$packageName, $this->actor()); + } + + private function actor(): AccessActor + { + $user = $this->security->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + + /** + * @param list $excludedKeys + * + * @return array + */ + private function requestFormValues(?Request $request, array $excludedKeys = []): array + { + $values = $request?->attributes->get('_system_form_values'); + + if (!is_array($values)) { + return []; + } + + foreach ($excludedKeys as $key) { + unset($values[$key]); + } + + return $values; + } + + /** + * @return array> + */ + private function requestFormErrors(?Request $request): array + { + $errors = $request?->attributes->get('_system_form_errors'); + + return is_array($errors) ? $errors : []; + } + + /** + * @param iterable $definitions + * + * @return list + */ + private function sensitiveDefinitionKeys(iterable $definitions): array + { + $keys = []; + + foreach ($definitions as $definition) { + if (true === ($definition->metadata()['sensitive'] ?? false)) { + $keys[] = $definition->key(); + } + } + + return $keys; + } + + /** + * @param iterable $fields + * + * @return list + */ + private function sensitiveFieldKeys(iterable $fields): array + { + $keys = []; + + foreach ($fields as $field) { + if (true === ($field->metadata()['sensitive'] ?? false)) { + $keys[] = $field->name(); + } + } + + return $keys; + } +} diff --git a/src/View/Twig/AdminViewTwigExtension.php b/src/View/Twig/AdminViewTwigExtension.php index 0c89e7c2..ecf43144 100644 --- a/src/View/Twig/AdminViewTwigExtension.php +++ b/src/View/Twig/AdminViewTwigExtension.php @@ -5,16 +5,15 @@ namespace App\View\Twig; use App\Backend\BackendActions; +use App\Core\Access\AccessActor; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; -use App\Core\Config\Settings\CoreSettingsRegistry; use App\Core\Package\PackageAdminOverview; -use App\Core\Package\Settings\PackageSettingRegistry; -use App\Core\Package\Settings\PackageSettings; use App\Core\Package\ThemeAdminOverview; -use App\Form\FormBuilder; +use App\Entity\UserAccount; use App\View\SystemPackageMetadataProvider; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Bundle\SecurityBundle\Security; use Throwable; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -23,15 +22,13 @@ final class AdminViewTwigExtension extends AbstractExtension { public function __construct( private readonly Config $config, - private readonly CoreSettingsRegistry $coreSettingsRegistry, - private readonly FormBuilder $formBuilder, + private readonly AdminSettingsFormViewFactory $settingsForms, private readonly BackendActions $backendActions, private readonly PackageAdminOverview $packageAdminOverview, private readonly ThemeAdminOverview $themeAdminOverview, - private readonly PackageSettings $packageSettings, - private readonly PackageSettingRegistry $packageSettingRegistry, private readonly SystemPackageMetadataProvider $systemPackageMetadata, - private readonly RequestStack $requestStack, + private readonly Security $security, + private readonly ?AdminFeatureAccessPolicy $adminAcl = null, ) { } @@ -50,6 +47,9 @@ public function getFunctions(): array new TwigFunction('package_settings', $this->packageSettings(...)), new TwigFunction('package_settings_form', $this->packageSettingsForm(...)), new TwigFunction('package_setting_packages', $this->packageSettingPackages(...)), + new TwigFunction('admin_feature_state', $this->adminFeatureState(...)), + new TwigFunction('admin_feature_visible', $this->adminFeatureVisible(...)), + new TwigFunction('admin_feature_mutable', $this->adminFeatureMutable(...)), ]; } @@ -68,7 +68,34 @@ public function extensionPackages(): array */ public function backendActions(array $ids = []): array { - return $this->backendActions->definitions($ids); + return $this->backendActions->definitions($ids, $this->actor()); + } + + public function adminFeatureState(string $feature): string + { + if (null === $this->adminAcl) { + return AdminPermissionState::Mutable->value; + } + + return $this->adminAcl->state($feature, $this->actor())->value; + } + + public function adminFeatureVisible(string $feature): bool + { + if (null === $this->adminAcl) { + return true; + } + + return $this->adminAcl->isVisible($feature, $this->actor()); + } + + public function adminFeatureMutable(string $feature): bool + { + if (null === $this->adminAcl) { + return true; + } + + return $this->adminAcl->isMutable($feature, $this->actor()); } /** @@ -84,12 +111,12 @@ public function themes(): array */ public function packageSettings(string $packageName): array { - return $this->packageSettings->viewRows($packageName, $this->packageSettingRegistry); + return $this->settingsForms->packageSettings($packageName); } public function packageSetting(string $packageName, string $key, mixed $default = null): mixed { - return $this->packageSettings->get($packageName, $key, $default); + return $this->settingsForms->packageSetting($packageName, $key, $default); } public function footerCopyright(string $area = 'frontend'): string @@ -114,25 +141,7 @@ public function footerCopyright(string $area = 'frontend'): string */ public function coreSettingsForm(string $section): array { - $definitions = $this->coreSettingsRegistry->definitions($section); - $values = []; - $request = $this->requestStack->getCurrentRequest(); - - foreach ($definitions as $definition) { - $values[$definition->key()] = $this->config->get($definition->key()) ?? $definition->defaultValue(); - } - - $values = array_replace($values, $this->requestFormValues($request)); - $errors = $this->requestFormErrors($request); - - return $this->formBuilder->build( - 'admin-settings-'.$section, - 'admin.settings.'.$section.'.title', - array_map(static fn ($definition) => $definition->formField(), $definitions), - $values, - $errors, - $errors['__form'] ?? [], - )->toArray(); + return $this->settingsForms->coreSettingsForm($section); } /** @@ -140,17 +149,7 @@ public function coreSettingsForm(string $section): array */ public function packageSettingsForm(string $packageName): array { - $request = $this->requestStack->getCurrentRequest(); - $errors = $this->requestFormErrors($request); - - return $this->formBuilder->build( - 'package-settings-'.preg_replace('/[^a-z0-9_]+/', '_', strtolower($packageName)), - $packageName, - $this->packageSettings->formFields($packageName, $this->packageSettingRegistry), - $this->requestFormValues($request), - $errors, - $errors['__form'] ?? [], - )->toArray(); + return $this->settingsForms->packageSettingsForm($packageName); } /** @@ -158,18 +157,7 @@ public function packageSettingsForm(string $packageName): array */ public function packageSettingPackages(): array { - $packages = []; - - foreach ($this->packageSettingRegistry->packagesWithDefinitions() as $packageName => $metadata) { - $packages[] = [ - 'package_name' => $packageName, - 'label' => $metadata['label'], - 'description' => $metadata['description'], - 'path' => $metadata['path'], - ]; - } - - return $packages; + return $this->settingsForms->packageSettingPackages(); } private function defaultFooterCopyright(): string @@ -184,23 +172,11 @@ private function defaultFooterCopyright(): string return trim(sprintf('Powered by %s %s', $linkedName, $version)); } - /** - * @return array - */ - private function requestFormValues(?Request $request): array + private function actor(): AccessActor { - $values = $request?->attributes->get('_system_form_values'); + $user = $this->security->getUser(); - return is_array($values) ? $values : []; + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); } - /** - * @return array> - */ - private function requestFormErrors(?Request $request): array - { - $errors = $request?->attributes->get('_system_form_errors'); - - return is_array($errors) ? $errors : []; - } } diff --git a/templates/backend/admin/log-detail.html.twig b/templates/backend/admin/log-detail.html.twig index 8c9063c2..0b722940 100644 --- a/templates/backend/admin/log-detail.html.twig +++ b/templates/backend/admin/log-detail.html.twig @@ -19,7 +19,9 @@ - + {% if log_entry.level|default('') is not empty %} + + {% endif %} @@ -45,8 +47,10 @@ {% endif %} -
-

{{ 'admin.logs.detail.raw'|trans }}

-
{{ log_entry.raw|default('') }}
-
+ {% if log_entry.raw|default('') is not empty %} +
+

{{ 'admin.logs.detail.raw'|trans }}

+
{{ log_entry.raw }}
+
+ {% endif %} {% endblock %} diff --git a/templates/backend/admin/logs.html.twig b/templates/backend/admin/logs.html.twig index e94d3287..7098e3c2 100644 --- a/templates/backend/admin/logs.html.twig +++ b/templates/backend/admin/logs.html.twig @@ -14,18 +14,40 @@ title: 'admin.logs.title'|trans, } only %} + + + {% set log_page_query = { + source: log_view.selected_source, + q: log_view.filters.search, + match: log_view.filters.match, + time_window: log_view.filters.time_window, + per_page: log_view.filters.per_page, + } %} + {% if log_view.capabilities.level_filter %} + {% set log_page_query = log_page_query|merge({level: log_view.filters.levels}) %} + {% endif %} + {% if log_view.capabilities.audit_action_filter or log_view.capabilities.signal_reason_filter %} + {% set log_page_query = log_page_query|merge({audit_action: log_view.filters.audit_action}) %} + {% endif %} +

{{ 'admin.logs.filters.title'|trans }}

+
- - + {% if log_view.capabilities.level_filter %} + + {% endif %} - + {% if log_view.capabilities.audit_action_filter %} + + {% elseif log_view.capabilities.signal_reason_filter %} + + {% endif %}
{{ 'admin.logs.columns.timestamp'|trans }}{{ log_entry.timestamp|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.level'|trans }}{{ log_entry.level|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.level'|trans }}{{ log_entry.level }}
{{ 'admin.logs.columns.source'|trans }}{{ log_entry.source|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.file'|trans }}{{ log_entry.file|default('admin.logs.empty_value'|trans) }}
{{ 'admin.logs.columns.message'|trans }}{{ log_entry.message|default('admin.logs.empty_value'|trans) }}
- + {% if log_view.capabilities.level_filter %} + + {% endif %} {% if log_view.selected_source == 'access' %} {% elseif log_view.selected_source == 'audit' %} + {% elseif log_view.selected_source == 'security_signal' %} + + {% else %} {% endif %} @@ -103,13 +135,18 @@ {% for entry in log_view.entries %} - + {% if log_view.capabilities.level_filter %} + + {% endif %} {% if log_view.selected_source == 'access' %} {% elseif log_view.selected_source == 'audit' %} + {% elseif log_view.selected_source == 'security_signal' %} + + {% else %} {% endif %} @@ -122,10 +159,10 @@
{{ 'admin.logs.entries.page_summary'|trans({'%page%': log_view.pagination.page, '%pages%': log_view.pagination.total_pages, '%total%': log_view.pagination.total}) }} {% if log_view.pagination.has_previous %} - {{ 'admin.logs.entries.previous'|trans }} + {{ 'admin.logs.entries.previous'|trans }} {% endif %} {% if log_view.pagination.has_next %} - {{ 'admin.logs.entries.next'|trans }} + {{ 'admin.logs.entries.next'|trans }} {% endif %}
{% endif %} diff --git a/templates/backend/admin/operations.html.twig b/templates/backend/admin/operations.html.twig index bd225db2..7c1e5654 100644 --- a/templates/backend/admin/operations.html.twig +++ b/templates/backend/admin/operations.html.twig @@ -41,7 +41,7 @@ - @@ -58,7 +58,7 @@ - @@ -79,7 +79,7 @@ - diff --git a/templates/backend/admin/operations/detail.html.twig b/templates/backend/admin/operations/detail.html.twig index 2d98242b..6155555b 100644 --- a/templates/backend/admin/operations/detail.html.twig +++ b/templates/backend/admin/operations/detail.html.twig @@ -90,7 +90,7 @@ {% if report.result.can_continue|default(false) %} - diff --git a/templates/backend/admin/packages.html.twig b/templates/backend/admin/packages.html.twig index 4a71d26f..ca6f46b9 100644 --- a/templates/backend/admin/packages.html.twig +++ b/templates/backend/admin/packages.html.twig @@ -12,11 +12,15 @@ {% block admin_body %}
+ {% set package_lifecycle_visible = admin_feature_visible('admin.packages') %} + {% set package_lifecycle_mutable = admin_feature_mutable('admin.packages') %} {% set backend_actions %}
- + {% if package_lifecycle_visible %} + + {% endif %} {% include '@backend/admin/partials/_backend-actions.html.twig' with { actions: backend_actions(['package_discovery']), class: 'system-backend-button-group-inline', @@ -32,6 +36,7 @@ {% include '@backend/partials/feedback/_message.html.twig' with {message: message} only %} + {% if package_lifecycle_visible and package_lifecycle_mutable %}
+ {% endif %}

{{ 'admin.packages.registry_title'|trans }}

diff --git a/templates/backend/admin/packages/detail.html.twig b/templates/backend/admin/packages/detail.html.twig index 52d0da43..eff1d14c 100644 --- a/templates/backend/admin/packages/detail.html.twig +++ b/templates/backend/admin/packages/detail.html.twig @@ -13,9 +13,15 @@ {% if package.actions is not empty %}
{% for action in package.actions %} - - {{ action.label_key|trans }} - + {% if action.disabled|default(false) %} + + {% else %} + + {{ action.label_key|trans }} + + {% endif %} {% endfor %}
{% endif %} diff --git a/templates/backend/admin/packages/lifecycle.html.twig b/templates/backend/admin/packages/lifecycle.html.twig index 57041467..77d1e234 100644 --- a/templates/backend/admin/packages/lifecycle.html.twig +++ b/templates/backend/admin/packages/lifecycle.html.twig @@ -52,23 +52,30 @@

{{ 'admin.packages.lifecycle.no_changes'|trans }}

{% endif %} -
- - - + {% if lifecycle_mutable|default(false) %} + + + + + + {{ 'admin.packages.lifecycle.cancel'|trans }} + + + {% else %} +

{{ 'admin.acl.read_only_action'|trans }}

{{ 'admin.packages.lifecycle.cancel'|trans }} - + {% endif %} {% else %} {% for issue in plan.issues|default([]) %}

{{ issue.translation_key|trans(issue.parameters) }}

diff --git a/templates/backend/admin/partials/_backend-actions.html.twig b/templates/backend/admin/partials/_backend-actions.html.twig index f6b33705..c858df78 100644 --- a/templates/backend/admin/partials/_backend-actions.html.twig +++ b/templates/backend/admin/partials/_backend-actions.html.twig @@ -13,7 +13,7 @@ - diff --git a/templates/backend/admin/scheduler/detail.html.twig b/templates/backend/admin/scheduler/detail.html.twig index 661ed732..ff32f3a7 100644 --- a/templates/backend/admin/scheduler/detail.html.twig +++ b/templates/backend/admin/scheduler/detail.html.twig @@ -14,7 +14,7 @@ {% set run_now_action %}
- + {% endset %} {% endif %} @@ -57,11 +57,11 @@
{{ 'admin.scheduler.legend.summary'|trans }} @@ -72,10 +72,10 @@ {% if task.type.value == 'action_queue' and not task.trusted %} {% endif %} - +
diff --git a/templates/backend/admin/settings/acl.html.twig b/templates/backend/admin/settings/acl.html.twig new file mode 100644 index 00000000..5cdd5e64 --- /dev/null +++ b/templates/backend/admin/settings/acl.html.twig @@ -0,0 +1,113 @@ +{% extends '@backend/admin.html.twig' %} + +{% block title %}{{ 'admin.settings.acl.title'|trans }}{% endblock %} + +{% block admin_sidebar %} + +{% endblock %} + +{% block admin_body %} + {% include '@backend/admin/partials/_page-header.html.twig' with { + eyebrow: 'admin.settings.title'|trans, + title: 'admin.settings.acl.title'|trans, + } only %} + + + +
+ + + + {% for surface in acl_matrix.surfaces %} +
+
+

{{ surface.label_key|trans }}

+
+ + {% if surface.rows is empty %} +

{{ 'admin.acl.empty'|trans }}

+ {% else %} +
+
{{ 'admin.logs.columns.timestamp'|trans }}{{ 'admin.logs.columns.level'|trans }}{{ 'admin.logs.columns.level'|trans }}{{ 'admin.logs.columns.request'|trans }} {{ 'admin.logs.columns.http_status'|trans }}{{ 'admin.logs.columns.user'|trans }} {{ 'admin.logs.columns.action'|trans }}{{ 'admin.logs.columns.security_signal'|trans }}{{ 'admin.logs.columns.subject'|trans }}{{ 'admin.logs.columns.message'|trans }}
{{ entry.timestamp|default('') }}{{ entry.level|default('') }}{{ entry.level|default('') }}{{ entry.context.method|default('admin.logs.empty_value'|trans) }} {{ entry.context.requested_path|default(entry.context.path|default('admin.logs.empty_value'|trans)) }}
{{ entry.context.resolved_route|default(entry.context.route|default('admin.logs.empty_value'|trans)) }}
{{ entry.context.http_status|default('admin.logs.empty_value'|trans) }}{{ entry.context.user|default('admin.logs.empty_value'|trans) }}
{{ entry.context.user_access_level|default('0') }}
{{ entry.context.action|default(entry.message) }}{{ entry.context.signal_type|default(entry.summary|default(entry.message)) }}
{{ entry.context.reason_code|default('admin.logs.empty_value'|trans) }}
{{ entry.context.subject_type|default('admin.logs.empty_value'|trans) }}
{{ entry.context.subject_identifier|default('admin.logs.empty_value'|trans) }}
{{ entry.summary|default(entry.message|default(entry.raw)) }}
+ + + + + + + + + + {% for row in surface.rows %} + + + + + + + {% endfor %} + +
{{ 'admin.acl.table.feature'|trans }}{{ 'admin.acl.table.default'|trans }}{{ 'admin.acl.table.admin_state'|trans }}{{ 'admin.acl.table.groups'|trans }}
+ {{ row.category_key|trans }} + {{ row.label_key|trans }} + {{ row.identifier }} + {% if not row.configurable %} + {{ 'admin.acl.non_configurable'|trans }} + {% endif %} + {{ ('admin.acl.states.' ~ row.default_state)|trans }} + + + {% if surface.groups is empty %} + {{ 'admin.acl.groups.empty'|trans }} + {% else %} +
+ {% for group in row.groups %} + + {% endfor %} +
+ {% endif %} +
+ + {% endif %} + + {% endfor %} + + + +{% endblock %} diff --git a/templates/backend/admin/settings/section.html.twig b/templates/backend/admin/settings/section.html.twig index 4885f3e3..c8335364 100644 --- a/templates/backend/admin/settings/section.html.twig +++ b/templates/backend/admin/settings/section.html.twig @@ -26,5 +26,39 @@ {% if settings_form %} {% include '@backend/partials/forms/_dynamic.html.twig' with {form: settings_form} only %} {% endif %} + {% if settings_section == 'statistics' and geoip_settings.can_update|default(false) and geoip_settings.status|default(null) %} + {% set geoip_status = geoip_settings.status %} +

{{ 'admin.settings.geoip.status.title'|trans }}

+
+
+
{{ 'admin.settings.geoip.status.provider'|trans }}
+
{{ geoip_status.provider_key|default('n/a') }}
+
+
+
{{ 'admin.settings.geoip.status.state'|trans }}
+
{{ ('admin.settings.geoip.status.values.' ~ (geoip_status.status|default('unknown')))|trans }}
+
+
+
{{ 'admin.settings.geoip.status.database_edition'|trans }}
+
{{ geoip_status.database_edition|default('n/a') ?: 'n/a' }}
+
+
+
{{ 'admin.settings.geoip.status.database_build_date'|trans }}
+
{{ geoip_status.database_build_date|default('n/a') ?: 'n/a' }}
+
+ {% if geoip_status.failure_code|default(null) %} +
+
{{ 'admin.settings.geoip.status.failure_code'|trans }}
+
{{ geoip_status.failure_code }}
+
+ {% endif %} +
+ {% endif %} + {% if settings_section == 'statistics' and geoip_settings.can_update|default(false) and geoip_settings.has_license_key|default(false) %} + {% include '@backend/admin/partials/_backend-actions.html.twig' with { + actions: backend_actions(['geoip_database_update']), + class: 'system-settings-actions', + } only %} + {% endif %} {% endblock %} diff --git a/templates/backend/admin/users/deleted.html.twig b/templates/backend/admin/users/deleted.html.twig index 7ac1bcad..e070b522 100644 --- a/templates/backend/admin/users/deleted.html.twig +++ b/templates/backend/admin/users/deleted.html.twig @@ -20,7 +20,7 @@ {{ 'admin.users.deleted.retention'|trans({'%days%': retention_days, '%cutoff%': cleanup_cutoff|date('Y-m-d H:i')}) }}
- +
@@ -58,11 +58,11 @@
- +
- +
diff --git a/templates/backend/admin/users/detail.html.twig b/templates/backend/admin/users/detail.html.twig index 4fbea8a1..33bf5f3d 100644 --- a/templates/backend/admin/users/detail.html.twig +++ b/templates/backend/admin/users/detail.html.twig @@ -43,6 +43,7 @@ value: user_account.role.value, options: role_options|reduce((carry, role) => carry|merge({(role.value): ('admin.users.roles.' ~ role.value)|trans}), {}), required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/select.html.twig' with { id: 'user_status', @@ -55,6 +56,7 @@ deleted: 'admin.users.status.deleted'|trans, }, required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/checkbox-group.html.twig' with { id: 'user_groups', @@ -62,8 +64,9 @@ label: 'admin.users.fields.groups'|trans, values: user_account.groups|map(group => group.identifier), options: groups|reduce((carry, group) => carry|merge({(group.identifier): group.name}), {}), + disabled: not users_mutable, } only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans, disabled: not users_mutable} only %} @@ -72,7 +75,7 @@

{{ 'admin.users.password_reset.text'|trans }}

- +
diff --git a/templates/backend/admin/users/group-detail.html.twig b/templates/backend/admin/users/group-detail.html.twig index b13cb337..2a43bc18 100644 --- a/templates/backend/admin/users/group-detail.html.twig +++ b/templates/backend/admin/users/group-detail.html.twig @@ -33,9 +33,9 @@

{{ 'admin.groups.member_count'|trans({'%count%': member_count}) }}

- {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, value: group.name, required: true, attributes: {maxlength: 160}} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', value: group.minRole, required: true, attributes: {min: 0, max: 9}} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, value: group.name, required: true, attributes: {maxlength: 160}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', value: group.minRole, required: true, attributes: {min: 0, max: 9}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.actions.save'|trans, disabled: not acl_mutable} only %}
@@ -43,7 +43,7 @@

{{ 'admin.groups.delete.title'|trans }}

- +
diff --git a/templates/backend/admin/users/groups.html.twig b/templates/backend/admin/users/groups.html.twig index e9369023..6c3858d8 100644 --- a/templates/backend/admin/users/groups.html.twig +++ b/templates/backend/admin/users/groups.html.twig @@ -18,10 +18,10 @@

{{ 'admin.groups.create.title'|trans }}

- {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_identifier', name: 'identifier', label: 'admin.groups.fields.identifier'|trans, required: true} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, required: true, attributes: {maxlength: 160}} only %} - {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', required: true, attributes: {min: 0, max: 9}} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.groups.actions.create'|trans} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_identifier', name: 'identifier', label: 'admin.groups.fields.identifier'|trans, required: true, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_name', name: 'name', label: 'admin.groups.fields.name'|trans, required: true, attributes: {maxlength: 160}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/input.html.twig' with {id: 'group_min_role', name: 'min_role', label: 'admin.groups.fields.min_role'|trans, type: 'number', required: true, attributes: {min: 0, max: 9}, disabled: not acl_mutable} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.groups.actions.create'|trans, disabled: not acl_mutable} only %}
diff --git a/templates/backend/admin/users/index.html.twig b/templates/backend/admin/users/index.html.twig index d2c91495..2e43af50 100644 --- a/templates/backend/admin/users/index.html.twig +++ b/templates/backend/admin/users/index.html.twig @@ -30,12 +30,14 @@ label: 'admin.users.fields.email'|trans, type: 'email', required: true, + disabled: not users_mutable, } only %} {% include '@backend/partials/forms/fields/select.html.twig' with { id: 'invite_role', name: 'role', label: 'admin.users.fields.role'|trans, value: 'user', + disabled: not users_mutable, attributes: { 'data-invite-groups-target': 'role', 'data-action': 'invite-groups#refresh', @@ -53,12 +55,13 @@ type="checkbox" name="groups[]" value="{{ group.identifier }}" + {% if not users_mutable %}disabled{% endif %} > {{ group.name }} {% endfor %} - {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.invitation.submit'|trans} only %} + {% include '@backend/partials/forms/fields/submit.html.twig' with {label: 'admin.users.invitation.submit'|trans, disabled: not users_mutable} only %} @@ -191,6 +194,7 @@ {% for token in pending_tokens %} + {% set token_revoke_mutable = reviews_mutable %} {{ token.email }} {{ ('admin.users.token_type.' ~ token.type.value)|trans }} {{ ('admin.users.roles.' ~ token.role.value)|trans }} @@ -200,17 +204,17 @@ {% if token.status.value == 'pending_approval' %}
- +
{% elseif token.status.value == 'pending' %}
- +
{% endif %}
- +
diff --git a/templates/backend/admin/users/reviews.html.twig b/templates/backend/admin/users/reviews.html.twig index 8dffc07a..462e0efb 100644 --- a/templates/backend/admin/users/reviews.html.twig +++ b/templates/backend/admin/users/reviews.html.twig @@ -102,36 +102,36 @@
- +
- +
{% elseif item.kind in ['invitation', 'registration'] %}
- +
- +
{% elseif item.kind == 'password_dispute' and user %}
- +
- +
{% endif %} diff --git a/templates/backend/components/Button.html.twig b/templates/backend/components/Button.html.twig index e775207a..f7d9387e 100644 --- a/templates/backend/components/Button.html.twig +++ b/templates/backend/components/Button.html.twig @@ -1,9 +1,13 @@ -{% props label, href = null, type = null, variant = 'primary', class = '' %} +{% props label, href = null, type = null, variant = 'primary', class = '', disabled = false %} {% set button_type = type|default(href ? null : 'button') %} {% set button_class = ['system-backend-button', 'system-button-' ~ variant, class]|filter(item => item is not empty)|join(' ') %} {% if href %} - {{ label }} + {% if disabled %} + {{ label }} + {% else %} + {{ label }} + {% endif %} {% else %} - + {% endif %} diff --git a/templates/backend/components/ButtonGroup.html.twig b/templates/backend/components/ButtonGroup.html.twig index 8f50e74f..b3fc148c 100644 --- a/templates/backend/components/ButtonGroup.html.twig +++ b/templates/backend/components/ButtonGroup.html.twig @@ -8,6 +8,7 @@ :type="action.type|default(null)" :variant="action.variant|default('secondary')" :class="action.class|default('')" + :disabled="action.disabled|default(false)" /> {% endfor %} diff --git a/templates/backend/partials/actions/_button.html.twig b/templates/backend/partials/actions/_button.html.twig index 6dc84965..6f754fba 100644 --- a/templates/backend/partials/actions/_button.html.twig +++ b/templates/backend/partials/actions/_button.html.twig @@ -4,4 +4,5 @@ :type="type|default(null)" :variant="variant|default('primary')" :class="class|default('')" + :disabled="disabled|default(false)" /> diff --git a/templates/backend/partials/forms/_dynamic.html.twig b/templates/backend/partials/forms/_dynamic.html.twig index dc0211ee..5f7cc9ca 100644 --- a/templates/backend/partials/forms/_dynamic.html.twig +++ b/templates/backend/partials/forms/_dynamic.html.twig @@ -6,9 +6,11 @@ {% include '@backend/partials/forms/_dynamic_fields.html.twig' with {fields: form.fields} only %} - {% include '@backend/partials/forms/fields/submit.html.twig' with { - label: 'admin.settings.form.save'|trans, - variant: 'primary', - } only %} + {% if not form.metadata.read_only|default(false) %} + {% include '@backend/partials/forms/fields/submit.html.twig' with { + label: 'admin.settings.form.save'|trans, + variant: 'primary', + } only %} + {% endif %} {% endif %} diff --git a/templates/backend/partials/forms/_dynamic_fields.html.twig b/templates/backend/partials/forms/_dynamic_fields.html.twig index f9b523f5..4e989681 100644 --- a/templates/backend/partials/forms/_dynamic_fields.html.twig +++ b/templates/backend/partials/forms/_dynamic_fields.html.twig @@ -13,6 +13,7 @@ name: field.name, label: field_label, checked: field.value, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -25,6 +26,7 @@ options: field_options, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), autocomplete_enabled: field.metadata.autocomplete_enabled|default(false), autocomplete_options: field.metadata.autocomplete_options|default({}), help: field_help, @@ -37,6 +39,7 @@ label: field_label, values: field.value is iterable ? field.value : [], options: field_options, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -48,6 +51,7 @@ value: field.value, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} @@ -65,12 +69,21 @@ id: field.id, name: field.name, label: field_label, - type: field.input_type == 'number' ? 'number' : (field.input_type == 'color' ? 'color' : 'text'), + type: field.input_type == 'number' ? 'number' : (field.input_type == 'color' ? 'color' : (field.input_type == 'password' ? 'password' : 'text')), value: field.value, required: field.required, attributes: field.attributes, + disabled: field.metadata.disabled|default(false), help: field_help, errors: field.errors, } only %} {% endif %} + {% set help_link_url = field.metadata.help_link_url|default(null) %} + {% if help_link_url starts with 'https://' and field.metadata.help_link_label|default(null) %} +

+ + {{ field.metadata.help_link_label|trans }} + +

+ {% endif %} {% endfor %} diff --git a/templates/backend/partials/forms/fields/checkbox-group.html.twig b/templates/backend/partials/forms/fields/checkbox-group.html.twig index 8eee077e..d88f461a 100644 --- a/templates/backend/partials/forms/fields/checkbox-group.html.twig +++ b/templates/backend/partials/forms/fields/checkbox-group.html.twig @@ -12,6 +12,7 @@ name="{{ name }}[]" value="{{ option_value }}" {% if option_value in selected_values %}checked{% endif %} + {% if disabled|default(false) %}disabled{% endif %} > {{ option_label }} diff --git a/templates/backend/partials/forms/fields/input.html.twig b/templates/backend/partials/forms/fields/input.html.twig index db2acfe7..dcfbb958 100644 --- a/templates/backend/partials/forms/fields/input.html.twig +++ b/templates/backend/partials/forms/fields/input.html.twig @@ -7,6 +7,7 @@ name="{{ name }}" value="{{ value|default('') }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} > {% endset %} diff --git a/templates/backend/partials/forms/fields/select.html.twig b/templates/backend/partials/forms/fields/select.html.twig index 3e68ad9c..0d2f6403 100644 --- a/templates/backend/partials/forms/fields/select.html.twig +++ b/templates/backend/partials/forms/fields/select.html.twig @@ -9,6 +9,7 @@ class="system-backend-select" name="{{ name }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} {{ autocomplete_attributes }} > diff --git a/templates/backend/partials/forms/fields/submit.html.twig b/templates/backend/partials/forms/fields/submit.html.twig index e08e9dda..0566f694 100644 --- a/templates/backend/partials/forms/fields/submit.html.twig +++ b/templates/backend/partials/forms/fields/submit.html.twig @@ -3,4 +3,5 @@ type: 'submit', variant: variant|default('primary'), class: class|default(''), + disabled: disabled|default(false), } only %} diff --git a/templates/backend/partials/forms/fields/textarea.html.twig b/templates/backend/partials/forms/fields/textarea.html.twig index 224ad311..cba868a7 100644 --- a/templates/backend/partials/forms/fields/textarea.html.twig +++ b/templates/backend/partials/forms/fields/textarea.html.twig @@ -6,6 +6,7 @@ name="{{ name }}" rows="{{ rows|default(6) }}" {% if required|default(false) %}required{% endif %} + {% if disabled|default(false) %}disabled{% endif %} {{ html_attributes(attributes|default({})) }} >{{ value|default('') }} {% endset %} diff --git a/templates/backend/partials/forms/fields/toggle.html.twig b/templates/backend/partials/forms/fields/toggle.html.twig index 6e9cba2d..1f357a97 100644 --- a/templates/backend/partials/forms/fields/toggle.html.twig +++ b/templates/backend/partials/forms/fields/toggle.html.twig @@ -9,6 +9,7 @@ name="{{ name }}" value="{{ value|default('1') }}" {% if checked|default(false) %}checked{% endif %} + {% if disabled|default(false) %}disabled{% endif %} > {{ label }} diff --git a/templates/backend/setup/partials/steps/_review.html.twig b/templates/backend/setup/partials/steps/_review.html.twig index 32046654..f88916ff 100644 --- a/templates/backend/setup/partials/steps/_review.html.twig +++ b/templates/backend/setup/partials/steps/_review.html.twig @@ -5,7 +5,7 @@
{{ 'setup.form.language.label'|trans }}
{{ setup_values.language }}
{{ 'setup.form.site_title.label'|trans }}
{{ setup_values.site_title }}
{{ 'setup.form.default_uri.label'|trans }}
{{ setup_values.default_uri }}
-
{{ 'setup.form.registration_mode.label'|trans }}
{{ ('admin.settings.options.registration.' ~ setup_values.registration_mode)|trans }}
+
{{ 'setup.form.registration_mode.label'|trans }}
{{ ('setup.form.registration_mode.options.' ~ setup_values.registration_mode)|trans }}
{{ 'setup.form.route_prefixes_enabled.label'|trans }}
{{ (setup_values.route_prefixes_enabled ? 'setup.review.enabled' : 'setup.review.disabled')|trans }}
{{ 'setup.form.statistics_enabled.label'|trans }}
{{ (setup_values.statistics_enabled ? 'setup.review.enabled' : 'setup.review.disabled')|trans }}
{{ 'setup.form.database_driver.label'|trans }}
{{ ('setup.form.database_driver.options.' ~ setup_values.database_driver)|trans }}
diff --git a/tests/Api/Http/ApiTraceHeaderSubscriberTest.php b/tests/Api/Http/ApiTraceHeaderSubscriberTest.php index e160e705..57142361 100644 --- a/tests/Api/Http/ApiTraceHeaderSubscriberTest.php +++ b/tests/Api/Http/ApiTraceHeaderSubscriberTest.php @@ -49,6 +49,17 @@ public function testItIgnoresNonVersionedApiResponses(): void self::assertNull($response->headers->get('X-Correlation-ID')); } + public function testItIgnoresApiLookalikeResponses(): void + { + $request = Request::create('/api/v10/status'); + $response = new Response('ok'); + + $this->subscriber()->onKernelResponse($this->responseEvent($request, $response)); + + self::assertNull($response->headers->get('X-Request-ID')); + self::assertNull($response->headers->get('X-Correlation-ID')); + } + private function subscriber(): ApiTraceHeaderSubscriber { return new ApiTraceHeaderSubscriber(new AccessRequestMetadata()); diff --git a/tests/Api/Security/ApiAvailabilitySubscriberTest.php b/tests/Api/Security/ApiAvailabilitySubscriberTest.php index 5d6c5f54..d289b520 100644 --- a/tests/Api/Security/ApiAvailabilitySubscriberTest.php +++ b/tests/Api/Security/ApiAvailabilitySubscriberTest.php @@ -92,6 +92,20 @@ public function isAvailable(): bool self::assertFalse($event->hasResponse()); } + public function testItIgnoresApiLookalikeRequests(): void + { + $event = $this->event('/api/v10/status'); + + $this->subscriber(new class implements ApiAvailabilityCheckerInterface { + public function isAvailable(): bool + { + return false; + } + })->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + private function subscriber(ApiAvailabilityCheckerInterface $availabilityChecker, bool $enabled = true): ApiAvailabilitySubscriber { $config = $this->config(); diff --git a/tests/Api/Security/ApiCorsSubscriberTest.php b/tests/Api/Security/ApiCorsSubscriberTest.php index 050a39e7..c9361d18 100644 --- a/tests/Api/Security/ApiCorsSubscriberTest.php +++ b/tests/Api/Security/ApiCorsSubscriberTest.php @@ -47,6 +47,19 @@ public function testItIgnoresDisallowedOrigins(): void self::assertFalse($event->hasResponse()); } + public function testItDoesNotShortCircuitPreflightsWithActualAuthorizationHeader(): void + { + $event = $this->requestEvent(Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + ])); + + $this->subscriber(['https://client.example'])->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + public function testItAddsCorsHeadersToAllowedApiResponses(): void { $request = Request::create('/api/v1/status', 'GET', server: [ diff --git a/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php b/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php index 7f6abf57..0fa2e628 100644 --- a/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php +++ b/tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php @@ -46,6 +46,36 @@ public function testItAllowsSafeRequestsForReadOnlyKeys(): void self::assertFalse($event->hasResponse()); } + public function testItBlocksUnsafePreflightMethodsForReadOnlyKeys(): void + { + $request = Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]); + $this->context(ApiKeyStatus::ReadOnly)->attachTo($request); + $event = new RequestEvent($this->kernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber()->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()->getStatusCode()); + $payload = json_decode((string) $event->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame('api_key.permission_write_required', $payload['error']['code']); + self::assertSame('OPTIONS', $payload['error']['context']['method']); + } + + public function testItAllowsSafePreflightMethodsForReadOnlyKeys(): void + { + $request = Request::create('/api/v1/status', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ]); + $this->context(ApiKeyStatus::ReadOnly)->attachTo($request); + $event = new RequestEvent($this->kernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber()->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + public function testItAllowsMutatingRequestsForReadWriteKeys(): void { $request = Request::create('/api/v1/status', 'POST'); diff --git a/tests/Api/Security/ApiRequestMethodPolicyTest.php b/tests/Api/Security/ApiRequestMethodPolicyTest.php new file mode 100644 index 00000000..2a7ab3f4 --- /dev/null +++ b/tests/Api/Security/ApiRequestMethodPolicyTest.php @@ -0,0 +1,95 @@ + + */ + public static function effectiveMethodCases(): iterable + { + yield 'ordinary get' => [ + Request::create('/api/v1/status'), + Request::METHOD_GET, + true, + ]; + yield 'ordinary post' => [ + Request::create('/api/v1/content/items', Request::METHOD_POST), + Request::METHOD_POST, + false, + ]; + yield 'preflight uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_PATCH, + ]), + Request::METHOD_PATCH, + false, + ]; + yield 'credentialed preflight uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_PATCH, + 'HTTP_AUTHORIZATION' => 'Basic credential-probe', + ]), + Request::METHOD_PATCH, + false, + ]; + yield 'malformed bearer preflight still uses requested unsafe method' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => Request::METHOD_DELETE, + 'HTTP_AUTHORIZATION' => 'Bearer', + ]), + Request::METHOD_DELETE, + false, + ]; + yield 'credentialed preflight without requested method defaults to read' => [ + Request::create('/api/v1/content/items', Request::METHOD_OPTIONS, server: [ + 'HTTP_AUTHORIZATION' => 'Bearer', + ]), + Request::METHOD_GET, + true, + ]; + } + + #[DataProvider('effectiveMethodCases')] + public function testEffectiveMethod(Request $request, string $method, bool $safe): void + { + $policy = new ApiRequestMethodPolicy(); + + self::assertSame($method, $policy->effectiveMethod($request)); + self::assertSame($safe, $policy->isSafeEffectiveMethod($request)); + } + + /** + * @return iterable + */ + public static function apiPathCases(): iterable + { + yield 'api root' => ['/api/v1', true]; + yield 'api child' => ['/api/v1/status', true]; + yield 'api lookalike' => ['/api/v10/status', false]; + yield 'browser content' => ['/docs/api/v1', false]; + } + + #[DataProvider('apiPathCases')] + public function testApiPathUsesSegmentBoundaries(string $path, bool $api): void + { + self::assertSame($api, (new ApiRequestMethodPolicy())->isApiV1Request(Request::create($path))); + } + + public function testApiPathDoesNotUseLocalizedRequestSegments(): void + { + $request = Request::create('/de/api/v1/status'); + $request->attributes->set('_locale', 'de'); + + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request($request)); + self::assertFalse((new ApiRequestMethodPolicy())->isApiV1Request(Request::create('/de/api/v1/status'))); + } +} diff --git a/tests/Backend/PackageDependencyLabelParserTest.php b/tests/Backend/PackageDependencyLabelParserTest.php index 4b4c8fcf..ed482a7a 100644 --- a/tests/Backend/PackageDependencyLabelParserTest.php +++ b/tests/Backend/PackageDependencyLabelParserTest.php @@ -14,10 +14,10 @@ public function testItParsesDependencyLabelsFromManifestJson(): void $parser = new PackageDependencyLabelParser(); self::assertSame([ - 'system 0.2.4', + 'system 0.2.5', 'demo-module', 'provider 1.0', - ], $parser->parse('[["system","0.2.4"],"demo-module",["provider","1.0",{"ignored":true}]]')); + ], $parser->parse('[["system","0.2.5"],"demo-module",["provider","1.0",{"ignored":true}]]')); } public function testItKeepsMalformedDependencyValuesVisible(): void diff --git a/tests/Command/RenderRouteCommandTest.php b/tests/Command/RenderRouteCommandTest.php index 618a6045..717dcefd 100644 --- a/tests/Command/RenderRouteCommandTest.php +++ b/tests/Command/RenderRouteCommandTest.php @@ -5,6 +5,12 @@ namespace App\Tests\Command; use App\Command\RenderRouteCommand; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Debug\RouteRenderer; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -22,6 +28,19 @@ public function testItRendersPublicRoutesFromTheConsole(): void self::assertStringContainsString('Sign in', $tester->getDisplay()); } + public function testItIsBlockedInProductionEnvironment(): void + { + self::bootKernel(); + $command = new RenderRouteCommand(self::getContainer()->get(RouteRenderer::class), 'prod'); + $tester = new CommandTester($command); + + $exitCode = $tester->execute(['path' => '/user/login']); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('render:route command is available only', $tester->getDisplay()); + self::assertStringContainsString('environments', $tester->getDisplay()); + } + public function testItRendersProtectedRoutesWithDebugRoleContext(): void { self::bootKernel(); @@ -44,6 +63,44 @@ public function testItRendersApiRoutesWithDebugApiContext(): void self::assertStringContainsString('"type":"user_profile"', $tester->getDisplay()); } + public function testItRejectsInvalidSyntheticHeaders(): void + { + self::bootKernel(); + $tester = new CommandTester(self::getContainer()->get(RenderRouteCommand::class)); + + $exitCode = $tester->execute([ + 'path' => '/user/login', + '--header' => ["X-Test: ok\nInjected: nope"], + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('unsupported control characters', $tester->getDisplay()); + } + + public function testCronRunRenderEnforcesSchedulerRateLimitForApiKeyContext(): void + { + self::bootKernel(); + $this->setRateLimitMode(RateLimitProfile::Standard); + $command = self::getContainer()->get(RenderRouteCommand::class); + + $first = new CommandTester($command); + $firstExit = $first->execute($this->cronRenderInput()); + + self::assertSame(Command::SUCCESS, $firstExit); + self::assertStringContainsString('HTTP 200', $first->getDisplay()); + + $second = new CommandTester($command); + $secondExit = $second->execute($this->cronRenderInput()); + $display = $second->getDisplay(); + + self::assertSame(Command::SUCCESS, $secondExit); + self::assertStringContainsString('HTTP 429', $display); + self::assertStringContainsString('Retry-After:', $display); + self::assertStringContainsString('Cache-Control:', $display); + self::assertStringContainsString('no-store', $display); + self::assertStringContainsString('"code":"rate_limit.exceeded"', $display); + } + public function testItDoesNotOverrideExistingUserRoles(): void { self::bootKernel(); @@ -54,4 +111,32 @@ public function testItDoesNotOverrideExistingUserRoles(): void self::assertSame(Command::FAILURE, $exitCode); self::assertStringContainsString('The --role option cannot override an existing --user role.', $tester->getDisplay()); } + + /** + * @return array + */ + private function cronRenderInput(): array + { + return [ + 'path' => '/cron/run', + '--role' => 'public', + '--include-status' => true, + '--include-headers' => true, + '--header' => [ + 'Authorization: Bearer test_seed_read_write_key', + 'X-Rate-Limit-Testing: 1', + ], + ]; + } + + private function setRateLimitMode(RateLimitProfile $profile): void + { + $cache = self::getContainer()->get('cache.rate_limiter'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, $profile->value, ConfigValueType::String, modifiedBy: 'test'); + } } diff --git a/tests/Content/Routing/ContentRouteGuardTest.php b/tests/Content/Routing/ContentRouteGuardTest.php index 60ff1a89..fed23786 100644 --- a/tests/Content/Routing/ContentRouteGuardTest.php +++ b/tests/Content/Routing/ContentRouteGuardTest.php @@ -37,7 +37,23 @@ public function testItRejectsVariantMarkersBeforeTheLastPathSegment(): void public function testItRejectsReservedPathPrefixes(): void { - foreach (['system', 'user', 'setup', 'cron', 'admin', 'editor', 'packages'] as $prefix) { + foreach ([ + 'system', + 'user', + 'setup', + 'cron', + 'admin', + 'editor', + 'api', + 'assets', + 'build', + '_profiler', + 'profiler', + '_wdt', + 'packages', + 'media', + 'files', + ] as $prefix) { try { (new ContentRouteGuard())->assertPathAllowed(sprintf('/%s/example', $prefix)); self::fail(sprintf('Expected prefix "%s" to be reserved.', $prefix)); diff --git a/tests/Controller/AdminSchedulerControllerTest.php b/tests/Controller/AdminSchedulerControllerTest.php index 1fdbdf90..d1e6b664 100644 --- a/tests/Controller/AdminSchedulerControllerTest.php +++ b/tests/Controller/AdminSchedulerControllerTest.php @@ -4,6 +4,8 @@ namespace App\Tests\Controller; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\SchedulerTask; use App\Scheduler\SchedulerTaskDefinition; use App\Scheduler\SchedulerTaskStatus; @@ -20,6 +22,14 @@ public function testAdminSchedulerListsEditsAndRunsRegisteredJobs(): void { $client = self::createClient(); $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); try { $crawler = $client->request('GET', '/admin/scheduler'); @@ -53,6 +63,62 @@ public function testAdminSchedulerListsEditsAndRunsRegisteredJobs(): void self::assertResponseIsSuccessful(); self::assertStringContainsString('Scheduler run completed with status completed.', (string) $client->getResponse()->getContent()); } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); + } + } + + public function testAdminSchedulerReadOnlyDisablesControlsAndRejectsMutations(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, 8); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $definition = SchedulerTaskDefinition::command( + 'system.live_operation_cleanup', + 'admin.scheduler.tasks.live_operation_cleanup.label', + 'admin.scheduler.tasks.live_operation_cleanup.description', + 'operations:cleanup', + '*/15 * * * *', + ); + $task = $entityManager->find(SchedulerTask::class, 'system.live_operation_cleanup') ?? new SchedulerTask($definition); + $task->syncDefinition($definition, new \DateTimeImmutable()); + $task->activate('*/15 * * * *'); + $entityManager->persist($task); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $crawler = $client->request('GET', '/admin/scheduler/system.live_operation_cleanup'); + + self::assertResponseIsSuccessful(); + self::assertNotNull($crawler->filter('input[name="enabled"]')->attr('disabled')); + self::assertNotNull($crawler->filter('input[name="cron_expression"]')->attr('disabled')); + self::assertNotNull($crawler->selectButton('Save')->attr('disabled')); + self::assertNotNull($crawler->selectButton('Run now')->attr('disabled')); + + $client->request('POST', '/admin/scheduler/system.live_operation_cleanup', [ + '_csrf_token' => (string) $crawler->filter('form.system-form input[name="_csrf_token"]')->attr('value'), + 'enabled' => '1', + 'cron_expression' => '*/5 * * * *', + ]); + + self::assertResponseStatusCodeSame(401); + + $client->request('POST', '/admin/scheduler/system.live_operation_cleanup/run', [ + '_csrf_token' => (string) $crawler->filter('form[action="/admin/scheduler/system.live_operation_cleanup/run"] input[name="_csrf_token"]')->attr('value'), + ]); + + self::assertResponseStatusCodeSame(401); + $this->assertSchedulerTaskStatus('system.live_operation_cleanup', SchedulerTaskStatus::Active, '*/15 * * * *'); + } finally { + $store->save($store->defaultOverrides(), 'test'); $this->removeSchedulerTasks(); } } @@ -61,6 +127,14 @@ public function testAdminSchedulerRunNowSurfacesFailedTaskResults(): void { $client = self::createClient(); $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $definition = SchedulerTaskDefinition::command( 'system.live_operation_cleanup', @@ -89,6 +163,7 @@ public function testAdminSchedulerRunNowSurfacesFailedTaskResults(): void (string) $client->getResponse()->getContent(), ); } finally { + $store->save($store->defaultOverrides(), 'test'); $this->removeSchedulerTasks(); } } diff --git a/tests/Controller/AdminUserControllerTest.php b/tests/Controller/AdminUserControllerTest.php index 421f6e9b..14756d4b 100644 --- a/tests/Controller/AdminUserControllerTest.php +++ b/tests/Controller/AdminUserControllerTest.php @@ -6,6 +6,8 @@ use App\Content\Schema\ContentSchemaSource; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Core\State\StateMarkerKey; @@ -1372,6 +1374,125 @@ public function testLowerAccessAdminCannotRevokeOwnerRecoveryToken(): void $entityManager->flush(); } + public function testPendingAccountTokenActionsUseReviewFeatureWhenUsersFeatureIsReadOnly(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $admin = $this->createUser('reviewtokenactor', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + [$reissueToken] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Invitation, + 'review-reissue-browser@example.test', + [], + ttl: '-1 hour', + ); + [$revokeToken] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Invitation, + 'review-revoke-browser@example.test', + [], + ttl: '-1 hour', + ); + $originalHash = $reissueToken->tokenHash(); + $entityManager->persist($reissueToken); + $entityManager->persist($revokeToken); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $crawler = $client->request('GET', '/admin/users'); + $reissueForm = $crawler->filter('form[action="/admin/users/invitations/'.$reissueToken->uid().'/reissue"]'); + $revokeForm = $crawler->filter('form[action="/admin/users/invitations/'.$revokeToken->uid().'/revoke"]'); + + self::assertCount(1, $reissueForm); + self::assertCount(1, $revokeForm); + self::assertNull($reissueForm->filter('button[type="submit"]')->attr('disabled')); + self::assertNull($revokeForm->filter('button[type="submit"]')->attr('disabled')); + + $client->submit($reissueForm->form()); + self::assertResponseRedirects('/admin/users'); + + $client->request('POST', '/admin/users/invitations/'.$revokeToken->uid().'/revoke', [ + '_csrf_token' => (string) $revokeForm->filter('input[name="_csrf_token"]')->attr('value'), + ]); + self::assertResponseRedirects('/admin/users'); + + $entityManager->clear(); + $reissuedToken = $entityManager->find(AccountToken::class, $reissueToken->uid()); + $revokedToken = $entityManager->find(AccountToken::class, $revokeToken->uid()); + + self::assertInstanceOf(AccountToken::class, $reissuedToken); + self::assertInstanceOf(AccountToken::class, $revokedToken); + self::assertNotSame($originalHash, $reissuedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testPendingApprovalRevocationRequiresReviewFeatureEvenWithoutReviewReturnTarget(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $admin = $this->createUser('reviewrevoker', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + [$token] = self::getContainer()->get(AccountTokenIssuer::class)->issue( + AccountTokenType::Registration, + 'review-revoke@example.test', + [], + status: AccountTokenStatus::PendingApproval, + ); + $entityManager->persist($token); + $entityManager->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $crawler = $client->request('GET', '/admin/users'); + $revokeForm = $crawler->filter('form[action="/admin/users/invitations/'.$token->uid().'/revoke"]'); + + self::assertCount(1, $revokeForm); + self::assertNotNull($revokeForm->filter('button[type="submit"]')->attr('disabled')); + + $client->request('POST', '/admin/users/invitations/'.$token->uid().'/revoke', [ + '_csrf_token' => (string) $revokeForm->filter('input[name="_csrf_token"]')->attr('value'), + ]); + + self::assertResponseStatusCodeSame(401); + + $entityManager->clear(); + $unchangedToken = $entityManager->find(AccountToken::class, $token->uid()); + self::assertInstanceOf(AccountToken::class, $unchangedToken); + self::assertSame(AccountTokenStatus::PendingApproval, $unchangedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminCanRevokeInvitationWithStaleEmptyGroups(): void { $client = self::createClient(); @@ -2006,4 +2127,77 @@ public function testAdminCanCreateAclGroup(): void $entityManager->flush(); } + public function testAdminUsersFeatureReadOnlyKeepsControlsVisibleButDisabled(): void + { + $client = self::createClient(); + $admin = $this->createUser('readonlyusersadmin', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $client->request('GET', '/admin/users'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form[action="/admin/users/invitations"] input[name="email"][disabled]'); + self::assertSelectorExists('form[action="/admin/users/invitations"] select[name="role"][disabled]'); + self::assertSelectorExists('form[action="/admin/users/invitations"] button[type="submit"][disabled]'); + + $client->request('POST', '/admin/users/invitations', [ + 'email' => 'readonly-invite@example.test', + 'role' => UserRole::User->value, + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminUserAclFeatureReadOnlyKeepsGroupControlsVisibleButDisabled(): void + { + $client = self::createClient(); + $admin = $this->createUser('readonlyacladm', UserAccountStatus::Active); + $admin->changeRole(UserRole::Admin); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $this->loginTestUser($client, $admin); + $client->request('GET', '/admin/users/groups'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('section form.system-backend-form input[name="identifier"][disabled]'); + self::assertSelectorExists('section form.system-backend-form input[name="min_role"][disabled]'); + self::assertSelectorExists('section form.system-backend-form button[type="submit"][disabled]'); + + $client->request('POST', '/admin/users/groups', [ + 'identifier' => 'readonly_acl_group', + 'name' => 'Read-only ACL Group', + 'min_role' => (string) AccessLevel::MANAGER, + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + } diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index 36a725d7..7cc3eaa8 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Operation\OperationMessageCode; @@ -56,28 +58,90 @@ public function testAdminLogsListSourcesAndSourceEntries(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopslog'); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('GET', '/api/v1/admin/logs', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertGreaterThan(0, $payload['meta']['count']); - self::assertSame('log_source', $payload['data'][0]['type']); + try { + $client->request('GET', '/api/v1/admin/logs', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - $client->request('GET', '/api/v1/admin/logs/message?level=INFO&limit=25', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertGreaterThan(0, $payload['meta']['count']); + self::assertSame('log_source', $payload['data'][0]['type']); + $sources = []; + foreach ($payload['data'] as $resource) { + $sources[$resource['id']] = $resource['attributes']['filters']; + } + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['application']); + self::assertSame(['level', 'q', 'match', 'time_window', 'limit', 'page'], $sources['message']); + self::assertSame(['q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['audit']); + self::assertSame(['q', 'match', 'time_window', 'limit', 'page'], $sources['access']); + self::assertSame(['level', 'q', 'match', 'time_window', 'audit_action', 'limit', 'page'], $sources['security_signal']); + + $client->request('GET', '/api/v1/admin/logs/message?level=INFO&limit=25', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('message', $payload['meta']['selected_source']); - self::assertSame('INFO', $payload['meta']['filters']['level']); - self::assertSame(25, $payload['meta']['filters']['limit']); - self::assertArrayNotHasKey('per_page', $payload['meta']['filters']); - self::assertArrayNotHasKey('per_page', $payload['meta']['pagination']); - self::assertArrayNotHasKey('total_pages', $payload['meta']['pagination']); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('message', $payload['meta']['selected_source']); + self::assertSame('INFO', $payload['meta']['filters']['level']); + self::assertSame(25, $payload['meta']['filters']['limit']); + self::assertArrayNotHasKey('per_page', $payload['meta']['filters']); + self::assertArrayNotHasKey('per_page', $payload['meta']['pagination']); + self::assertArrayNotHasKey('total_pages', $payload['meta']['pagination']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); + } + } + + public function testAdminLogsFeatureReadOnlyHidesSensitiveSources(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopslogro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/logs', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + $sources = array_column($payload['data'], 'id'); + self::assertNotContains('audit', $sources); + self::assertNotContains('security_signal', $sources); + + $client->request('GET', '/api/v1/admin/logs/audit', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.logs', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeSchedulerTasks(); + } } public function testAdminOperationDetailAndContinuationReviewAreAvailable(): void @@ -86,6 +150,8 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi $plainKey = $this->createPlainApiKey('apiopsrun'); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); $run = $store->create('package.install.verify', [], 'Install package'); $result = WorkflowResult::requiresReview(null, [ Message::info( @@ -102,6 +168,17 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi ]); $store->finish($run['operation_id'], false, $result->toArray()); + $overrides->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + try { $client->request('GET', '/api/v1/admin/operations/'.$run['operation_id'], server: [ 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, @@ -133,7 +210,17 @@ public function testAdminOperationDetailAndContinuationReviewAreAvailable(): voi self::assertArrayNotHasKey('payload', $payload['data']['attributes']); self::assertSame('/api/v1/admin/operations/'.$run['operation_id'], $payload['links']['status']); self::assertSame('/api/v1/admin/operations/'.$run['operation_id'].'/continue?confirm=true', $payload['links']['confirm']); + + $client->request('POST', '/api/v1/admin/operations/'.$run['operation_id'].'/continue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainWriteKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.packages', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); } finally { + $overrides->save($overrides->defaultOverrides(), 'test'); @unlink(dirname($store->outputPath($run['operation_id'])).'/'.$run['operation_id'].'.json'); @unlink($store->outputPath($run['operation_id'])); @unlink($store->pidPath($run['operation_id'])); @@ -144,63 +231,202 @@ public function testAdminOperationMaintenanceRequiresConfirmation(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopsmaint', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('POST', '/api/v1/admin/operations/cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('operation_maintenance_review', $payload['data']['type']); - self::assertSame('requires_confirmation', $payload['data']['attributes']['status']); - self::assertSame('/api/v1/admin/operations/cleanup?confirm=true', $payload['links']['confirm']); + try { + $client->request('POST', '/api/v1/admin/operations/cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('operation_maintenance_review', $payload['data']['type']); + self::assertSame('requires_confirmation', $payload['data']['attributes']['status']); + self::assertSame('/api/v1/admin/operations/cleanup?confirm=true', $payload['links']['confirm']); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('operation_maintenance_result', $payload['data']['type']); - self::assertSame('completed', $payload['data']['attributes']['status']); + $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('operation_maintenance_result', $payload['data']['type']); + self::assertSame('completed', $payload['data']['attributes']['status']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminOperationsFeatureReadOnlyStillListsButRejectsConfirmedMaintenance(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/operations', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/operations/cleanup?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.operations', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } } public function testAdminSchedulerTaskDetailAndPatchAreAvailable(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apiopssched', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); - $client->request('GET', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('scheduler_task', $payload['data']['type']); - self::assertSame('system.live_operation_cleanup', $payload['data']['id']); - self::assertArrayHasKey('recent_runs', $payload['data']['relationships']); - - $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - 'CONTENT_TYPE' => 'application/json', - ], content: json_encode([ - 'enabled' => true, - 'cron_expression' => '*/10 * * * *', - ], JSON_THROW_ON_ERROR)); + try { + $client->request('GET', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('active', $payload['data']['attributes']['status']); - self::assertSame('*/10 * * * *', $payload['data']['attributes']['cron_expression']); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_task', $payload['data']['type']); + self::assertSame('system.live_operation_cleanup', $payload['data']['id']); + self::assertArrayHasKey('recent_runs', $payload['data']['relationships']); - $client->request('POST', '/api/v1/admin/scheduler/system.live_operation_cleanup/run', server: [ - 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, - ]); + $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => true, + 'cron_expression' => '*/10 * * * *', + ], JSON_THROW_ON_ERROR)); - self::assertResponseIsSuccessful(); - $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('scheduler_run', $payload['data']['type']); - self::assertSame('/api/v1/admin/scheduler/system.live_operation_cleanup', $payload['data']['links']['task']); + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('active', $payload['data']['attributes']['status']); + self::assertSame('*/10 * * * *', $payload['data']['attributes']['cron_expression']); + + $client->request('POST', '/api/v1/admin/scheduler/system.live_operation_cleanup/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_run', $payload['data']['type']); + self::assertSame('/api/v1/admin/scheduler/system.live_operation_cleanup', $payload['data']['links']['task']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testTrustedPackageDiscoverySchedulerRunUsesSchedulerAclRatherThanPackageAcl(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsschedpkg', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('PATCH', '/api/v1/admin/scheduler/system.package_discovery', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => true, + 'cron_expression' => '0 */6 * * *', + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/scheduler/system.package_discovery/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('scheduler_run', $payload['data']['type']); + self::assertSame('/api/v1/admin/scheduler/system.package_discovery', $payload['data']['links']['task']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminSchedulerFeatureReadOnlyStillShowsTasksButRejectsMutations(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiopsschedro', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/scheduler', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('PATCH', '/api/v1/admin/scheduler/system.live_operation_cleanup', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'enabled' => false, + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.scheduler', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } } public function testOpenApiIncludesAdminOperationalEndpoints(): void @@ -222,6 +448,10 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): void self::assertSame(['backend-admin', 'backend-admin-scheduler'], $payload['paths']['/admin/scheduler']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-statistics'], $payload['paths']['/admin/statistics']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-themes'], $payload['paths']['/admin/themes']['get']['tags']); + self::assertSame( + ['application', 'message', 'audit', 'access', 'security_signal'], + $payload['paths']['/admin/logs/{log}']['get']['parameters'][0]['schema']['enum'], + ); self::assertContains([ 'name' => 'backend-admin-operations', 'summary' => 'Backend Admin Operations', @@ -252,6 +482,13 @@ private function createPlainApiKey(string $prefix, ApiKeyStatus $status = ApiKey return $plainKey; } + private function removeSchedulerTasks(): void + { + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->executeStatement("DELETE FROM scheduler_task_run WHERE task_identifier LIKE 'system.%'"); + $connection->executeStatement("DELETE FROM scheduler_task WHERE source = 'system'"); + } + /** * @return array */ diff --git a/tests/Controller/ApiPackageControllerTest.php b/tests/Controller/ApiPackageControllerTest.php index 8d7c2337..1159da72 100644 --- a/tests/Controller/ApiPackageControllerTest.php +++ b/tests/Controller/ApiPackageControllerTest.php @@ -64,7 +64,7 @@ public function testPackagesReturnOverviewForAdminApiKeys(): void self::assertArrayNotHasKey('detail_path', $system['attributes']); } - public function testPackageDetailReturnsApiActionsForAdminApiKeys(): void + public function testPackageDetailReturnsDisabledLifecycleApiActionsForDelegatedAdminApiKeys(): void { $client = self::createClient(); $plainKey = $this->createPlainApiKey('apipkgdetail'); @@ -81,16 +81,33 @@ public function testPackageDetailReturnsApiActionsForAdminApiKeys(): void self::assertSame('api-package-detail', $payload['data']['attributes']['package_name']); self::assertSame('api-package-detail', $payload['data']['attributes']['package_slug']); self::assertSame('/api/v1/admin/packages/api-package-detail', $payload['data']['attributes']['api_path']); + $actions = array_column($payload['data']['attributes']['api_actions'], null, 'id'); + self::assertSame('/api/v1/admin/packages/api-package-detail/activate', $actions['activate']['api_path']); + self::assertTrue($actions['activate']['disabled']); + } + + public function testPackageDetailReturnsApiActionsForOwnerApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apipkgowner', accessLevel: AccessLevel::OWNER); + $this->upsertPackage('api-package-owner', ExtensionPackageStatus::Inactive); + + $client->request('GET', '/api/v1/admin/packages/api-package-owner', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); $actions = array_column($payload['data']['attributes']['api_actions'], 'api_path', 'id'); - self::assertSame('/api/v1/admin/packages/api-package-detail/activate', $actions['activate']); - self::assertSame('/api/v1/admin/packages/api-package-detail/delete', $actions['delete']); + self::assertSame('/api/v1/admin/packages/api-package-owner/activate', $actions['activate']); + self::assertSame('/api/v1/admin/packages/api-package-owner/delete', $actions['delete']); } - public function testPackageLifecycleActionReturnsReviewUntilConfirmed(): void + public function testPackageLifecycleActionReturnsReviewUntilConfirmedForOwnerApiKeys(): void { $client = self::createClient(); - $plainKey = $this->createPlainApiKey('apipkgwrite', ApiKeyStatus::ReadWrite); + $plainKey = $this->createPlainApiKey('apipkgwrite', ApiKeyStatus::ReadWrite, AccessLevel::OWNER); $this->upsertPackage('api-package-action', ExtensionPackageStatus::Inactive); $client->request('POST', '/api/v1/admin/packages/api-package-action/activate', server: [ @@ -105,6 +122,30 @@ public function testPackageLifecycleActionReturnsReviewUntilConfirmed(): void self::assertSame('confirm=true', $payload['data']['attributes']['confirm_parameter']); } + public function testPackageLifecycleConfirmationRejectsDelegatedAdminApiKeysByDefault(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apipkgdenied', ApiKeyStatus::ReadWrite); + $this->upsertPackage('api-package-denied', ExtensionPackageStatus::Inactive); + + $client->request('POST', '/api/v1/admin/packages/api-package-denied/activate', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('package_lifecycle_review', $payload['data']['type']); + + $client->request('POST', '/api/v1/admin/packages/api-package-denied/activate?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('api.operation_unavailable', $payload['error']['code']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } + public function testPackageLifecycleActionRequiresWriteApiKey(): void { $client = self::createClient(); @@ -146,13 +187,17 @@ public function testOpenApiIncludesPackagesEndpoint(): void ], $payload['tags']); } - private function createPlainApiKey(string $prefix, ApiKeyStatus $status = ApiKeyStatus::ReadOnly): string + private function createPlainApiKey( + string $prefix, + ApiKeyStatus $status = ApiKeyStatus::ReadOnly, + int $accessLevel = AccessLevel::ADMIN, + ): string { - $user = $this->createUserWithLevel(AccessLevel::ADMIN, $prefix.'user', 'current-password'); + $user = $this->createUserWithLevel($accessLevel, $prefix.'user', 'current-password'); $vault = self::getContainer()->get(ApiKeyVault::class); $plainKey = $vault->generatePlainKey($prefix); $apiKey = new ApiKey( - '68000000-0000-7000-8000-'.substr(md5($prefix.$status->value), 0, 12), + '68000000-0000-7000-8000-'.substr(md5($prefix.$status->value.(string) $accessLevel), 0, 12), $prefix, $vault->hmac($plainKey), $vault->encrypt($plainKey, $prefix), diff --git a/tests/Controller/ApiSettingsControllerTest.php b/tests/Controller/ApiSettingsControllerTest.php index b4fd960a..81f0113d 100644 --- a/tests/Controller/ApiSettingsControllerTest.php +++ b/tests/Controller/ApiSettingsControllerTest.php @@ -5,9 +5,14 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Entity\ApiKey; use App\Security\ApiKeyStatus; use App\Security\ApiKeyVault; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -102,6 +107,119 @@ public function testSettingsSectionCanBePatchedWithReadWriteAdminApiKeys(): void self::assertSame('API test footer', $footer['attributes']['value']); } + public function testSettingsPatchPreservesSensitiveValuesWhenClientEchoesProtectedPlaceholder(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecret', AccessLevel::OWNER); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); + + try { + $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + MaxMindGeoIpConfig::ENABLED_KEY => true, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } finally { + $this->removeApiKeyUser('apisetsecret'); + } + } + + public function testGeoIpSettingsAreHiddenAndRejectedForDelegatedAdminApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetgeoipadmin', AccessLevel::ADMIN); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'stored-api-secret', ConfigValueType::String, sensitive: true); + + $client->request('GET', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + $licenseKey = $this->resourceById($payload['data'], MaxMindGeoIpConfig::LICENSE_KEY_KEY); + self::assertSame('[protected]', $licenseKey['attributes']['value']); + self::assertTrue($licenseKey['attributes']['metadata']['read_only']); + + $client->request('PATCH', '/api/v1/admin/settings/statistics', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + MaxMindGeoIpConfig::LICENSE_KEY_KEY => 'delegated-admin-secret', + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(422); + self::assertSame('stored-api-secret', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + + public function testSecuritySettingsSectionAclHidesAndRejectsSecurityFieldsForDelegatedAdminApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecadm', AccessLevel::ADMIN); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set('security.captcha.enabled', true, ConfigValueType::Boolean); + + $client->request('GET', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(404); + + $client->request('PATCH', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + 'security.captcha.enabled' => false, + 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Panic->value, + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(404); + self::assertTrue($config->get('security.captcha.enabled')); + } + + public function testSecuritySettingsCanBeReadAndPatchedByOwnerApiKeys(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey(ApiKeyStatus::ReadWrite, 'apisetsecown', AccessLevel::OWNER); + + try { + $client->request('PATCH', '/api/v1/admin/settings/security', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'values' => [ + 'security.captcha.enabled' => true, + 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Strict->value, + ], + ], JSON_THROW_ON_ERROR)); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertContains('security.captcha.enabled', $payload['meta']['updated_keys']); + self::assertContains('security.captcha.provider', $payload['meta']['updated_keys']); + self::assertContains(RateLimitPolicyCatalogue::MODE_KEY, $payload['meta']['updated_keys']); + } finally { + $this->removeApiKeyUser('apisetsecown'); + } + } + public function testSettingsPatchReturnsValidationErrors(): void { $client = self::createClient(); @@ -184,6 +302,13 @@ private function createPlainApiKey(ApiKeyStatus $status, string $prefix, int $ac return $plainKey; } + private function removeApiKeyUser(string $prefix): void + { + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->executeStatement('DELETE FROM api_key WHERE prefix = ?', [$prefix]); + $connection->executeStatement('DELETE FROM user_account WHERE username = ?', [$prefix.'user']); + } + /** * @param list> $resources * @@ -200,6 +325,22 @@ private function resourceById(array $resources, string $id): array self::fail(sprintf('Resource "%s" was not returned.', $id)); } + /** + * @param list> $resources + * + * @return array|null + */ + private function optionalResourceById(array $resources, string $id): ?array + { + foreach ($resources as $resource) { + if (($resource['id'] ?? null) === $id) { + return $resource; + } + } + + return null; + } + /** * @return array */ diff --git a/tests/Controller/ApiUserControllerTest.php b/tests/Controller/ApiUserControllerTest.php index de86d5df..4258755f 100644 --- a/tests/Controller/ApiUserControllerTest.php +++ b/tests/Controller/ApiUserControllerTest.php @@ -5,6 +5,8 @@ namespace App\Tests\Controller; use App\Core\Access\AccessLevel; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Entity\AccountToken; use App\Entity\AclGroup; use App\Entity\ApiKey; @@ -324,6 +326,129 @@ public function testUserGroupAndReviewEndpointsReturnBasicListsForAdminApiKeys() } } + public function testAdminUserFeatureReadOnlyStillListsButRejectsMutations(): void + { + $client = self::createClient(); + $target = $this->createUserWithLevel(AccessLevel::AUTHOR, 'apiuserfeature', 'current-password'); + $group = $this->createGroup('api_user_feature_group', AccessLevel::USER); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $plainKey = $this->createPlainApiKey('apiusrfeat', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/users', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('PATCH', '/api/v1/admin/users/items/'.$target->username(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'status' => 'inactive', + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('api.operation_unavailable', $payload['error']['code']); + self::assertSame('admin.users', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + + $client->request('POST', '/api/v1/admin/users/items/'.$target->username().'/groups/'.$group->identifier(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminUserAclFeatureReadOnlyStillListsButRejectsGroupMutations(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiusraclfeat', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/api/v1/admin/users/groups', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('POST', '/api/v1/admin/users/groups', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'CONTENT_TYPE' => 'application/json', + ], content: json_encode([ + 'identifier' => 'api_group_readonly', + 'name' => 'API Group Read-only', + 'min_role' => AccessLevel::USER, + ], JSON_THROW_ON_ERROR)); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users.acl', $payload['error']['context']['feature']); + self::assertSame('feature_read_only', $payload['error']['context']['reason']); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testUserGroupMembershipDoesNotRequireGroupAdministrationFeature(): void + { + $client = self::createClient(); + $target = $this->createUserWithLevel(AccessLevel::AUTHOR, 'apiusermemuser', 'current-password'); + $group = $this->createGroup('api_member_user_feature', AccessLevel::USER); + self::getContainer()->get(EntityManagerInterface::class)->flush(); + $plainKey = $this->createPlainApiKey('apiusrmemusr', ApiKeyStatus::ReadWrite); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.acl' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/items/'.$target->username().'/groups/'.$group->identifier(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertContains($group->identifier(), array_column($payload['data']['attributes']['groups'], 'identifier')); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAclGroupsCanBeCreatedAndEditedWithConfirmation(): void { $client = self::createClient(); @@ -543,6 +668,94 @@ public function testRegistrationReviewCanBeApprovedAndDeniedByToken(): void self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); } + public function testOrdinaryAccountTokenReviewActionsUseReviewFeatureWhenUserFeatureIsReadOnly(): void + { + $client = self::createClient(); + $reissueToken = $this->createPendingAccountToken('api-review-reissue@example.test'); + $revokeToken = $this->createPendingAccountToken('api-review-revoke@example.test'); + $plainKey = $this->createPlainApiKey('apiusrrevtok', ApiKeyStatus::ReadWrite); + $originalHash = $reissueToken->tokenHash(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/reviews/tokens/'.$reissueToken->uid().'/reissue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + + $client->request('DELETE', '/api/v1/admin/users/reviews/tokens/'.$revokeToken->uid().'?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->clear(); + $reissuedToken = $entityManager->find(AccountToken::class, $reissueToken->uid()); + $revokedToken = $entityManager->find(AccountToken::class, $revokeToken->uid()); + self::assertInstanceOf(AccountToken::class, $reissuedToken); + self::assertInstanceOf(AccountToken::class, $revokedToken); + self::assertNotSame($originalHash, $reissuedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Revoked, $revokedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeAccountTokens($reissueToken->uid(), $revokeToken->uid()); + } + } + + public function testOrdinaryAccountTokenReviewActionsRejectWhenReviewFeatureIsDenied(): void + { + $client = self::createClient(); + $token = $this->createPendingAccountToken('api-review-denied@example.test'); + $plainKey = $this->createPlainApiKey('apiusrrevdeny', ApiKeyStatus::ReadWrite); + $originalHash = $token->tokenHash(); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.users' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.users.review' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('POST', '/api/v1/admin/users/reviews/tokens/'.$token->uid().'/reissue?confirm=true', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.users.review', $payload['error']['context']['feature']); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->clear(); + $unchangedToken = $entityManager->find(AccountToken::class, $token->uid()); + self::assertInstanceOf(AccountToken::class, $unchangedToken); + self::assertSame($originalHash, $unchangedToken->tokenHash()); + self::assertSame(AccountTokenStatus::Pending, $unchangedToken->status()); + } finally { + $store->save($store->defaultOverrides(), 'test'); + $this->removeAccountTokens($token->uid()); + } + } + public function testOpenApiIncludesUsersEndpoint(): void { $client = self::createClient(); @@ -681,6 +894,36 @@ private function createPendingApprovalToken(string $email): AccountToken return $token; } + private function createPendingAccountToken(string $email, AccountTokenType $type = AccountTokenType::Invitation): AccountToken + { + $token = new AccountToken( + '69000000-0000-7000-8004-'.substr(md5($email), 0, 12), + hash('sha256', $email), + $type, + $email, + status: AccountTokenStatus::Pending, + ); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->persist($token); + $entityManager->flush(); + + return $token; + } + + private function removeAccountTokens(string ...$uids): void + { + if ([] === $uids) { + return; + } + + self::getContainer()->get(EntityManagerInterface::class)->getConnection()->executeStatement( + 'DELETE FROM account_token WHERE uid IN (?)', + [$uids], + [\Doctrine\DBAL\ArrayParameterType::STRING], + ); + } + /** * @return array */ diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index f575fcc6..4ee224d4 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -7,8 +7,12 @@ use App\Core\Access\AccessLevel; use App\Core\ActionLog\ActionLogEntry; use App\Core\ActionLog\ActionLogStatus; +use App\Core\AdminAcl\AdminFeatureAccessPolicy; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; @@ -19,6 +23,7 @@ use App\Core\Workflow\WorkflowResult; use App\Entity\AclGroup; use App\Entity\ExtensionPackage; +use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\UserAccountStatus; use App\Security\UserFlowConfig; use App\Setup\SetupCompletionMarker; @@ -382,7 +387,7 @@ public function testAdminRouteRequiresAdministrativeAccess(): void public function testAdminRouteAllowsAccessLevelEight(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin'); self::assertResponseIsSuccessful(); @@ -393,7 +398,7 @@ public function testAdminRegisteredBackendViewRouteRendersThroughRegistry(): voi { $manifest = $this->rootManifest(); $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/packages'); self::assertResponseIsSuccessful(); @@ -471,7 +476,7 @@ public function testAdminRegisteredBackendViewRouteRendersThroughRegistry(): voi public function testAdminOperationsViewListsTransientLiveOperationState(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); $run = $store->create('backend.cache_clear', [], 'Cache clear'); @@ -497,7 +502,7 @@ public function testAdminOperationsViewListsTransientLiveOperationState(): void public function testAdminOperationsCleanupWritesAuditEntry(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $logDir = self::getContainer()->getParameter('kernel.logs_dir'); foreach (glob($logDir.'/test/audit-*.log') ?: [] as $logFile) { @@ -517,10 +522,67 @@ public function testAdminOperationsCleanupWritesAuditEntry(): void self::assertStringContainsString('"result_status":"success"', $auditLog); } + public function testAdminOperationsFeatureReadOnlyKeepsActionsVisibleButDisabled(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/operations'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form input[name="_operations_action"][value="cleanup"]'); + self::assertSelectorExists('form input[name="_operations_action"][value="cleanup"] + button[disabled]'); + + $client->request('POST', '/admin/operations', [ + '_operations_action' => 'cleanup', + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAdminLogsFeatureReadOnlyHidesSensitiveSources(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + + $store->save([ + 'admin.logs' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/logs?source=audit'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="source"][value="message"]'); + self::assertSelectorNotExists('a[href*="source=audit"]'); + self::assertSelectorNotExists('a[href*="source=security_signal"]'); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminOperationDetailShowsRetainedActionLogEntries(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $store = self::getContainer()->get(LiveOperationRunStore::class); self::assertInstanceOf(LiveOperationRunStore::class, $store); $run = $store->create('backend.cache_clear', [], 'Cache clear'); @@ -550,19 +612,52 @@ public function testAdminOperationDetailShowsRetainedActionLogEntries(): void public function testAdminLogsViewReadsSelectedLogSource(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); - $logDir = self::getContainer()->getParameter('kernel.logs_dir'); - $logFile = $logDir.'/test/access-2099-01-01.log'; - - if (!is_dir($logDir.'/test')) { - mkdir($logDir.'/test', 0775, true); - } - foreach (glob($logDir.'/test/access-*.log') ?: [] as $existingLogFile) { - @unlink($existingLogFile); - } - file_put_contents($logFile, '[2099-01-01T10:00:00.000000+00:00] access.INFO: access.request {"method":"GET","path":"/admin/logs","route":"backend_admin_route","http_status":200,"ip":"127.0.0.1","city":"n/a","state":"n/a","country":"n/a","continent":"n/a"} []'.PHP_EOL); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); + $accessLogContext = [ + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'http_status' => 200, + 'client_ip' => '127.0.0.1', + ]; + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000900', + 'occurred_at' => '2099-01-01 10:00:00', + 'request_id' => 'request-admin-logs', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'surface' => 'admin', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => 12, + 'visitor_id' => hash('sha256', 'test-visitor'), + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '127.0.0.1', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Test Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => 100, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => json_encode($accessLogContext, JSON_THROW_ON_ERROR), + ]); $connection->insert('access_statistic_event', [ 'uid' => '99999999-0000-7000-8000-000000000901', 'occurred_at' => '2099-01-01 10:00:00', @@ -591,12 +686,24 @@ public function testAdminLogsViewReadsSelectedLogSource(): void 'continent' => 'n/a', 'metadata' => '{}', ]); + $logDir = (string) self::getContainer()->getParameter('kernel.logs_dir'); + $applicationLog = $logDir.'/test.log'; + $previousApplicationLog = is_file($applicationLog) ? file_get_contents($applicationLog) : null; + $applicationLine = '[2099-01-01T10:02:00.000000+00:00] app.ERROR: app.functional_failure {"code":"app.functional_failure","request_id":"functional-application-request"} []'; + if (!is_dir($logDir)) { + mkdir($logDir, 0777, true); + } + $applicationPrefix = is_string($previousApplicationLog) && '' !== $previousApplicationLog ? rtrim($previousApplicationLog).PHP_EOL : ''; + file_put_contents($applicationLog, $applicationPrefix.$applicationLine.PHP_EOL); + $applicationEntryId = substr(hash('sha256', "application\0test.log\0".$applicationLine), 0, 24); try { $client->request('GET', '/admin/logs?source=access&q=/admin/logs'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Logs'); + self::assertSelectorTextContains('.system-tabs', 'Application'); + self::assertSelectorTextContains('.system-tabs', 'Security signals'); self::assertSelectorTextContains('.system-backend-log-table', 'GET /admin/logs'); self::assertSelectorTextContains('.system-backend-log-table', 'Details'); @@ -605,7 +712,18 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Log event'); self::assertSelectorTextContains('body', '127.0.0.1'); - self::assertSelectorTextContains('.system-code-block', 'access.request'); + self::assertSelectorTextContains('body', 'backend_admin_route'); + + $client->request('GET', '/admin/logs?source=application&level%5B0%5D=ERROR&q=functional-application-request'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.system-backend-log-table', 'app.functional_failure'); + + $client->request('GET', '/admin/logs/'.$applicationEntryId.'?source=application'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Log event'); + self::assertSelectorTextContains('body', 'functional-application-request'); $client->request('GET', '/admin/statistics?statistics_window=all'); @@ -616,8 +734,13 @@ public function testAdminLogsViewReadsSelectedLogSource(): void self::assertSelectorTextContains('body', 'Unique visitors'); self::assertSelectorTextContains('body', 'Top browsers'); } finally { - @unlink($logFile); + $connection->delete('access_log_entry', ['request_id' => 'request-admin-logs']); $connection->delete('access_statistic_event', ['route' => 'backend_admin_route']); + if (is_string($previousApplicationLog)) { + file_put_contents($applicationLog, $previousApplicationLog); + } else { + @unlink($applicationLog); + } } } @@ -656,6 +779,60 @@ public function testAdminOperationDetailExposesReviewContinuation(): void } } + public function testAdminOperationContinuationRequiresTargetFeatureMutation(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, 8); + $store = self::getContainer()->get(LiveOperationRunStore::class); + self::assertInstanceOf(LiveOperationRunStore::class, $store); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); + $run = $store->create('package.install.verify', [], 'Install package'); + $result = WorkflowResult::requiresReview(null, [ + Message::info( + OperationMessageCode::OPERATION_ACTION_REQUIRED, + OperationMessageKey::OPERATION_ACTION_REQUIRED, + ['%operation%' => 'Install package'], + ), + ], [ + 'live_operation_continuation' => [ + 'operation' => 'package.install.apply', + 'payload' => ['install_id' => 'aaaaaaaaaaaaaaaaaaaaaaaa', 'package' => 'demo-module'], + 'label' => 'Install package', + ], + ]); + $store->finish($run['operation_id'], false, $result->toArray()); + + $overrides->save([ + 'admin.operations' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.packages' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $crawler = $client->request('GET', '/admin/operations/'.$run['operation_id']); + + self::assertResponseIsSuccessful(); + $form = $crawler->filter(sprintf('form[action="/admin/operations/%s/continue"]', $run['operation_id'])); + self::assertCount(1, $form); + self::assertNotNull($form->filter('button[type="submit"]')->attr('disabled')); + + $client->submit($form->form()); + + self::assertResponseStatusCodeSame(401); + } finally { + $overrides->save($overrides->defaultOverrides(), 'test'); + @unlink(dirname($store->outputPath($run['operation_id'])).'/'.$run['operation_id'].'.json'); + @unlink($store->outputPath($run['operation_id'])); + @unlink($store->pidPath($run['operation_id'])); + } + } + public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): void { $client = self::createClient(); @@ -670,7 +847,7 @@ public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): voi } try { - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $crawler = $client->request('GET', '/admin/packages'); self::assertSelectorNotExists('.system-table tr[data-package-name="demo-module"]'); @@ -702,6 +879,58 @@ public function testAdminBackendActionFormsRunPackageDiscoveryImmediately(): voi } } + public function testDelegatedAdminsSeeDisabledPackageLifecycleActionsByDefault(): void + { + $client = self::createClient(); + $this->removePackageByName('test-admin-hidden-lifecycle'); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $package = new ExtensionPackage( + '00000000-0000-7000-8000-000000000496', + [PackageScope::Module], + 'test-admin-hidden-lifecycle', + 'packages/test-admin-hidden-lifecycle', + ExtensionPackageStatus::Inactive, + [ + 'display_name' => 'Test Admin Hidden Lifecycle', + 'description' => 'Lifecycle ACL fixture', + 'manifest' => [ + 'PACKAGE_NAME' => 'Test Admin Hidden Lifecycle', + 'PACKAGE_VERSION' => '1.0.0', + ], + ], + manifestVersion: '1.0.0', + ); + $entityManager->persist($package); + $entityManager->flush(); + + try { + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/packages'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('a[href="/admin/packages/test-admin-hidden-lifecycle"]'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/activate"]'); + + $client->request('GET', '/admin/packages/test-admin-hidden-lifecycle'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Test Admin Hidden Lifecycle'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/activate"]'); + self::assertSelectorNotExists('a[href="/admin/packages/test-admin-hidden-lifecycle/delete"]'); + self::assertSelectorExists('button[disabled]'); + self::assertStringContainsString('Activate', (string) $client->getResponse()->getContent()); + self::assertStringContainsString('Delete', (string) $client->getResponse()->getContent()); + + $client->request('GET', '/admin/packages/test-admin-hidden-lifecycle/activate'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.system-alert-warning', 'You may review this action, but mutation is not allowed by the current ACL policy.'); + self::assertStringNotContainsString('Activate package', (string) $client->getResponse()->getContent()); + } finally { + $this->removePackageByName('test-admin-hidden-lifecycle'); + } + } + public function testAdminPackageDetailAndLifecycleReviewRoutesRender(): void { $client = self::createClient(); @@ -728,7 +957,7 @@ public function testAdminPackageDetailAndLifecycleReviewRoutesRender(): void file_put_contents($packageDir.'/README.md', "# Lifecycle README\n\nThis package has **markdown** docs."); file_put_contents($assetsDir.'/preview.svg', ''); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $package = new ExtensionPackage( '00000000-0000-7000-8000-000000000498', @@ -813,7 +1042,7 @@ public function testAdminPackageDeactivationReviewIncludesActiveDependents(): vo $this->removePackageByName('test-dependent-theme'); $this->removePackageByName('test-dependent-captcha'); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $entityManager = self::getContainer()->get(EntityManagerInterface::class); $theme = new ExtensionPackage( '00000000-0000-7000-8000-000000000596', @@ -926,15 +1155,61 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertSelectorExists('form#admin-settings-users'); self::assertSelectorExists(sprintf('input[name="%s"][min="1"][max="3650"]', UserFlowConfig::DELETED_USER_RETENTION_DAYS_KEY)); + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/settings/security'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Security settings'); self::assertSelectorExists('form#admin-settings-security'); self::assertSelectorExists('select[name="security.captcha.provider"]'); + self::assertSelectorExists(sprintf('select[name="%s"]', RateLimitPolicyCatalogue::MODE_KEY)); self::assertSelectorExists(sprintf('input[name="%s"]', ConfigAuditLogPolicy::ENABLED_KEY)); self::assertSelectorExists(sprintf('input[name="%s[]"]', ConfigAuditLogPolicy::EVENTS_KEY)); + self::assertSelectorExists('input[name="security.signals.retention_days"]'); + + $client->request('GET', '/admin/settings/logging'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Log settings'); + self::assertSelectorExists('form#admin-settings-logging'); + self::assertSelectorExists('input[name="logging.database.message_retention_days"]'); + self::assertSelectorExists('input[name="logging.database.audit_retention_days"]'); + self::assertSelectorExists('input[name="logging.database.access_retention_days"]'); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set('statistics.geoip.maxmind.license_key', '', ConfigValueType::String, sensitive: true); + + $this->loginUserWithLevel($client, AccessLevel::OWNER); + $client->request('GET', '/admin/settings/statistics'); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Statistics settings'); + self::assertSelectorExists('form#admin-settings-statistics'); + self::assertSelectorExists('input[name="statistics.geoip.enabled"]'); + self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][type="password"]'); + self::assertSelectorExists('a[href="https://www.maxmind.com/en/geolite2/signup"]'); + self::assertSelectorTextContains('h3', 'GeoIP2 status'); + self::assertSelectorTextContains('.system-definition-list', 'Provider'); + self::assertSelectorNotExists('input[name="_backend_action"][value="geoip_database_update"]'); + + $config->set('statistics.geoip.maxmind.license_key', 'saved-test-key', ConfigValueType::String, sensitive: true); + $client->request('GET', '/admin/settings/statistics'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="_backend_action"][value="geoip_database_update"]'); + + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/settings/statistics'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form#admin-settings-statistics'); + self::assertSelectorExists('input[name="statistics.enabled"]'); + self::assertSelectorExists('input[name="statistics.geoip.enabled"][disabled]'); + self::assertSelectorExists('input[name="statistics.geoip.maxmind.license_key"][disabled]'); + self::assertSelectorExists('input[name="_backend_action"][value="geoip_database_update"] + input + button[disabled]'); + + $this->loginUserWithLevel($client, AccessLevel::OWNER); $client->request('GET', '/admin/settings/scheduler'); self::assertResponseIsSuccessful(); @@ -954,10 +1229,138 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertStringNotContainsString('$_SERVER', (string) $client->getResponse()->getContent()); } + public function testSecuritySettingsSectionIsHiddenAndRejectsPostsForDelegatedAdmins(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + + $client->request('GET', '/admin/settings/security'); + + self::assertResponseStatusCodeSame(401); + + $client->request('POST', '/admin/settings/security', [ + '_form_id' => 'admin-settings-security', + '_csrf_token' => 'direct-post', + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + ]); + + self::assertResponseStatusCodeSame(401); + } + + public function testSchedulerSettingsReadOnlyDisablesFieldsAndRejectsPosts(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $store->save([ + 'admin.settings.scheduler' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); + + try { + $client->request('GET', '/admin/settings/scheduler'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form#admin-settings-scheduler'); + self::assertSelectorExists('input[name="scheduler.enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.get_auth_enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.package_action_queues_enabled"][disabled]'); + self::assertSelectorExists('input[name="scheduler.web_trigger_enabled"][disabled]'); + self::assertSelectorNotExists('form#admin-settings-scheduler button[type="submit"]'); + + $client->request('POST', '/admin/settings/scheduler', [ + '_form_id' => 'admin-settings-scheduler', + '_csrf_token' => 'direct-post', + 'scheduler.enabled' => '0', + ]); + + self::assertResponseStatusCodeSame(401); + } finally { + $store->save($store->defaultOverrides(), 'test'); + } + } + + public function testAclSettingsMatrixIsOwnerGatedAndRendersFeatureRegistry(): void + { + $client = self::createClient(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $adminAcl = self::getContainer()->get(AdminFeatureAccessPolicy::class); + self::assertInstanceOf(AdminFeatureAccessPolicy::class, $adminAcl); + $store = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $store); + $logDir = self::getContainer()->getParameter('kernel.logs_dir'); + foreach (glob($logDir.'/test/audit-*.log') ?: [] as $logFile) { + @unlink($logFile); + } + $existingGroup = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => 'acl_matrix_test']); + if ($existingGroup instanceof AclGroup) { + $entityManager->remove($existingGroup); + $entityManager->flush(); + $adminAcl->resetCache(); + } + $group = new AclGroup( + '72000000-0000-7000-8000-000000000715', + 'acl_matrix_test', + 'ACL Matrix Test', + AccessLevel::ADMIN, + ); + $entityManager->persist($group); + $entityManager->flush(); + $adminAcl->resetCache(); + + $this->loginUserWithLevel($client, AccessLevel::OWNER); + $crawler = $client->request('GET', '/admin/settings/acl'); + + try { + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'ACL'); + self::assertSelectorExists('form#admin-settings-acl'); + self::assertSelectorTextContains('#acl-surface-admin', 'Packages and themes'); + self::assertSelectorExists('select[name="acl[admin.packages][state]"]'); + self::assertSelectorExists('select[name="acl[admin.settings.statistics.geoip][state]"]'); + self::assertGreaterThan(0, $crawler->filter('option[value=""]')->count()); + self::assertSelectorTextContains('#acl-surface-admin', 'Read-only'); + self::assertGreaterThan(0, $crawler->filter('select[disabled]')->count()); + + $form = $crawler->filter('form#admin-settings-acl')->form([ + 'acl[admin.packages][state]' => AdminPermissionState::Denied->value, + ]); + $client->submit($form); + + self::assertResponseRedirects('/admin/settings/acl'); + $auditLog = implode(PHP_EOL, array_map(static fn (string $file): string => (string) file_get_contents($file), glob($logDir.'/test/audit-*.log') ?: [])); + self::assertStringContainsString('settings.acl.save', $auditLog); + self::assertStringContainsString('"section":"acl"', $auditLog); + self::assertStringContainsString('"feature":"admin.packages"', $auditLog); + self::assertStringContainsString('"previous_state":"visible"', $auditLog); + self::assertStringContainsString('"next_state":"denied"', $auditLog); + self::assertStringContainsString('"setting_keys":["acl"]', $auditLog); + self::assertStringNotContainsString('"_audit"', $auditLog); + + $this->loginUserWithLevel($client, AccessLevel::ADMIN); + $client->request('GET', '/admin/settings/acl'); + + self::assertResponseStatusCodeSame(401); + } finally { + $cleanupGroup = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => $group->identifier()]); + if ($cleanupGroup instanceof AclGroup) { + $entityManager->remove($cleanupGroup); + $entityManager->flush(); + $adminAcl->resetCache(); + } + $store->save($store->defaultOverrides(), 'test'); + } + } + public function testAdminSettingsFormsPersistCoreSettings(): void { $client = self::createClient(); - $this->loginUserWithLevel($client, 8); + $this->loginUserWithLevel($client, AccessLevel::ADMIN); $config = self::getContainer()->get(Config::class); $logDir = self::getContainer()->getParameter('kernel.logs_dir'); @@ -1021,6 +1424,28 @@ public function testAdminSettingsFormsRenderValidationErrors(): void self::assertStringContainsString('The submitted value does not match the expected format.', $html); } + public function testAdminSettingsFormsDoNotReRenderSubmittedSensitiveValuesAfterValidationErrors(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::OWNER); + $crawler = $client->request('GET', '/admin/settings/statistics'); + $form = $crawler->selectButton('Save settings')->form([ + 'statistics.enabled' => '1', + 'statistics.respect_do_not_track' => '1', + MaxMindGeoIpConfig::ENABLED_KEY => '1', + MaxMindGeoIpConfig::DATABASE_PATH_KEY => '', + MaxMindGeoIpConfig::LICENSE_KEY_KEY => 'submitted-geoip-secret', + ]); + + $client->submit($form); + + self::assertResponseIsSuccessful(); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('This field is required.', $html); + self::assertStringNotContainsString('submitted-geoip-secret', $html); + self::assertSelectorExists(sprintf('input[name="%s"][type="password"]', MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + public function testAdminTestUserHelperRestoresUsableAccountStatus(): void { $client = self::createClient(); diff --git a/tests/Controller/PublicContentErrorPageTest.php b/tests/Controller/PublicContentErrorPageTest.php index 232c12ed..d5fab69a 100644 --- a/tests/Controller/PublicContentErrorPageTest.php +++ b/tests/Controller/PublicContentErrorPageTest.php @@ -20,6 +20,7 @@ public function testItReturnsNotFoundForMissingContent(): void self::assertSelectorTextContains('h1', 'Page not found'); self::assertSelectorTextContains('.system-frontend-error-reference', 'Request ID'); self::assertSelectorNotExists('.system-frontend-error-reference dd:nth-of-type(2)'); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); } public function testItReturnsForbiddenForReservedCronPrefix(): void diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php new file mode 100644 index 00000000..cc7c07af --- /dev/null +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -0,0 +1,537 @@ +server('198.51.100.10')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 16; ++$i) { + $client->request('GET', '/home'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/home'); + + self::assertResponseStatusCodeSame(429); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertNotNull($client->getResponse()->headers->get('Retry-After')); + self::assertStringContainsString('Request ID', $client->getResponse()->getContent()); + self::assertStringNotContainsString('website.deliberate', $client->getResponse()->getContent()); + self::assertStringNotContainsString('ip_bucket', $client->getResponse()->getContent()); + } + + public function testApiRateLimitReturnsJsonWithRequestIdAndNoInternalDetails(): void + { + $client = self::createClient(server: $this->server('198.51.100.11')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('GET', '/api/v1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/api/v1'); + + self::assertResponseStatusCodeSame(429); + self::assertStringStartsWith('application/json', (string) $client->getResponse()->headers->get('Content-Type')); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertNotNull($client->getResponse()->headers->get('Retry-After')); + + $payload = json_decode($client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame('rate_limit.exceeded', $payload['error']['code']); + self::assertArrayHasKey('request_id', $payload['error']['context']); + self::assertStringNotContainsString('api.public_read', $client->getResponse()->getContent()); + self::assertStringNotContainsString('ip_bucket', $client->getResponse()->getContent()); + } + + public function testSuspiciousProbeReturnsGenericBadRequestEvenWhenModeIsOff(): void + { + $client = self::createClient(server: $this->server('198.51.100.12')); + $this->setMode(RateLimitProfile::Off); + + $client->request('GET', '/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + self::assertStringContainsString('Invalid Request', $client->getResponse()->getContent()); + self::assertStringContainsString('Request-ID', $client->getResponse()->getContent()); + self::assertStringNotContainsString('suspicious.probe', $client->getResponse()->getContent()); + } + + public function testLiveSuspiciousProbeIsBlockedBeforeLiveApiExclusion(): void + { + $client = self::createClient(server: $this->server('198.51.100.17')); + $this->setMode(RateLimitProfile::Off); + + $client->request('GET', '/api/live/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + } + + public function testApiSuspiciousProbeIsBlockedBeforeApiDisabledGate(): void + { + $client = self::createClient(server: $this->server('198.51.100.27')); + $this->setMode(RateLimitProfile::Off); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::ENABLED_KEY, false, ConfigValueType::Boolean); + + $client->request('GET', '/api/v1/.env'); + + self::assertResponseStatusCodeSame(400); + self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control')); + } finally { + $config->set(ApiFeaturePolicy::ENABLED_KEY, true, ConfigValueType::Boolean); + } + } + + public function testPrefetchAndLiveApiPathsAreNotChargedToOrdinaryLimiter(): void + { + $client = self::createClient(server: $this->server('198.51.100.13')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/home', server: ['HTTP_SEC_PURPOSE' => 'prefetch']); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + $client->request('GET', '/api/live/status'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + public function testBuildAssetsAreNotChargedToOrdinaryLimiter(): void + { + $client = self::createClient(server: $this->server('198.51.100.14')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/build/app.js'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + public function testInvalidLoginSubmissionsSpendLoginBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.15')); + $this->setMode(RateLimitProfile::Standard); + + for ($i = 0; $i < 5; ++$i) { + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-user', + 'password' => 'wrong-password', + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-user', + 'password' => 'wrong-password', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerRequestsSpendApiBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.16')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer invalid%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid31.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerOptionsRequestsSpendApiBudgetBeforeAuthenticationResponse(): void + { + $client = self::createClient(server: $this->server('198.51.100.22')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 30; ++$i) { + $client->request('OPTIONS', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer option%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer option31.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testCorsBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCircuit(): void + { + $client = self::createClient(server: $this->server('198.51.100.28')); + $this->setMode(RateLimitProfile::Panic); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => sprintf('Bearer corsadm%02d.invalid-secret', $i), + ]); + self::assertNotSame(204, $client->getResponse()->getStatusCode()); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer corsadm08.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + } + } + + public function testMalformedBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCircuit(): void + { + $client = self::createClient(server: $this->server('198.51.100.30')); + $this->setMode(RateLimitProfile::Panic); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer ', + ]); + self::assertNotSame(204, $client->getResponse()->getStatusCode()); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer ', + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + } + } + + public function testRecoveryLoginRendersSpendRecoveryBucket(): void + { + $client = self::createClient(server: $this->server('198.51.100.23')); + $this->setMode(RateLimitProfile::Standard); + + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('GET', '/user/login?bypass=1'); + + self::assertResponseStatusCodeSame(429); + } + + public function testPanicRecoveryLoginRenderAndSubmitAreNotRateLimited(): void + { + $client = self::createClient(server: $this->server('198.51.100.26')); + $this->setMode(RateLimitProfile::Panic); + + $client->request('GET', '/user/login?bypass=1'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('POST', '/user/login', parameters: [ + 'username' => 'missing-recovery-user', + 'password' => 'wrong-password', + ]); + + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + public function testRotatingInvalidBearerPrefixesDoNotBypassApiWriteBudget(): void + { + $client = self::createClient(server: $this->server('198.51.100.18')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 15; ++$i) { + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer write%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer write16.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testInvalidBearerAdminMutationsSpendAuthFailureBudget(): void + { + $client = self::createClient(server: $this->server('198.51.100.21')); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 7; ++$i) { + $client->request('PATCH', '/api/v1/admin/settings/general', server: [ + 'HTTP_AUTHORIZATION' => sprintf('Bearer admin%02d.invalid-secret', $i), + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('PATCH', '/api/v1/admin/settings/general', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer admin08.invalid-secret', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testReadOnlyOwnerApiKeyMutationsSpendApiWriteBudgetBeforeDenial(): void + { + $prefix = 'rlownro'; + $client = self::createClient(server: $this->server('198.51.100.24')); + $plainKey = $this->createOwnerApiKey($prefix, ApiKeyStatus::ReadOnly); + + try { + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 15; ++$i) { + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + + $client->request('POST', '/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $this->removeApiKey($prefix); + } + } + + public function testReadOnlyOwnerApiKeyUnsafePreflightsSpendAdminBudgetBeforeDenial(): void + { + $prefix = 'rlownpf'; + $client = self::createClient(server: $this->server('198.51.100.29')); + $plainKey = $this->createOwnerApiKey($prefix, ApiKeyStatus::ReadOnly); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + try { + $this->setMode(RateLimitProfile::Panic); + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + + for ($i = 0; $i < 7; ++$i) { + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertResponseStatusCodeSame(403); + } + + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: [ + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseStatusCodeSame(429); + } finally { + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + $this->removeApiKey($prefix); + } + } + + public function testSchedulerIntervalUsesStableBearerCredentialAcrossVisitorChanges(): void + { + $first = self::createClient(server: [ + ...$this->server('198.51.100.25'), + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]); + $this->setMode(RateLimitProfile::Standard); + + $first->request('GET', '/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + ]); + self::assertNotSame(429, $first->getResponse()->getStatusCode()); + + self::ensureKernelShutdown(); + + $second = self::createClient(server: [ + ...$this->server('198.51.100.25'), + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ]); + $second->request('GET', '/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + ]); + + self::assertResponseStatusCodeSame(429); + } + + public function testSignedInSchedulerIntervalKeepsIpAnchorAcrossRotatingQueryCredentials(): void + { + $client = self::createClient(server: $this->server('198.51.100.31')); + $this->loginTestUser($client, $this->adminUser()); + $this->setMode(RateLimitProfile::Standard); + + $client->request('GET', '/cron/run?auth=scheduler-token-a'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + + $client->request('GET', '/cron/run?auth=scheduler-token-b'); + + self::assertResponseStatusCodeSame(429); + } + + public function testValidOwnerApiKeyUsesPostAuthOwnerExemption(): void + { + $prefix = 'rlowner'; + $client = self::createClient(server: $this->server('198.51.100.19')); + $plainKey = $this->createOwnerApiKey($prefix); + + try { + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 35; ++$i) { + $client->request('GET', '/api/v1', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } finally { + $this->removeApiKey($prefix); + } + } + + public function testSignedInOwnerUsesPostAuthOwnerExemption(): void + { + $client = self::createClient(server: $this->server('198.51.100.20')); + $this->loginTestUser($client, $this->adminUser()); + $this->setMode(RateLimitProfile::Panic); + + for ($i = 0; $i < 12; ++$i) { + $client->request('GET', '/home'); + self::assertNotSame(429, $client->getResponse()->getStatusCode()); + } + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcementControllerTest', + 'HTTP_X_RATE_LIMIT_TESTING' => '1', + ]; + } + + private function setMode(RateLimitProfile $profile): void + { + $cache = self::getContainer()->get('cache.rate_limiter'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, $profile->value, ConfigValueType::String, modifiedBy: 'test'); + } + + private function adminUser(): UserAccount + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $user = $entityManager->getRepository(UserAccount::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(UserAccount::class, $user); + + return $user; + } + + private function createOwnerApiKey(string $prefix, ApiKeyStatus $status = ApiKeyStatus::ReadWrite): string + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $this->removeApiKey($prefix); + + $vault = self::getContainer()->get(ApiKeyVault::class); + self::assertInstanceOf(ApiKeyVault::class, $vault); + + $plainKey = $vault->generatePlainKey($prefix); + $apiKey = new ApiKey( + '63000000-0000-7000-8000-'.substr(md5($prefix), 0, 12), + $prefix, + $vault->hmac($plainKey), + $vault->encrypt($plainKey, $prefix), + $this->adminUser(), + $status, + ); + + $entityManager->persist($apiKey); + $entityManager->flush(); + + return $plainKey; + } + + private function removeApiKey(string $prefix): void + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $existing = $entityManager->getRepository(ApiKey::class)->findOneBy(['prefix' => $prefix]); + if ($existing instanceof ApiKey) { + $entityManager->remove($existing); + $entityManager->flush(); + } + } +} diff --git a/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php b/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php new file mode 100644 index 00000000..486c1bf4 --- /dev/null +++ b/tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php @@ -0,0 +1,102 @@ +get(EntityManagerInterface::class); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + self::assertInstanceOf(AdminFeatureOverrideStore::class, $overrides); + $policy = self::getContainer()->get(AdminFeatureAccessPolicy::class); + self::assertInstanceOf(AdminFeatureAccessPolicy::class, $policy); + + $adminGroup = $this->upsertGroup($entityManager, 'admin_acl_grant', AccessLevel::ADMIN); + $userGroup = $this->upsertGroup($entityManager, 'user_acl_grant', AccessLevel::USER); + $policy->resetCache(); + $overrides->save([ + 'admin.settings.api' => [ + 'state' => AdminPermissionState::Denied->value, + 'groups' => [ + $adminGroup->identifier() => AdminPermissionState::Visible->value, + $userGroup->identifier() => AdminPermissionState::Mutable->value, + ], + ], + ], 'test'); + + try { + self::assertSame( + AdminPermissionState::Visible, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::ADMIN, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Denied, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::DIRECTOR, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Denied, + $policy->state('admin.settings.api', AccessActor::fromAccess(AccessLevel::ADMIN, [$userGroup->identifier()])), + ); + + $overrides->save([ + 'admin.settings.statistics' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [ + $adminGroup->identifier() => AdminPermissionState::Visible->value, + ], + ], + ], 'test'); + + self::assertSame( + AdminPermissionState::Visible, + $policy->state('admin.settings.statistics', AccessActor::fromAccess(AccessLevel::ADMIN, [$adminGroup->identifier()])), + ); + self::assertSame( + AdminPermissionState::Mutable, + $policy->state('admin.settings.statistics', AccessActor::fromAccess(AccessLevel::ADMIN)), + ); + } finally { + $overrides->save([], 'test'); + $entityManager->remove($adminGroup); + $entityManager->remove($userGroup); + $entityManager->flush(); + $policy->resetCache(); + } + } + + private function upsertGroup(EntityManagerInterface $entityManager, string $identifier, int $minRole): AclGroup + { + $existing = $entityManager->getRepository(AclGroup::class)->findOneBy(['identifier' => $identifier]); + if ($existing instanceof AclGroup) { + $existing->changeMinRole($minRole); + $entityManager->flush(); + + return $existing; + } + + $group = new AclGroup( + '71000000-0000-7000-8000-'.substr(md5($identifier), 0, 12), + $identifier, + ucfirst(str_replace('_', ' ', $identifier)), + $minRole, + ); + $entityManager->persist($group); + $entityManager->flush(); + + return $group; + } +} diff --git a/tests/Core/AdminAcl/AdminFeatureCacheTest.php b/tests/Core/AdminAcl/AdminFeatureCacheTest.php new file mode 100644 index 00000000..6d208938 --- /dev/null +++ b/tests/Core/AdminAcl/AdminFeatureCacheTest.php @@ -0,0 +1,73 @@ +definitions()); + self::assertCount(1, (new AdminFeatureRegistry([$provider], $cache))->definitions()); + self::assertSame(1, $provider->calls); + + (new AdminFeatureRegistry([$provider], $cache))->resetCache(); + + self::assertCount(1, (new AdminFeatureRegistry([$provider], $cache))->definitions()); + self::assertSame(2, $provider->calls); + } + + public function testFeatureOverrideStoreCachesConfiguredOverrides(): void + { + $connection = $this->createMock(Connection::class); + $cache = new ArrayAdapter(); + $payload = [ + 'admin.settings.api' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ]; + + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with('SELECT value FROM config_entry WHERE config_key = ?', [AdminFeatureOverrideStore::CONFIG_KEY]) + ->willReturn(json_encode($payload, JSON_THROW_ON_ERROR)); + + self::assertSame($payload, (new AdminFeatureOverrideStore(new Config($connection), cache: $cache))->overrides()); + self::assertSame($payload, (new AdminFeatureOverrideStore(new Config($connection), cache: $cache))->overrides()); + } +} + +final class CountingFeatureProvider implements AdminFeatureProviderInterface +{ + public int $calls = 0; + + public function adminFeatures(): array + { + ++$this->calls; + + return [ + new AdminFeatureDefinition( + 'admin.settings.api', + 'admin.acl.features.admin_settings_api.label', + 'admin.acl.features.admin_settings_api.description', + 'admin.acl.categories.settings', + ), + ]; + } +} diff --git a/tests/Core/Config/ConfigTest.php b/tests/Core/Config/ConfigTest.php index ce868618..786b1551 100644 --- a/tests/Core/Config/ConfigTest.php +++ b/tests/Core/Config/ConfigTest.php @@ -122,6 +122,24 @@ public function testItSetsConfigurationValues(): void self::assertSame('test', $row['modified_by']); } + public function testItStoresSensitiveConfigurationFlag(): void + { + $connection = $this->connection(); + $config = new Config($connection); + + self::assertTrue($config->set('statistics.geoip.maxmind.license_key', 'secret-value', ConfigValueType::String, sensitive: true, modifiedBy: 'test')); + + $row = $connection->fetchAssociative('SELECT value, value_type, sensitive, modified_by FROM config_entry WHERE config_key = ?', [ + 'statistics.geoip.maxmind.license_key', + ]); + + self::assertIsArray($row); + self::assertSame('"secret-value"', $row['value']); + self::assertSame('string', $row['value_type']); + self::assertSame(1, (int) $row['sensitive']); + self::assertSame('test', $row['modified_by']); + } + public function testItReportsInvalidConfigurationKeys(): void { $reporter = new RecordingConfigMessageReporter(); diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php new file mode 100644 index 00000000..07f3bc22 --- /dev/null +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -0,0 +1,150 @@ +connection()); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('statistics', [ + 'statistics.enabled' => '1', + 'statistics.respect_do_not_track' => '1', + MaxMindGeoIpConfig::ENABLED_KEY => '0', + MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '', + ], 'test', AccessActor::fromAccess(AccessLevel::OWNER)); + + self::assertTrue($result->isValid()); + self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + + public function testItPreservesExistingSensitiveSettingsWhenSubmittedProtectedPlaceholder(): void + { + $config = new Config($this->connection()); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('statistics', [ + 'statistics.enabled' => '1', + 'statistics.respect_do_not_track' => '1', + MaxMindGeoIpConfig::ENABLED_KEY => '1', + MaxMindGeoIpConfig::DATABASE_PATH_KEY => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, + MaxMindGeoIpConfig::LICENSE_KEY_KEY => '[protected]', + ], 'test', AccessActor::fromAccess(AccessLevel::OWNER)); + + self::assertTrue($result->isValid()); + self::assertSame('secret-license-key', $config->get(MaxMindGeoIpConfig::LICENSE_KEY_KEY)); + } + + public function testItInvalidatesSuspiciousProbePatternCacheWhenSecuritySettingsChange(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, '#/old-probe$#', ConfigValueType::String); + $cache = new ArrayAdapter(); + $matcher = new SuspiciousProbePathMatcher($config, cache: $cache); + + self::assertTrue($matcher->isProbe('/old-probe')); + + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + $matcher, + ); + + $result = $handler->submit('security', [ + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Strict->value, + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', + SuspiciousProbePathMatcher::PATTERNS_KEY => '#/new-probe$#', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertSame(RateLimitProfile::Strict->value, $config->get(RateLimitPolicyCatalogue::MODE_KEY)); + self::assertTrue((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/new-probe')); + self::assertFalse((new SuspiciousProbePathMatcher($config, cache: $cache))->isProbe('/old-probe')); + } + + public function testItRejectsInvalidRateLimitModes(): void + { + $config = new Config($this->connection()); + $handler = new CoreSettingsFormHandler( + $this->registry(), + $config, + new FormSubmissionHandler(), + $this->createStub(EntityManagerInterface::class), + ); + + $result = $handler->submit('security', [ + 'security.captcha.enabled' => '0', + 'security.captcha.provider' => 'none', + RateLimitPolicyCatalogue::MODE_KEY => 'forever', + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', + SuspiciousProbePathMatcher::PATTERNS_KEY => SuspiciousProbePathMatcher::defaultPatternText(), + ], 'test'); + + self::assertFalse($result->isValid()); + self::assertSame(['admin.settings.form.errors.choice'], $result->errors()[RateLimitPolicyCatalogue::MODE_KEY]); + self::assertNull($config->get(RateLimitPolicyCatalogue::MODE_KEY)); + } + + private function registry(): CoreSettingsRegistry + { + $projectDir = dirname(__DIR__, 3); + + return new CoreSettingsRegistry(new TranslationLanguageCatalog($projectDir), new SystemPackageMetadataProvider($projectDir)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 7ed2b3b7..0fde14f6 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -5,13 +5,20 @@ namespace App\Tests\Core\Config; use App\Api\ApiFeaturePolicy; +use App\Core\AdminAcl\AdminFeatureDefaults; +use App\Core\AdminAcl\AdminFeatureOverrideStore; use App\Core\Config\Settings\CoreSettingDefinition; use App\Core\Config\Settings\CoreConfigDefaultProvider; use App\Core\Config\Settings\CoreSettingsRegistry; +use App\Core\Log\DatabaseLogRetentionPolicy; +use App\Core\Geo\MaxMindGeoIpConfig; use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Statistics\AccessStatisticsPolicy; use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; +use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\RateLimit\RateLimitPolicyCatalogue; +use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; use App\View\SystemPackageMetadataProvider; use PHPUnit\Framework\TestCase; @@ -25,6 +32,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void $general = $registry->definitions('general'); $users = $registry->definitions('users'); $security = $registry->definitions('security'); + $logging = $registry->definitions('logging'); $statistics = $registry->definitions('statistics'); $api = $registry->definitions('api'); @@ -56,19 +64,50 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.enabled', 'security.captcha.provider', 'security.captcha.preview', + RateLimitPolicyCatalogue::MODE_KEY, ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + SuspiciousProbePathMatcher::PATTERNS_KEY, ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $security)); self::assertSame(FormInputType::Captcha, $security[2]->formField()->inputType()); - self::assertSame(FormInputType::MultiSelect, $security[4]->formField()->inputType()); - self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[4]->defaultValue()); + self::assertSame(FormInputType::Select, $security[3]->formField()->inputType()); + self::assertSame(RateLimitProfile::Standard->value, $security[3]->defaultValue()); + self::assertSame([ + RateLimitProfile::Off->value => 'admin.settings.options.rate_limit_mode.off', + RateLimitProfile::Standard->value => 'admin.settings.options.rate_limit_mode.standard', + RateLimitProfile::Strict->value => 'admin.settings.options.rate_limit_mode.strict', + RateLimitProfile::Panic->value => 'admin.settings.options.rate_limit_mode.panic', + ], $security[3]->formField()->options()); + self::assertSame('admin.settings.security', $security[3]->metadata()['access_feature']); + self::assertSame(FormInputType::MultiSelect, $security[5]->formField()->inputType()); + self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[5]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[6]->defaultValue()); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[7]->defaultValue()); + self::assertSame(FormInputType::Textarea, $security[7]->formField()->inputType()); + + self::assertSame([ + DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY, + DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $logging)); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[0]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[1]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $logging[2]->defaultValue()); self::assertSame([ AccessStatisticsPolicy::ENABLED_KEY, AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, + MaxMindGeoIpConfig::ENABLED_KEY, + MaxMindGeoIpConfig::DATABASE_PATH_KEY, + MaxMindGeoIpConfig::LICENSE_KEY_KEY, ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $statistics)); self::assertTrue($statistics[0]->defaultValue()); self::assertTrue($statistics[1]->defaultValue()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $statistics[3]->defaultValue()); + self::assertTrue($statistics[4]->metadata()['sensitive']); + self::assertSame('https://www.maxmind.com/en/geolite2/signup', $statistics[4]->metadata()['help_link_url']); + self::assertSame(FormInputType::Password, $statistics[4]->formField()->inputType()); self::assertSame([ ApiFeaturePolicy::ENABLED_KEY, @@ -100,6 +139,11 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void self::assertTrue($provider->defaultValue(ApiFeaturePolicy::ENABLED_KEY)); self::assertFalse($provider->defaultValue(ApiFeaturePolicy::CORS_ENABLED_KEY)); self::assertSame([], $provider->defaultValue(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY)); + self::assertFalse($provider->defaultValue(MaxMindGeoIpConfig::ENABLED_KEY)); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $provider->defaultValue(MaxMindGeoIpConfig::DATABASE_PATH_KEY)); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $provider->defaultValue(SuspiciousProbePathMatcher::PATTERNS_KEY)); + self::assertSame(RateLimitProfile::Standard->value, $provider->defaultValue(RateLimitPolicyCatalogue::MODE_KEY)); + self::assertSame((new AdminFeatureDefaults())->overrides(), $provider->defaultValue(AdminFeatureOverrideStore::CONFIG_KEY)); self::assertFalse($provider->hasDefault('security.captcha.preview')); self::assertNull($provider->defaultValue('security.captcha.preview')); } diff --git a/tests/Core/Config/SettingsApiReadModelTest.php b/tests/Core/Config/SettingsApiReadModelTest.php new file mode 100644 index 00000000..3c33749b --- /dev/null +++ b/tests/Core/Config/SettingsApiReadModelTest.php @@ -0,0 +1,56 @@ +connection()); + $config->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, 'secret-license-key', ConfigValueType::String, sensitive: true); + + $readModel = new SettingsApiReadModel($this->registry(), $config); + + $licenseSetting = null; + foreach ($readModel->settings('statistics', AccessActor::fromAccess(AccessLevel::OWNER)) as $setting) { + if (MaxMindGeoIpConfig::LICENSE_KEY_KEY === $setting['id']) { + $licenseSetting = $setting; + } + } + + self::assertIsArray($licenseSetting); + self::assertSame('[protected]', $licenseSetting['attributes']['value']); + self::assertSame('', $readModel->values('statistics', AccessActor::fromAccess(AccessLevel::OWNER))[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); + self::assertArrayNotHasKey(MaxMindGeoIpConfig::LICENSE_KEY_KEY, $readModel->values('statistics', AccessActor::fromAccess(AccessLevel::ADMIN))); + } + + private function registry(): CoreSettingsRegistry + { + $projectDir = dirname(__DIR__, 3); + + return new CoreSettingsRegistry(new TranslationLanguageCatalog($projectDir), new SystemPackageMetadataProvider($projectDir)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Geo/GeoIpResolverTest.php b/tests/Core/Geo/GeoIpResolverTest.php new file mode 100644 index 00000000..3b5026b9 --- /dev/null +++ b/tests/Core/Geo/GeoIpResolverTest.php @@ -0,0 +1,142 @@ + 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('unconfigured', $resolver->status()->status); + } + + public function testItReportsFallbackStatusWhenNoProviderIsConfigured(): void + { + $resolver = new GeoIpResolver([], new NullGeoIpProvider()); + + self::assertSame('none', $resolver->status()->providerKey); + self::assertSame('disabled', $resolver->status()->status); + } + + public function testItUsesFirstReadyProvider(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::unconfigured('disabled')), + new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind', databaseEdition: 'GeoLite2-City'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $resolver->resolve('203.0.113.10')->toArray()); + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('GeoLite2-City', $resolver->status()->databaseEdition); + } + + public function testItFallsBackWhenReadyProviderThrows(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::ready('maxmind'), throws: true), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + } + + public function testItDoesNotCallProvidersWithoutAnIpAddress(): void + { + $provider = new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ); + $resolver = new GeoIpResolver([$provider], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('')->toArray()); + self::assertSame(0, $provider->lookupCount); + } + + public function testProviderStatusContainsOnlySafeDiagnosticFields(): void + { + $status = GeoIpProviderStatus::ready( + 'maxmind', + databaseEdition: 'GeoLite2-City', + databaseBuildDate: '2026-06-15', + ); + + self::assertSame([ + 'provider_key' => 'maxmind', + 'status' => 'ready', + 'database_edition' => 'GeoLite2-City', + 'database_build_date' => '2026-06-15', + 'failure_code' => null, + ], $status->toSafeArray()); + } +} + +final class FakeGeoIpProvider implements GeoIpProviderInterface +{ + public int $lookupCount = 0; + + public function __construct( + private readonly GeoIpProviderStatus $status, + private readonly GeoIpResult $result = new GeoIpResult(), + private readonly bool $throws = false, + ) { + } + + public function key(): string + { + return $this->status->providerKey; + } + + public function status(): GeoIpProviderStatus + { + return $this->status; + } + + public function resolve(?string $ipAddress): GeoIpResult + { + ++$this->lookupCount; + + if ($this->throws) { + throw new RuntimeException('GeoIP provider failure'); + } + + return $this->result; + } +} diff --git a/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php new file mode 100644 index 00000000..23172bec --- /dev/null +++ b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php @@ -0,0 +1,184 @@ +download('https://example.test/geoip.tar.gz', $target); + + self::assertTrue($result->isSuccess()); + self::assertSame('archive-chunk', file_get_contents($target)); + self::assertArrayNotHasKey('buffer', $client->lastOptions); + } finally { + @unlink($target); + @rmdir($workspace); + } + } +} + +final class ChunkedGeoIpHttpClient implements HttpClientInterface +{ + /** @var array */ + public array $lastOptions = []; + + /** + * @param list $chunks + */ + public function __construct(private readonly int $statusCode, private readonly array $chunks) + { + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $this->lastOptions = $options; + + return new ThrowingContentGeoIpResponse($this->statusCode); + } + + public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface + { + $response = $responses instanceof ResponseInterface ? $responses : iterator_to_array($responses)[0]; + + return new ChunkedGeoIpResponseStream($response, array_map( + static fn (string $content): ChunkInterface => new GeoIpResponseChunk($content), + $this->chunks, + )); + } + + public function withOptions(array $options): static + { + return $this; + } +} + +final class ThrowingContentGeoIpResponse implements ResponseInterface +{ + public function __construct(private readonly int $statusCode) + { + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getHeaders(bool $throw = true): array + { + return []; + } + + public function getContent(bool $throw = true): string + { + throw new \LogicException('The download client must stream chunks instead of materializing the response body.'); + } + + public function toArray(bool $throw = true): array + { + return []; + } + + public function cancel(): void + { + } + + public function getInfo(?string $type = null): mixed + { + return null === $type ? ['http_code' => $this->statusCode] : null; + } +} + +final class ChunkedGeoIpResponseStream implements ResponseStreamInterface +{ + private int $position = 0; + + /** + * @param list $chunks + */ + public function __construct(private readonly ResponseInterface $response, private readonly array $chunks) + { + } + + public function current(): ChunkInterface + { + return $this->chunks[$this->position]; + } + + public function next(): void + { + ++$this->position; + } + + public function key(): ResponseInterface + { + return $this->response; + } + + public function valid(): bool + { + return isset($this->chunks[$this->position]); + } + + public function rewind(): void + { + $this->position = 0; + } +} + +final class GeoIpResponseChunk implements ChunkInterface +{ + public function __construct(private readonly string $content) + { + } + + public function isTimeout(): bool + { + return false; + } + + public function isFirst(): bool + { + return false; + } + + public function isLast(): bool + { + return false; + } + + public function getInformationalStatus(): ?array + { + return null; + } + + public function getContent(): string + { + return $this->content; + } + + public function getOffset(): int + { + return 0; + } + + public function getError(): ?string + { + return null; + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php new file mode 100644 index 00000000..046c784d --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php @@ -0,0 +1,120 @@ +workspaceDir = $this->createTemporaryDirectory('maxmind-geoip-archive'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->workspaceDir); + } + + public function testItExtractsReadableDatabaseFromSafeArchive(): void + { + $archivePath = $this->archivePath('safe', [ + 'GeoLite2-City/GeoLite2-City.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertTrue($result->isSuccess()); + self::assertSame('database', file_get_contents($result->value()['database_path'])); + } + + public function testItRejectsUnsafeArchivePaths(): void + { + $archivePath = $this->archivePath('unsafe', [ + '../escape.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + self::assertFileDoesNotExist(dirname($this->workspaceDir).DIRECTORY_SEPARATOR.'escape.mmdb'); + } + + public function testItRejectsUnsupportedArchiveEntryTypes(): void + { + $archivePath = $this->archivePath('unsupported-type', [ + 'GeoLite2-City/link' => ['type' => '2', 'link' => '/tmp'], + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + } + + /** + * @param array $files + */ + private function archivePath(string $name, array $files): string + { + $archivePath = $this->workspaceDir.DIRECTORY_SEPARATOR.$name.'.tar.gz'; + file_put_contents($archivePath, gzencode($this->tarContents($files))); + + return $archivePath; + } + + /** + * @param array $files + */ + private function tarContents(array $files): string + { + $tar = ''; + + foreach ($files as $path => $entry) { + $contents = is_array($entry) ? (string) ($entry['contents'] ?? '') : $entry; + $type = is_array($entry) ? (string) ($entry['type'] ?? '0') : '0'; + $link = is_array($entry) ? (string) ($entry['link'] ?? '') : ''; + $tar .= $this->tarHeader($path, strlen($contents), $type, $link); + $tar .= $contents; + $tar .= str_repeat("\0", (512 - (strlen($contents) % 512)) % 512); + } + + return $tar.str_repeat("\0", 1024); + } + + private function tarHeader(string $path, int $size, string $type = '0', string $link = ''): string + { + $header = str_pad(substr($path, 0, 100), 100, "\0"); + $header .= str_pad('0000644', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad(decoct($size), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_repeat(' ', 8); + $header .= $type[0] ?? '0'; + $header .= str_pad(substr($link, 0, 100), 100, "\0"); + $header .= "ustar\0"; + $header .= "00"; + $header .= str_repeat("\0", 247); + $header = str_pad($header, 512, "\0"); + $checksum = 0; + + for ($index = 0; $index < 512; ++$index) { + $checksum += ord($header[$index]); + } + + return substr_replace($header, str_pad(decoct($checksum), 6, '0', STR_PAD_LEFT)."\0 ", 148, 8); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpConfigTest.php b/tests/Core/Geo/MaxMindGeoIpConfigTest.php new file mode 100644 index 00000000..a502def3 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpConfigTest.php @@ -0,0 +1,86 @@ +connection())); + + self::assertFalse($config->enabled()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $config->databasePath()); + self::assertSame(['en'], $config->locales()); + self::assertSame('', $config->licenseKey()); + self::assertFalse($config->hasLicenseKey()); + } + + public function testItUsesConfiguredDefaultLanguageForLocalesAndNormalizesSensitiveSettings(): void + { + $connection = $this->connection(); + $store = new Config($connection); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + $store->set('localization.default_language', 'de', ConfigValueType::String); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, ' test-license ', ConfigValueType::String, sensitive: true); + + $config = new MaxMindGeoIpConfig($store); + + self::assertTrue($config->enabled()); + self::assertSame(['de', 'en'], $config->locales()); + self::assertSame('test-license', $config->licenseKey()); + self::assertTrue($config->hasLicenseKey()); + self::assertStringContainsString('license_key=test-license', $config->downloadUrl()); + } + + public function testItResolvesSafeProjectRelativeDatabasePath(): void + { + $store = new Config($this->connection()); + $config = new MaxMindGeoIpConfig($store); + $projectDir = 'project-root'; + + self::assertSame( + $projectDir.DIRECTORY_SEPARATOR.'var'.DIRECTORY_SEPARATOR.'geoip2'.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb', + $config->databaseAbsolutePath($projectDir), + ); + + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, '../secret.mmdb', ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir)); + } + + public function testItRejectsAbsoluteOrEscapingDatabasePaths(): void + { + $store = new Config($this->connection()); + $projectDir = 'project-root'; + + foreach ([ + '/secret.mmdb', + '//server/share/secret.mmdb', + '\\\\server\\share\\secret.mmdb', + 'C:/secret.mmdb', + 'C:secret.mmdb', + 'var/../secret.mmdb', + ] as $unsafePath) { + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, $unsafePath, ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir), $unsafePath); + } + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php new file mode 100644 index 00000000..835bc4cd --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -0,0 +1,231 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-updater'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItFailsWithoutLicenseKey(): void + { + $updater = new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($this->connection())), + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('scheduler'); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->translationKey()); + } + + public function testItDownloadsValidatesAndReplacesDatabase(): void + { + $config = $this->configuredConfig('test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor('new database'), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'old database'); + + $result = $updater->update('admin_ui'); + + self::assertTrue($result->isSuccess()); + self::assertSame(['database_path' => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH], $result->value()); + self::assertSame('new database', file_get_contents($this->defaultDatabasePath())); + if ('\\' !== DIRECTORY_SEPARATOR) { + self::assertSame('0644', substr(sprintf('%o', fileperms($this->defaultDatabasePath()) ?: 0), -4)); + } + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, $result->messages()[0]->translationKey()); + } + + public function testItForwardsDownloadFailureWithoutLeakingLicenseKey(): void + { + $config = $this->configuredConfig('sensitive-test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('admin_ui'); + $encoded = json_encode($result->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertStringNotContainsString('sensitive-test-license', $encoded); + } + + public function testOperationExecutionStateDoesNotLeakLicenseKey(): void + { + $licenseKey = 'sensitive-operation-license'; + $config = $this->configuredConfig($licenseKey); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $executor = new OperationExecutor(new SilentWorkflowResultMessageReporter()); + $queue = ActionQueue::create('geoip database update', [ + new MaxMindGeoIpUpdateAction($updater, 'admin_ui'), + ], context: [ + 'operation' => 'geoip.database_update', + 'environment' => 'test', + 'trigger' => 'admin_ui', + ]); + + $execution = $executor->executeQueue($queue); + $encoded = json_encode($execution->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($execution->result()->isSuccess()); + self::assertStringNotContainsString($licenseKey, $encoded); + self::assertStringNotContainsString('license_key=', $encoded); + } + + private function configuredConfig(string $licenseKey): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, $licenseKey, ConfigValueType::String, sensitive: true); + + return new MaxMindGeoIpConfig($store); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } +} + +final readonly class SilentWorkflowResultMessageReporter implements WorkflowResultMessageReporterInterface +{ + public function report(WorkflowResult $result, array $operationContext = []): WorkflowResult + { + return $result; + } +} + +final readonly class SuccessfulGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class FailingGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + context: ['stage' => 'download', 'http_status' => 401], + ), + ], ['stage' => 'download', 'http_status' => 401]); + } +} + +final readonly class SuccessfulGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function __construct(private string $databaseContents = 'database') + { + } + + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; + file_put_contents($databasePath, $this->databaseContents); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class ValidGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new ValidGeoIpReader(); + } +} + +final readonly class ValidGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php new file mode 100644 index 00000000..e1a787de --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -0,0 +1,228 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-provider'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItStaysDisabledByDefault(): void + { + $factory = new RecordingMaxMindReaderFactory($this->reader()); + $provider = new MaxMindGeoIpProvider(new MaxMindGeoIpConfig(new Config($this->connection())), $factory, $this->projectDir); + + self::assertSame('maxmind', $provider->key()); + self::assertSame('disabled', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $factory->openCount); + } + + public function testItReportsMissingConfiguredDatabaseAsUnconfigured(): void + { + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($this->reader()), $this->projectDir); + + self::assertSame('unconfigured', $provider->status()->status); + self::assertSame('database_missing', $provider->status()->failureCode); + } + + public function testItReadsLocalDatabaseThroughGeoIp2Boundary(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $factory = new RecordingMaxMindReaderFactory($reader); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), $factory, $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame('GeoLite2-City', $provider->status()->databaseEdition); + self::assertSame('2026-06-15', $provider->status()->databaseBuildDate); + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(1, $factory->openCount); + self::assertSame($this->defaultDatabasePath(), $factory->lastDatabasePath); + self::assertSame(['en'], $factory->lastLocales); + self::assertSame(1, $reader->cityLookupCount); + } + + public function testItDoesNotLookupPrivateOrInvalidIpAddresses(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('127.0.0.1')->toArray()); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('not-an-ip')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + + public function testItReportsUnreadableDatabaseWithoutLeakingPath(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new ThrowingMaxMindReaderFactory(), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unreadable', $status->failureCode); + self::assertStringNotContainsString($this->projectDir, json_encode($status->toSafeArray(), JSON_THROW_ON_ERROR)); + } + + public function testItRejectsReadableNonCityDatabasesBeforeReportingReady(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader('GeoLite2-Country'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unsupported', $status->failureCode); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + + private function enabledConfig(): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + + return new MaxMindGeoIpConfig($store); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function reader(string $databaseType = 'GeoLite2-City'): RecordingMaxMindReader + { + return new RecordingMaxMindReader(new City([ + 'city' => ['names' => ['en' => 'Berlin']], + 'subdivisions' => [ + ['names' => ['en' => 'Berlin'], 'iso_code' => 'BE'], + ], + 'country' => ['names' => ['en' => 'Germany'], 'iso_code' => 'DE'], + 'continent' => ['names' => ['en' => 'Europe'], 'code' => 'EU'], + ]), $databaseType); + } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } +} + +final class RecordingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public int $openCount = 0; + public ?string $lastDatabasePath = null; + /** @var list */ + public array $lastLocales = []; + + public function __construct(private readonly RecordingMaxMindReader $reader) + { + } + + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + ++$this->openCount; + $this->lastDatabasePath = $databasePath; + $this->lastLocales = $locales; + + return $this->reader; + } +} + +final class ThrowingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + throw new RuntimeException('Cannot open local test database.'); + } +} + +final class RecordingMaxMindReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public int $cityLookupCount = 0; + + public function __construct(private readonly City $city, private readonly string $databaseType = 'GeoLite2-City') + { + } + + public function city(string $ipAddress): City + { + ++$this->cityLookupCount; + + return $this->city; + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => $this->databaseType, + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php new file mode 100644 index 00000000..ad47848d --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php @@ -0,0 +1,115 @@ +updater()); + $task = $provider->schedulerTasks()[0]; + + self::assertSame('system.geoip2_database_update', $task->identifier()); + self::assertSame(SchedulerTaskType::Callable, $task->type()); + self::assertSame('system.geoip2.database_update', $task->target()); + self::assertSame('0 3 * * *', $task->defaultCronExpression()); + self::assertTrue($task->trusted()); + } + + public function testCallableReportsMissingLicenseKeyAsFailure(): void + { + $provider = new MaxMindGeoIpSchedulerProvider($this->updater()); + $callable = $provider->schedulerCallable('system.geoip2.database_update'); + + self::assertNotNull($callable); + + $execution = $callable(); + + self::assertFalse($execution->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $execution->messages()[0]->code()); + } + + private function updater(): MaxMindGeoIpDatabaseUpdater + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($connection)), + new SchedulerGeoIpDownloadClient(), + new SchedulerGeoIpArchiveExtractor(), + new SchedulerGeoIpReaderFactory(), + sys_get_temp_dir(), + ); + } +} + +final readonly class SchedulerGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class SchedulerGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; + file_put_contents($databasePath, 'database'); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class SchedulerGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new SchedulerGeoIpReader(); + } +} + +final readonly class SchedulerGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/NullGeoIpResolverTest.php b/tests/Core/Geo/NullGeoIpResolverTest.php index fff7b10e..e8df0a54 100644 --- a/tests/Core/Geo/NullGeoIpResolverTest.php +++ b/tests/Core/Geo/NullGeoIpResolverTest.php @@ -20,4 +20,12 @@ public function testItReturnsNormalizedPlaceholders(): void 'continent' => 'n/a', ], $result->toArray()); } + + public function testItReportsDisabledStatus(): void + { + $status = (new NullGeoIpResolver())->status(); + + self::assertSame('none', $status->providerKey); + self::assertSame('disabled', $status->status); + } } diff --git a/tests/Core/Log/AccessRequestMetadataTest.php b/tests/Core/Log/AccessRequestMetadataTest.php index bfa401a2..7e59186b 100644 --- a/tests/Core/Log/AccessRequestMetadataTest.php +++ b/tests/Core/Log/AccessRequestMetadataTest.php @@ -4,7 +4,13 @@ namespace App\Tests\Core\Log; +use App\Content\Routing\ContentRouteLocalization; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Log\AccessRequestMetadata; +use App\Localization\TranslationLanguageCatalog; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -28,6 +34,8 @@ public function testItDerivesOperationalRequestMetadata(): void self::assertIsInt($metadata->durationMs($request)); self::assertSame('admin', $metadata->surface($request)); self::assertSame('api', $metadata->surface(Request::create('/api/v1/status'))); + self::assertSame('public', $metadata->surface(Request::create('/apiary'))); + self::assertSame('public', $metadata->surface(Request::create('/docs/api/reference'))); self::assertSame('backend_admin_route', $metadata->resolvedRoute($request)); self::assertSame('https://example.org/source', $metadata->referrer($request)); self::assertSame('example.org', $metadata->referrerHost($request)); @@ -89,4 +97,34 @@ public function testItRedactsSensitiveReferrerPathSegments(): void self::assertSame('https://example.org/user/invitation/[redacted]', $metadata->referrer($request)); } + + public function testItGatesLocalizedSurfacePrefixesByRouteLocaleOrEnabledRoutePrefixes(): void + { + $disabled = new AccessRequestMetadata($this->routeLocalization(false)); + $enabled = new AccessRequestMetadata($this->routeLocalization(true)); + $localizedRoute = Request::create('/de/admin/logs'); + $localizedRoute->attributes->set('_locale', 'de'); + + self::assertSame('public', $disabled->surface(Request::create('/de/admin'))); + self::assertSame('public', $disabled->surface(Request::create('/de/api/v1/status'))); + self::assertSame('admin', $disabled->surface($localizedRoute)); + self::assertSame('admin', $enabled->surface(Request::create('/de/admin/logs'))); + self::assertSame('public', $enabled->surface(Request::create('/de/api/v1/status'))); + } + + private function routeLocalization(bool $enabled): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, new TranslationLanguageCatalog(dirname(__DIR__, 3))); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } } diff --git a/tests/Core/Log/AdminLogBrowserTest.php b/tests/Core/Log/AdminLogBrowserTest.php new file mode 100644 index 00000000..2643018e --- /dev/null +++ b/tests/Core/Log/AdminLogBrowserTest.php @@ -0,0 +1,60 @@ +logDir = $this->createTemporaryDirectory('system-admin-log-browser'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->logDir); + } + + public function testItCombinesApplicationFileLogWithDatabaseSources(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure","request_id":"application-request"} []'.PHP_EOL); + + $browser = new AdminLogBrowser( + new DatabaseLogBrowser($connection), + new LogFileBrowser($this->logDir, 'test'), + ); + + $applicationView = $browser->browse(['source' => 'application', 'level' => 'ERROR', 'q' => 'application-request']); + self::assertSame(['application', 'message', 'audit', 'access', 'security_signal'], array_column($applicationView['sources'], 'key')); + self::assertSame('application', $applicationView['selected_source']); + self::assertTrue($applicationView['capabilities']['level_filter']); + self::assertSame(1, $applicationView['pagination']['total']); + self::assertSame('app.failure', $applicationView['entries'][0]['message']); + + $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); + self::assertNotNull($applicationEntry); + self::assertSame('app.failure', $applicationEntry['message']); + + $staleFilterView = $browser->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + self::assertSame('', $staleFilterView['filters']['audit_action']); + self::assertSame(1, $staleFilterView['pagination']['total']); + } +} diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php new file mode 100644 index 00000000..dc1e2efd --- /dev/null +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -0,0 +1,346 @@ + 'pdo_sqlite', 'memory' => true]); + $this->createTables($connection); + $now = '2026-06-16 12:00:00'; + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => $now, + 'level' => 'INFO', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => $now, + 'expires_at' => '2026-06-17 12:00:00', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.env', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-1', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-1', + 'visitor_id' => 'visitor-1', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.env"}', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000005', + 'occurred_at' => $now, + 'expires_at' => '2026-06-16 11:59:59', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.expired', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-expired', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-expired', + 'visitor_id' => 'visitor-expired', + 'path' => '/.git/config', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.expired"}', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000003', + 'occurred_at' => $now, + 'request_id' => 'request-access', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'surface' => 'admin', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'visitor-hidden-search', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '203.0.113.10', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Example Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{"hidden":"visitor-hidden-search"}', + ]); + $connection->insert('audit_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000004', + 'occurred_at' => $now, + 'user_name' => 'admin', + 'user_uid' => '99999999-0000-7000-8000-000000000100', + 'user_access_level' => 8, + 'action' => 'audit.test', + 'request_id' => 'request-audit', + 'visitor_id' => 'visitor-audit', + 'requested_path' => '/admin/users', + 'resolved_route' => 'backend_admin_route', + 'context' => '{"hidden":"visitor-audit"}', + ]); + $browser = new DatabaseLogBrowser($connection, clock: new MockClock($now)); + $defaultView = $browser->browse(['source' => 'message']); + self::assertSame(0, $defaultView['pagination']['total']); + + $infoView = $browser->browse(['source' => 'message', 'level' => 'INFO']); + self::assertSame(1, $infoView['pagination']['total']); + self::assertSame(['INFO'], $infoView['filters']['levels']); + + $accessView = $browser->browse(['source' => 'access', 'q' => 'visitor-hidden-search']); + self::assertFalse($accessView['capabilities']['level_filter']); + self::assertSame([], $accessView['filters']['levels']); + self::assertSame(1, $accessView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000003', $accessView['entries'][0]['id']); + self::assertSame('/admin/logs', $accessView['entries'][0]['context']['requested_path']); + self::assertSame('backend_admin_route', $accessView['entries'][0]['context']['resolved_route']); + + $auditView = $browser->browse(['source' => 'audit', 'level' => 'ERROR', 'q' => 'visitor-audit']); + self::assertFalse($auditView['capabilities']['level_filter']); + self::assertSame([], $auditView['filters']['levels']); + self::assertSame(1, $auditView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000004', $auditView['entries'][0]['id']); + + $view = $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.env']); + self::assertTrue($view['capabilities']['level_filter']); + self::assertTrue($view['capabilities']['signal_reason_filter']); + + self::assertSame(['message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); + self::assertSame('security_signal', $view['selected_source']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); + self::assertSame('probe: security.probe.env', $view['entries'][0]['summary']); + self::assertSame('visitor-1', $view['entries'][0]['context']['subject_identifier']); + self::assertSame(0, $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.expired'])['pagination']['total']); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000005')); + + $entry = $browser->entry('message', '99999999-0000-7000-8000-000000000001'); + + self::assertNotNull($entry); + self::assertSame('message.test', $entry['message']); + } + + public function testItCapsFormerAllPageSizeAtFiveHundredRowsWithPagination(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + + for ($i = 1; $i <= 501; ++$i) { + $connection->insert('message_log_entry', [ + 'uid' => sprintf('99999999-0000-7000-8000-%012d', $i), + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + } + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'per_page' => 'all', + 'page' => 999, + ]); + + self::assertSame(500, $view['filters']['per_page']); + self::assertSame(2, $view['filters']['page']); + self::assertSame(501, $view['pagination']['total']); + self::assertSame(2, $view['pagination']['total_pages']); + self::assertFalse($view['pagination']['has_next']); + self::assertCount(1, $view['entries']); + } + + public function testItHonorsConfiguredDatabaseRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'logging.database.message_retention_days', + 'value' => '1', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2026-06-16 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.current', + 'code' => 'test.current', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => '2026-06-14 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.expired_by_setting', + 'code' => 'test.expired', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'message', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.current', $view['entries'][0]['message']); + self::assertNotNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000001')); + self::assertNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000002')); + } + + public function testItHonorsConfiguredSecuritySignalRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'security.signals.retention_days', + 'value' => '1', + ]); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000001', '2026-06-16 12:00:00', '2026-06-23 12:00:00', 'current'); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000002', '2026-06-14 12:00:00', '2026-06-23 12:00:00', 'expired_by_setting'); + + $browser = new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')); + $view = $browser->browse([ + 'source' => 'security_signal', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('security.probe.current', $view['entries'][0]['message']); + self::assertNotNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000001')); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000002')); + } + + public function testItTreatsSqlLikeWildcardsAsLiteralSearchText(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.literal_percent_%', + 'code' => 'literal.percent', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.unrelated', + 'code' => 'unrelated', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '%', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.literal_percent_%', $view['entries'][0]['message']); + + $underscoreView = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '_', + ]); + + self::assertSame(1, $underscoreView['pagination']['total']); + self::assertSame('message.literal_percent_%', $underscoreView['entries'][0]['message']); + } + + public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection + ->expects(self::exactly(2)) + ->method('fetchOne') + ->willReturnCallback(static function (string $sql): mixed { + if (str_contains($sql, 'config_entry')) { + return false; + } + + self::assertStringContainsString("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'", $sql); + + return 0; + }); + $connection + ->expects(self::once()) + ->method('fetchAllAssociative') + ->with(self::stringContains("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'"), self::callback(static fn (array $params): bool => in_array('%scanner%', $params, true))) + ->willReturn([]); + + (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'security_signal', + 'q' => 'Scanner', + ]); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE audit_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, user_name VARCHAR(180) NOT NULL, user_uid VARCHAR(36) DEFAULT NULL, user_access_level INTEGER NOT NULL, action VARCHAR(160) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, requested_path VARCHAR(1024) NOT NULL, resolved_route VARCHAR(190) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + } + + private function insertSignal(Connection $connection, string $uid, string $occurredAt, string $expiresAt, string $reason): void + { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => $expiresAt, + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.'.$reason, + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-'.$reason, + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-'.$reason, + 'visitor_id' => 'visitor-'.$reason, + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + } +} diff --git a/tests/Core/Log/DatabaseLogProjectorTest.php b/tests/Core/Log/DatabaseLogProjectorTest.php new file mode 100644 index 00000000..758c109b --- /dev/null +++ b/tests/Core/Log/DatabaseLogProjectorTest.php @@ -0,0 +1,159 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + $projector->recordAccess([ + 'request_id' => 'setup-request', + 'method' => 'GET', + 'path' => '/setup', + ]); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItWritesAndPurgesDatabaseLogProjectionRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $this->createTables($connection); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + 'value' => '1', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'request_id' => 'old', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/old', + 'requested_path' => '/old', + 'route' => 'old', + 'resolved_route' => 'old', + 'surface' => 'public', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'old-visitor', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '127.0.0.1', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Old Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{}', + ]); + + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); + $projector->recordAccess([ + 'request_id' => 'current', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'http_status' => 200, + 'client_ip' => '203.0.113.1', + 'city' => str_repeat('x', 120), + ]); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM access_log_entry')); + self::assertSame('current', $connection->fetchOne('SELECT request_id FROM access_log_entry')); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM access_log_entry')); + self::assertSame(80, strlen((string) $connection->fetchOne('SELECT city FROM access_log_entry'))); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Core/Log/LogFileBrowserTest.php b/tests/Core/Log/LogFileBrowserTest.php index d1b836f0..f9687654 100644 --- a/tests/Core/Log/LogFileBrowserTest.php +++ b/tests/Core/Log/LogFileBrowserTest.php @@ -67,4 +67,40 @@ public function testItReadsAccessContextColumns(): void self::assertSame('/admin/logs', $view['entries'][0]['context']['path']); self::assertSame('n/a', $view['entries'][0]['context']['country']); } + + public function testItIgnoresAuditActionFiltersForApplicationLogs(): void + { + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure"} []'.PHP_EOL); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + + self::assertSame('', $view['filters']['audit_action']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('app.failure', $view['entries'][0]['message']); + } + + public function testItUsesClampedPaginationPageWhenReadingEntries(): void + { + $lines = []; + for ($i = 1; $i <= 26; ++$i) { + $lines[] = sprintf('[2099-01-01T10:%02d:00.000000+00:00] message.ERROR: message.%02d [] []', $i, $i); + } + $this->writeTestFile($this->logDir, 'test/message-2099-01-01.log', implode(PHP_EOL, [...$lines, ''])); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'message', + 'level' => 'ERROR', + 'per_page' => 25, + 'page' => 999, + ]); + + self::assertSame(2, $view['filters']['page']); + self::assertSame(2, $view['pagination']['page']); + self::assertCount(1, $view['entries']); + self::assertSame('message.01', $view['entries'][0]['message']); + } } diff --git a/tests/Core/Log/MonologMessageLoggerTest.php b/tests/Core/Log/MonologMessageLoggerTest.php index 3bf0837e..432a26a3 100644 --- a/tests/Core/Log/MonologMessageLoggerTest.php +++ b/tests/Core/Log/MonologMessageLoggerTest.php @@ -41,7 +41,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void ProcessMessageCode::PROCESS_COMMAND_FAILED, ProcessMessageKey::PROCESS_COMMAND_FAILED, ['%command%' => 'bin/console demo'], - ['exit_code' => 1, 'database_password' => 'secret'], + ['exit_code' => 1, 'database_password' => 'secret', 'license_key' => 'maxmind-secret'], ); $message = Message::info( PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, @@ -74,6 +74,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void self::assertSame('process.command_failed', $records[0]->context['code']); self::assertSame('demo', $records[0]->context['queue']); self::assertSame('[redacted]', $records[0]->context['message_context']['database_password']); + self::assertSame('[redacted]', $records[0]->context['message_context']['license_key']); self::assertSame('[redacted]', $records[0]->context['app_secret']); self::assertSame(Level::Info, $records[1]->level); self::assertSame('[redacted]', $records[1]->context['message_context']['api_token']); diff --git a/tests/Core/Manifest/ManifestParserTest.php b/tests/Core/Manifest/ManifestParserTest.php index 30a2b412..6bef5d1f 100644 --- a/tests/Core/Manifest/ManifestParserTest.php +++ b/tests/Core/Manifest/ManifestParserTest.php @@ -68,7 +68,7 @@ public function testItReportsDuplicateKeys(): void { $result = (new ManifestParser())->parse(<<<'MANIFEST' APP_VERSION=0.1.0 - APP_VERSION=0.2.4 + APP_VERSION=0.2.5 MANIFEST); self::assertFalse($result->isSuccess()); diff --git a/tests/Core/Message/MessageCodeTest.php b/tests/Core/Message/MessageCodeTest.php index e567e4e2..212a2226 100644 --- a/tests/Core/Message/MessageCodeTest.php +++ b/tests/Core/Message/MessageCodeTest.php @@ -105,7 +105,7 @@ private static function namePrefixes(): array SystemSecurityMessageCode::class => ['SYSTEM_'], TranslationMessageCode::class => ['TRANSLATION_'], SchedulerMessageCode::class => ['SCHEDULER_'], - SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_'], SetupMessageCode::class => ['SETUP_'], ViewMessageCode::class => ['VIEW_'], ]; @@ -136,7 +136,7 @@ private static function valuePrefixes(): array SystemSecurityMessageCode::class => ['system.'], TranslationMessageCode::class => ['translation.'], SchedulerMessageCode::class => ['scheduler.'], - SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.'], + SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.', 'rate_limit.'], SetupMessageCode::class => ['setup.'], ViewMessageCode::class => ['view.'], ]; diff --git a/tests/Core/Message/MessageKeyTest.php b/tests/Core/Message/MessageKeyTest.php index 6c88eca7..2b73dd1c 100644 --- a/tests/Core/Message/MessageKeyTest.php +++ b/tests/Core/Message/MessageKeyTest.php @@ -154,7 +154,7 @@ private static function namePrefixes(): array TranslationMessageKey::class => ['TRANSLATION_'], NavigationMessageKey::class => ['MENU_'], SchedulerMessageKey::class => ['SCHEDULER_'], - SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_'], SetupMessageKey::class => ['SETUP_'], ViewMessageKey::class => ['VIEW_'], ]; @@ -187,7 +187,7 @@ private static function valuePrefixes(): array TranslationMessageKey::class => ['message.translation.'], NavigationMessageKey::class => ['message.menu.'], SchedulerMessageKey::class => ['message.scheduler.'], - SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.'], + SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.', 'message.rate_limit.'], SetupMessageKey::class => ['message.setup.'], ViewMessageKey::class => ['message.view.'], ]; diff --git a/tests/Core/Messenger/DeferredMessengerDrainTest.php b/tests/Core/Messenger/DeferredMessengerDrainTest.php index 24c4b0c1..21395a25 100644 --- a/tests/Core/Messenger/DeferredMessengerDrainTest.php +++ b/tests/Core/Messenger/DeferredMessengerDrainTest.php @@ -139,6 +139,31 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c $this->removeDirectory($projectDir); } + public function testSubscriberDoesNotSkipSchedulerLookalikeRequests(): void + { + $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-lookalike'); + $connection = $this->connectionWithMessengerTable(); + $starter = new RecordingDeferredMessengerStarter(); + $settings = $this->schedulerSettings($connection, true); + $drain = new DeferredMessengerDrain($connection, $starter, $projectDir, 'test', schedulerSettings: $settings); + $request = Request::create('/cron/runaway'); + + (new DeferredMessengerDrainSubscriber($drain))->onKernelTerminate(new TerminateEvent( + new class implements HttpKernelInterface { + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + }, + $request, + new Response(), + )); + + self::assertCount(1, $starter->starts); + + $this->removeDirectory($projectDir); + } + public function testItLogsDispatchFailureWhenDetachedStartFails(): void { $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-failure'); diff --git a/tests/Core/Operation/LiveOperationQueueFactoryTest.php b/tests/Core/Operation/LiveOperationQueueFactoryTest.php index a36ff193..8b4c0a49 100644 --- a/tests/Core/Operation/LiveOperationQueueFactoryTest.php +++ b/tests/Core/Operation/LiveOperationQueueFactoryTest.php @@ -54,6 +54,10 @@ public function testItCreatesSupportedQueues(): void 'package' => 'demo-module', 'trigger' => 'admin_ui', ]); + $geoIpUpdate = $factory->create(LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE, [ + 'environment' => 'test', + 'trigger' => 'admin_ui', + ]); self::assertTrue($backendCacheClear->isSuccess()); self::assertSame('backend cache clear', $backendCacheClear->value()?->name()); @@ -71,6 +75,9 @@ public function testItCreatesSupportedQueues(): void self::assertSame('package install verification', $packageInstallVerify->value()?->name()); self::assertTrue($packageInstallApply->isSuccess()); self::assertSame('demo-module', $packageInstallApply->value()?->context()['package']); + self::assertTrue($geoIpUpdate->isSuccess()); + self::assertSame('geoip database update', $geoIpUpdate->value()?->name()); + self::assertSame('admin_ui', $geoIpUpdate->value()?->context()['trigger']); } public function testItRejectsUnknownOperationsAndInvalidPayloads(): void diff --git a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php b/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php index 3af5a04b..b27d8808 100644 --- a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php +++ b/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php @@ -4,6 +4,9 @@ namespace App\Tests\Core\Package; +use App\Core\AdminAcl\AdminFeatureOverrideStore; +use App\Core\AdminAcl\AdminFeatureRegistry; +use App\Core\AdminAcl\AdminPermissionState; use App\Core\Config\ConfigValueType; use App\Core\Package\ExtensionPackageStatus; use App\Core\Package\PackageLifecycleCleanupRunner; @@ -18,10 +21,23 @@ public function testItDeletesPackageSettingsDuringCleanup(): void { self::bootKernel(); $settings = self::getContainer()->get(PackageSettings::class); - $runner = new PackageLifecycleCleanupRunner($settings); + $overrides = self::getContainer()->get(AdminFeatureOverrideStore::class); + $registry = self::getContainer()->get(AdminFeatureRegistry::class); + self::assertInstanceOf(AdminFeatureRegistry::class, $registry); + $runner = new PackageLifecycleCleanupRunner($settings, $overrides, $registry); $settings->set('cleanup-module', 'display.mode', 'compact', ConfigValueType::String); $settings->set('neighbor-module', 'display.mode', 'comfortable', ConfigValueType::String); + $overrides->save([ + 'admin.settings.packages.cleanup-module' => [ + 'state' => AdminPermissionState::Mutable->value, + 'groups' => [], + ], + 'admin.settings.packages.neighbor-module' => [ + 'state' => AdminPermissionState::Visible->value, + 'groups' => [], + ], + ], 'test'); $result = $runner->cleanup(new ExtensionPackage( '10000000-0000-7000-8000-000000000611', @@ -37,10 +53,17 @@ public function testItDeletesPackageSettingsDuringCleanup(): void 'action' => 'delete_package_settings', 'count' => 1, ], + [ + 'action' => 'delete_package_acl_override', + 'count' => 1, + ], ], $result->value()['actions']); self::assertSame('fallback', $settings->get('cleanup-module', 'display.mode', 'fallback')); self::assertSame('comfortable', $settings->get('neighbor-module', 'display.mode', 'fallback')); + self::assertArrayNotHasKey('admin.settings.packages.cleanup-module', $overrides->overrides()); + self::assertArrayHasKey('admin.settings.packages.neighbor-module', $overrides->overrides()); $settings->removePackage('neighbor-module'); + $overrides->save([], 'test'); } } diff --git a/tests/Core/Package/PackageSettingsFormHandlerTest.php b/tests/Core/Package/PackageSettingsFormHandlerTest.php index 356b0e55..32146adc 100644 --- a/tests/Core/Package/PackageSettingsFormHandlerTest.php +++ b/tests/Core/Package/PackageSettingsFormHandlerTest.php @@ -63,6 +63,46 @@ public function testItPersistsTypedPackageSettingsFromSubmittedFormValues(): voi self::assertSame('comfortable', $settings->get('form-module', 'display.mode')); self::assertTrue($settings->get('form-module', 'feature.enabled')); } + + public function testItPreservesSensitivePackageSettingsWhenSubmittedProtectedPlaceholder(): void + { + self::bootKernel(); + $settings = self::getContainer()->get(PackageSettings::class); + $settings->set('form-module', 'api.secret', 'stored-secret', ConfigValueType::String, 'test'); + $handler = new PackageSettingsFormHandler( + new PackageSettingRegistry( + [new FormHandlerPackageSettingProvider([ + new PackageSettingDefinition( + 'form-module', + 'api.secret', + 'API secret', + '', + ConfigValueType::String, + inputType: FormInputType::Password, + metadata: ['sensitive' => true], + ), + ])], + new FormHandlerActivePackageProvider([ + new ExtensionPackage( + '10000000-0000-7000-8000-000000000778', + [PackageScope::Module], + 'form-module', + 'packages/form-module', + ExtensionPackageStatus::Active, + ), + ]), + ), + $settings, + new FormSubmissionHandler(), + ); + + $result = $handler->submit('form-module', [ + 'api.secret' => '[protected]', + ], 'test'); + + self::assertTrue($result->isValid()); + self::assertSame('stored-secret', $settings->get('form-module', 'api.secret')); + } } final readonly class FormHandlerPackageSettingProvider implements PackageSettingProviderInterface diff --git a/tests/Core/Routing/PathScopeMatcherTest.php b/tests/Core/Routing/PathScopeMatcherTest.php new file mode 100644 index 00000000..29fb0cfd --- /dev/null +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -0,0 +1,58 @@ + + */ + public static function prefixCases(): iterable + { + yield 'exact path' => ['/api/v1', '/api/v1', true]; + yield 'child path' => ['/api/v1/status', '/api/v1', true]; + yield 'lookalike path' => ['/api/v10/status', '/api/v1', false]; + yield 'segment sibling' => ['/cronjobs', '/cron', false]; + yield 'prefix without leading slash' => ['/build/app.js', 'build', true]; + yield 'root does not match every path' => ['/docs', '/', false]; + yield 'root matches only root' => ['/', '/', true]; + } + + #[DataProvider('prefixCases')] + public function testMatchesPrefixUsesSegmentBoundaries(string $path, string $prefix, bool $matches): void + { + self::assertSame($matches, (new PathScopeMatcher())->matchesPrefix($path, $prefix)); + } + + public function testMatchesAnyPrefixUsesSameSegmentRules(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesAnyPrefix('/_wdt/token', '/assets', '/_wdt')); + self::assertFalse($matcher->matchesAnyPrefix('/_wdtfoo', '/assets', '/_wdt')); + } + + public function testMatchesSegmentsPinsExplicitPathParts(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesSegments('/api/v1/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/api/v10/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/de/api/v1/content', 'api', 'v1')); + } + + public function testMatchesExactSegmentsRejectsChildrenAndLocalizedLookalikes(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesExactSegments('/cron/run', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/cron/run/extra', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/de/cron/run', 'cron', 'run')); + } +} diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php new file mode 100644 index 00000000..208b7e5a --- /dev/null +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -0,0 +1,71 @@ +attributes->set('_locale', 'de'); + $content = Request::create('/de/about'); + $content->attributes->set('_locale', 'de'); + + self::assertSame(['admin', 'settings', 'security'], $resolver->segments($admin)); + self::assertSame(['de', 'about'], $resolver->segments($content)); + } + + public function testItDoesNotStripLocalePrefixForPrefixlessTechnicalScopes(): void + { + $resolver = new RequestPathResolver(); + $api = Request::create('/de/api/v1/status'); + $api->attributes->set('_locale', 'de'); + $cron = Request::create('/de/cron/run'); + $cron->attributes->set('_locale', 'de'); + + self::assertFalse($resolver->matches($api, 'api', 'v1')); + self::assertFalse($resolver->matchesExact($cron, 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matches(Request::create('/apiary'), 'api')); + } + + public function testItUsesEnabledRoutePrefixLanguages(): void + { + $resolver = new RequestPathResolver($this->routeLocalization(true)); + + self::assertTrue($resolver->matches(Request::create('/de/admin/logs'), 'admin')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matchesExact(Request::create('/de/cron/run'), 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/fr/api/v1/status'), 'api', 'v1')); + self::assertFalse((new RequestPathResolver($this->routeLocalization(false)))->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + } + + private function routeLocalization(bool $enabled): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, new TranslationLanguageCatalog(dirname(__DIR__, 3))); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php index cac46004..f76cb644 100644 --- a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php +++ b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php @@ -5,6 +5,9 @@ namespace App\Tests\Core\Statistics; use App\Core\Log\AccessRequestMetadata; +use App\Core\Geo\GeoIpResolverInterface; +use App\Core\Geo\GeoIpProviderStatus; +use App\Core\Geo\GeoIpResult; use App\Core\Geo\NullGeoIpResolver; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; @@ -130,6 +133,27 @@ public function testItRedactsTokenizedPathSegments(): void self::assertStringNotContainsString('test-token', implode(' ', array_map('strval', $row))); } + public function testItBoundsGeoIpLabelsBeforeRecording(): void + { + $label = str_repeat('x', 120); + + (new DatabaseAccessStatisticsRecorder( + $this->connection, + new VisitorIdGenerator('test-secret'), + new UserAgentClassifier(), + new AccessRequestMetadata(), + new StaticGeoIpResolver(new GeoIpResult($label, $label, $label, $label)), + ))->record(Request::create('/docs', 'GET'), new Response('', 200)); + + $row = $this->connection->fetchAssociative('SELECT city, state, country, continent FROM access_statistic_event'); + + self::assertIsArray($row); + self::assertSame(80, strlen((string) $row['city'])); + self::assertSame(80, strlen((string) $row['state'])); + self::assertSame(80, strlen((string) $row['country'])); + self::assertSame(80, strlen((string) $row['continent'])); + } + public function testItDoesNotThrowWhenStatisticsTableIsUnavailable(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -234,3 +258,20 @@ public function testItDeletesStatisticEventsOlderThanThreeMonths(): void self::assertSame(1, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM access_statistic_event WHERE path = ?', ['/docs'])); } } + +final readonly class StaticGeoIpResolver implements GeoIpResolverInterface +{ + public function __construct(private GeoIpResult $result) + { + } + + public function resolve(?string $ipAddress): GeoIpResult + { + return $this->result; + } + + public function status(): GeoIpProviderStatus + { + return GeoIpProviderStatus::ready('test'); + } +} diff --git a/tests/Core/Statistics/VisitorIdGeneratorTest.php b/tests/Core/Statistics/VisitorIdGeneratorTest.php index 576d4393..8159332a 100644 --- a/tests/Core/Statistics/VisitorIdGeneratorTest.php +++ b/tests/Core/Statistics/VisitorIdGeneratorTest.php @@ -92,6 +92,55 @@ public function testItKeepsCookieLessVisitorsStableByIpAndUserAgent(): void ); } + public function testItUsesForwardingHeaderEntropyOnlyForCookieLessVisitorFallbacks(): void + { + $generator = new VisitorIdGenerator('test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + self::assertSame('203.0.113.10', $generator->sourceIp($firstRequest)); + } + + public function testItSeparatesRecentFallbacksBehindSameIpWhenForwardingEntropyDiffers(): void + { + $generator = $this->generator(); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + } + public function testItUsesPendingCookieVisitorIdsWithoutAStore(): void { $request = Request::create('/docs', server: [ diff --git a/tests/Navigation/NavigationBuilderTest.php b/tests/Navigation/NavigationBuilderTest.php index 4e03e473..2cc848c3 100644 --- a/tests/Navigation/NavigationBuilderTest.php +++ b/tests/Navigation/NavigationBuilderTest.php @@ -389,6 +389,25 @@ public function testItBuildsBackendViewsFromRegistry(): void ], array_column($navigation, 'url')); self::assertFalse($navigation[0]['active']); self::assertTrue($navigation[1]['active']); + self::assertSame([ + 'admin.navigation.general_settings', + 'admin.navigation.dashboard_settings', + 'admin.navigation.user_settings', + 'admin.navigation.mail_settings', + 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', + 'admin.navigation.package_settings', + 'admin.navigation.scheduler_settings', + 'admin.navigation.system_info', + ], array_column($navigation[9]['children'], 'label')); + + $ownerNavigation = self::getContainer()->get(NavigationBuilder::class)->build( + 'backend.admin', + actor: AccessActor::fromAccess(9), + activeUrl: '/admin/settings', + activeRoute: 'backend_admin_route', + ); + self::assertSame([ 'admin.navigation.general_settings', 'admin.navigation.dashboard_settings', @@ -396,11 +415,13 @@ public function testItBuildsBackendViewsFromRegistry(): void 'admin.navigation.mail_settings', 'admin.navigation.security_settings', 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', 'admin.navigation.api_settings', + 'admin.navigation.acl_settings', 'admin.navigation.package_settings', 'admin.navigation.scheduler_settings', 'admin.navigation.system_info', - ], array_column($navigation[9]['children'], 'label')); + ], array_column($ownerNavigation[9]['children'], 'label')); } public function testItFiltersNavigationItemsByAccessLevel(): void diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 014cbd1e..a6c9a4f6 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -38,6 +38,10 @@ public function testMigrationsApplyToConfiguredSqliteDatabase(): void self::assertContains('ui_alert_inbox', $tables); self::assertContains('config_entry', $tables); self::assertContains('state_marker', $tables); + self::assertContains('message_log_entry', $tables); + self::assertContains('audit_log_entry', $tables); + self::assertContains('access_log_entry', $tables); + self::assertContains('security_signal_event', $tables); self::assertContains('access_statistic_event', $tables); self::assertContains('content_schema', $tables); self::assertContains('content_revision', $tables); @@ -99,6 +103,10 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void static fn ($index): string => $index->getName(), $schema->getTable('ui_alert_inbox')->getIndexes(), ); + $signalIndexes = array_map( + static fn ($index): string => $index->getName(), + $schema->getTable('security_signal_event')->getIndexes(), + ); $userGroupForeignKeys = array_map( static fn ($foreignKey): string => $foreignKey->getName(), $schema->getTable('user_acl_group')->getForeignKeys(), @@ -110,6 +118,8 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void self::assertContains('studio_pk_ui_alert_inbox', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_topic_cursor', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_expires_at', $alertIndexes); + self::assertContains('studio_pk_security_signal_event', $signalIndexes); + self::assertContains('studio_idx_security_signal_subject_at', $signalIndexes); self::assertContains('studio_fk_user_acl_group_user', $userGroupForeignKeys); self::assertContains('studio_fk_user_acl_group_group', $userGroupForeignKeys); } finally { @@ -198,6 +208,10 @@ private function initialMigrationTables(): array 'package_setting_entry', 'scheduler_task', 'scheduler_task_run', + 'message_log_entry', + 'audit_log_entry', + 'access_log_entry', + 'security_signal_event', 'state_marker', 'access_statistic_event', 'acl_group', diff --git a/tests/Scheduler/SchedulerRunnerTest.php b/tests/Scheduler/SchedulerRunnerTest.php index ff92d2ae..8e233a04 100644 --- a/tests/Scheduler/SchedulerRunnerTest.php +++ b/tests/Scheduler/SchedulerRunnerTest.php @@ -249,7 +249,13 @@ public function testItTimestampsEachDueTaskWhenItStarts(): void $this->entityManager->flush(); $this->runner(new TestDelayedSchedulerTaskExecutor(), new TestMultipleSchedulerTaskProvider())->run(); - $runs = $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']); + $runs = array_values(array_filter( + $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']), + static fn (SchedulerTaskRun $run): bool => in_array($run->task()->identifier(), [ + 'system.first_task', + 'system.second_task', + ], true), + )); self::assertCount(2, $runs); self::assertGreaterThan( diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php new file mode 100644 index 00000000..cc39f68c --- /dev/null +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -0,0 +1,206 @@ + '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + $resolution = $resolver->resolve($request); + $visitor = $resolution->first(AbuseSubjectType::Visitor); + $ipBucket = $resolution->first(AbuseSubjectType::IpBucket); + $encoded = json_encode($resolution->toArray(), JSON_THROW_ON_ERROR); + + self::assertNotNull($visitor); + self::assertSame($visitor, $resolution->primary()); + self::assertNotNull($ipBucket); + self::assertTrue($ipBucket->ipDerived()); + self::assertStringNotContainsString('203.0.113.10', $encoded); + self::assertStringNotContainsString('198.51.100.10', $encoded); + } + + public function testItKeepsIpBucketStableWhenCookieLessForwardingEntropyChanges(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $first = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ])); + $second = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ])); + + self::assertNotSame( + $first->first(AbuseSubjectType::Visitor)?->identifier(), + $second->first(AbuseSubjectType::Visitor)?->identifier(), + ); + self::assertSame( + $first->first(AbuseSubjectType::IpBucket)?->identifier(), + $second->first(AbuseSubjectType::IpBucket)?->identifier(), + ); + } + + public function testItAddsAuthenticatedApiKeyAndUserSubjectsFromApiContext(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: ['REMOTE_ADDR' => '203.0.113.10']); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'owner', 'owner@example.test', 'hash', role: UserRole::Owner); + $apiKey = new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'testkey', + str_repeat('a', 64), + 'encrypted', + $user, + ApiKeyStatus::ReadWrite, + ); + ApiRequestContext::fromApiKey($apiKey)->attachTo($request); + + $resolution = $resolver->resolve($request); + + self::assertSame($user->uid(), $resolution->first(AbuseSubjectType::User)?->identifier()); + self::assertSame($apiKey->uid(), $resolution->first(AbuseSubjectType::ApiKey)?->identifier()); + self::assertSame('testkey', $resolution->first(AbuseSubjectType::ApiKey)?->context()['prefix']); + } + + public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer publicPrefix.secret-token-material', + ]); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::ApiKeyPrefix); + + self::assertNotNull($subject); + self::assertSame('publicPrefix', $subject->identifier()); + self::assertStringNotContainsString('secret-token-material', json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsRedactedSchedulerCredentialSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $bearer = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer scheduler.secret-token-material', + ]); + $query = Request::create('/cron/run?auth=scheduler.secret-token-material'); + + $bearerSubject = $resolver->resolve($bearer)->first(AbuseSubjectType::SchedulerCredential); + $querySubject = $resolver->resolve($query)->first(AbuseSubjectType::SchedulerCredential); + + self::assertNotNull($bearerSubject); + self::assertNotNull($querySubject); + self::assertSame($bearerSubject->identifier(), $querySubject->identifier()); + self::assertStringNotContainsString('scheduler.secret-token-material', json_encode($bearerSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotAddSchedulerCredentialSubjectsForLocalizedCronLookalikes(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/cron/run?auth=scheduler.secret-token-material'); + $request->attributes->set('_locale', 'de'); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SchedulerCredential)); + } + + public function testItAddsRedactedSubmittedAccountSubjectsForAuthWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $login = Request::create('/user/login', 'POST', ['username' => 'Admin']); + $reset = Request::create('/user/reset-password', 'POST', ['email' => 'ADMIN@Example.TEST']); + + $loginSubject = $resolver->resolve($login)->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve($reset)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($loginSubject); + self::assertNotNull($resetSubject); + self::assertSame('login', $loginSubject->context()['scope']); + self::assertSame('password_reset_email', $resetSubject->context()['scope']); + self::assertStringNotContainsString('Admin', json_encode($loginSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('ADMIN@Example.TEST', json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsRedactedSubmittedTokenSubjectsForAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $invitationToken = str_repeat('a', 64); + $resetToken = str_repeat('b', 64); + $reviewToken = str_repeat('c', 64); + + $invitationSubject = $resolver->resolve(Request::create('/user/invitation/'.$invitationToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve(Request::create('/user/reset-password/'.$resetToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $reviewSubject = $resolver->resolve(Request::create('/user/security-review/'.$reviewToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($invitationSubject); + self::assertNotNull($resetSubject); + self::assertNotNull($reviewSubject); + self::assertSame('registration_token', $invitationSubject->context()['scope']); + self::assertSame('password_reset_token', $resetSubject->context()['scope']); + self::assertSame('security_review_token', $reviewSubject->context()['scope']); + self::assertStringNotContainsString($invitationToken, json_encode($invitationSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($resetToken, json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($reviewToken, json_encode($reviewSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedTokenSubjectsFromRouteAttributesForLocalizedAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $token = str_repeat('d', 64); + $request = Request::create('/de/user/security-review/'.$token, 'POST'); + $request->attributes->set('_route', 'user_security_review'); + $request->attributes->set('_locale', 'de'); + $request->attributes->set('token', $token); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('security_review_token', $subject->context()['scope']); + self::assertStringNotContainsString($token, json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedAccountSubjectsFromLocalizedPathSegments(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/user/login', 'POST', ['username' => 'Admin']); + $request->attributes->set('_locale', 'de'); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('login', $subject->context()['scope']); + self::assertNull($resolver->resolve(Request::create('/de/user/login', 'POST', ['username' => 'Admin']))->first(AbuseSubjectType::SubmittedAccount)); + } + + public function testItDoesNotAddSubmittedAccountSubjectsForLookalikePaths(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/user/login-extra', 'POST', ['username' => 'Admin']); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount)); + } +} diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php new file mode 100644 index 00000000..73a49d5a --- /dev/null +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -0,0 +1,74 @@ +costFor($classifier->classify(Request::create('/api/live/alerts'))); + $prefetch = $catalogue->costFor($classifier->classify(Request::create('/docs', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]))); + + self::assertSame('live_api', $live->bucketFamily()); + self::assertSame(0, $live->credits()); + self::assertFalse($live->ordinaryEnforcement()); + self::assertSame('website_prefetch', $prefetch->bucketFamily()); + self::assertFalse($prefetch->ordinaryEnforcement()); + } + + public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic(): void + { + $classifier = new RequestIntentClassifier(); + $catalogue = new ActionCostCatalogue(); + + $probe = $catalogue->costFor($classifier->classify(Request::create('/.env'))); + $apiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/content/items', 'POST'))); + $adminApiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/admin/operations/cleanup', 'POST'))); + $schedulerTrigger = $catalogue->costFor($classifier->classify(Request::create('/cron/run'))); + $schedulerNotFound = $catalogue->costFor($classifier->classify(Request::create('/cron/not-found'))); + $setupWizard = $catalogue->costFor($classifier->classify(Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))); + $setupApply = $catalogue->costFor($classifier->classify(Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))); + + self::assertSame('suspicious_probe', $probe->bucketFamily()); + self::assertSame(10, $probe->credits()); + self::assertSame('api_write', $apiWrite->bucketFamily()); + self::assertSame(5, $apiWrite->credits()); + self::assertSame('admin_mutation', $adminApiWrite->bucketFamily()); + self::assertSame(8, $adminApiWrite->credits()); + self::assertSame('scheduler', $schedulerTrigger->bucketFamily()); + self::assertSame('website', $schedulerNotFound->bucketFamily()); + self::assertSame('setup', $setupWizard->bucketFamily()); + self::assertSame(1, $setupWizard->credits()); + self::assertSame('setup_apply', $setupApply->bucketFamily()); + self::assertSame(8, $setupApply->credits()); + } + + public function testItExposesUniqueBucketFamilyCostsForPolicyBudgets(): void + { + $catalogue = new ActionCostCatalogue(); + $costs = $catalogue->uniqueCreditsByBucketFamily(); + + self::assertSame(5, $costs['registration']); + self::assertSame(3, $costs['password_reset']); + self::assertSame(5, $costs['api_write']); + self::assertSame(10, $costs['suspicious_probe']); + self::assertArrayNotHasKey('live_api', $costs); + self::assertArrayNotHasKey('api_preflight', $costs); + } +} diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php new file mode 100644 index 00000000..0860ad25 --- /dev/null +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -0,0 +1,140 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $request = Request::create('/.env', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Scanner/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 400), + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + + self::assertSame('probe', $row['signal_type']); + self::assertSame('security.signal.suspicious_probe', $row['reason_code']); + self::assertSame('visitor', $row['subject_type']); + self::assertSame($visitorIds->generate($request), $row['visitor_id']); + self::assertSame($row['visitor_id'], $row['subject_identifier']); + self::assertIsString($context['ip_bucket'] ?? null); + self::assertStringNotContainsString('203.0.113.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('198.51.100.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotRecordOrdinaryNavigationSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/docs'), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItSanitizesTokenizedPathsBeforeRecordingSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $token = str_repeat('a', 64); + $request = Request::create('/user/reset-password/'.$token, 'POST', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]); + $request->attributes->set('_route', 'user_password_reset_token'); + $request->attributes->set('token', $token); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + self::assertSame('/user/reset-password/[redacted]', $row['path']); + self::assertStringNotContainsString($token, json_encode($row, JSON_THROW_ON_ERROR)); + } +} + +final class PassiveAbuseSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php new file mode 100644 index 00000000..4c4dd651 --- /dev/null +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -0,0 +1,386 @@ + + */ + public static function requestCases(): iterable + { + yield 'live api cheap json' => [ + Request::create('/api/live/alerts'), + RequestFamily::LiveApi, + RequestIntent::LiveApi, + ]; + yield 'localized api-like content path is browser navigation' => [ + self::localizedRequest('/de/api/live/alerts', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'api write' => [ + Request::create('/api/v1/content/items', 'POST'), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'admin api operation mutation is admin mutation' => [ + Request::create('/api/v1/admin/operations/cleanup', 'POST'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'admin api settings mutation is settings mutation' => [ + Request::create('/api/v1/admin/settings/security', 'PATCH'), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'admin api scheduler mutation is admin mutation' => [ + Request::create('/api/v1/admin/scheduler/system.live_operation_cleanup', 'PATCH'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'localized admin api-like content path is form submit' => [ + self::localizedRequest('/de/api/v1/admin/packages/demo/reset-fault', 'POST', 'de'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'apiary public content is not api' => [ + Request::create('/apiary'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'administer public content is not admin' => [ + Request::create('/administer', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'localized admin is admin' => [ + self::localizedRequest('/de/admin/settings/security', 'POST', 'de'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'admin package upload uses upload archive bucket before broad package bucket' => [ + Request::create('/admin/packages/upload', 'POST'), + RequestFamily::Admin, + RequestIntent::UploadArchiveValidation, + ]; + yield 'admin download uses download diagnostics bucket even for safe method' => [ + Request::create('/admin/logs/download'), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; + yield 'public path containing reserved segment is public' => [ + Request::create('/docs/api/reference', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'future content download path is ordinary navigation' => [ + self::contentRequest('/download'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'future localized content export path is ordinary form submit' => [ + self::contentRequest('/de/export', 'POST', 'de'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'future public package form post gets website form intent' => [ + self::contentRequest('/forum/thread/welcome', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'contact-like content slug is ordinary navigation' => [ + self::contentRequest('/contact-us'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'contact content slug is ordinary navigation' => [ + self::contentRequest('/contact'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'contact content post is ordinary form submit' => [ + self::contentRequest('/contact', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/captcha/refresh'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'localized captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/de/captcha/refresh', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'admin path containing settings only as part of a segment is generic admin' => [ + Request::create('/admin/content/site-settings', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; + yield 'cors preflight' => [ + Request::create('/api/v1/content/items', 'OPTIONS'), + RequestFamily::Api, + RequestIntent::CorsPreflight, + ]; + yield 'authorization options request is charged as api read' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + ]), + RequestFamily::Api, + RequestIntent::ApiRead, + ]; + yield 'authorization options request honors requested unsafe api method' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'authorization options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'malformed bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer ', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'empty bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'turbo prefetch' => [ + Request::create('/docs', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::TurboPrefetch, + ]; + yield 'recovery login bypass ignores spoofed prefetch' => [ + Request::create('/user/login?bypass=1', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'admin download ignores spoofed prefetch' => [ + Request::create('/admin/logs/download', server: ['HTTP_PURPOSE' => 'prefetch']), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; + yield 'scheduler trigger' => [ + Request::create('/cron/run'), + RequestFamily::Scheduler, + RequestIntent::SchedulerTrigger, + ]; + yield 'localized cron-like content path is browser navigation' => [ + self::localizedRequest('/de/cron/run', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler reserved non-run path is ordinary navigation' => [ + Request::create('/cron/not-found'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler trigger requires exact path' => [ + Request::create('/cron/run/extra'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'setup wizard post is setup navigation' => [ + Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; + yield 'setup apply' => [ + Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]), + RequestFamily::Setup, + RequestIntent::SetupApply, + ]; + yield 'setup apply requires exact review path' => [ + Request::create('/setup/review/extra', 'POST', [ + '_setup_action' => 'apply', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; + yield 'settings mutation' => [ + Request::create('/admin/settings/security', 'POST'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'admin user password reset is acl mutation' => [ + Request::create('/admin/users/details/example-user/password-reset', 'POST'), + RequestFamily::Admin, + RequestIntent::UserAclMutation, + ]; + yield 'admin package reset fault is package mutation' => [ + Request::create('/admin/packages/demo/reset-fault', 'POST'), + RequestFamily::Admin, + RequestIntent::PackageAdminOperation, + ]; + yield 'admin operation post is generic admin mutation' => [ + Request::create('/admin/operations', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; + yield 'login form render is ordinary navigation' => [ + Request::create('/user/login'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'recovery login bypass uses recovery intent' => [ + Request::create('/user/login?bypass=1'), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'recovery login bypass post stays login intent' => [ + Request::create('/user/login?bypass=1', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; + yield 'registration form render is ordinary navigation' => [ + Request::create('/user/register'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'password reset form render is ordinary navigation' => [ + Request::create('/user/reset-password'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'public login post is login intent' => [ + Request::create('/user/login', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; + yield 'public password reset stays public reset intent' => [ + Request::create('/user/password-reset', 'POST'), + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; + $tokenReset = Request::create('/user/reset-password/'.str_repeat('a', 64), 'POST'); + $tokenReset->attributes->set('_route', 'user_password_reset_token'); + yield 'public reset token route is password reset' => [ + $tokenReset, + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; + $invitation = Request::create('/user/invitation/'.str_repeat('b', 64), 'POST'); + $invitation->attributes->set('_route', 'user_invitation_accept'); + yield 'public invitation token route is registration' => [ + $invitation, + RequestFamily::Browser, + RequestIntent::Registration, + ]; + yield 'suspicious env probe' => [ + Request::create('/.env'), + RequestFamily::Browser, + RequestIntent::SuspiciousProbe, + ]; + } + + #[DataProvider('requestCases')] + public function testItClassifiesRequestIntent(Request $request, RequestFamily $family, RequestIntent $intent): void + { + $profile = (new RequestIntentClassifier(routeLocalization: $this->routeLocalization()))->classify($request); + + self::assertSame($family, $profile->family()); + self::assertSame($intent, $profile->intent()); + } + + public function testItClassifiesOrdinaryUploadRoutesAsUploadArchiveValidation(): void + { + $profile = (new RequestIntentClassifier())->classify(Request::create('/admin/packages/upload', 'POST')); + + self::assertSame(RequestIntent::UploadArchiveValidation, $profile->intent()); + self::assertFalse($profile->suspiciousProbe()); + } + + public function testItDoesNotStripLanguageSlugsWhenRoutePrefixesAreDisabled(): void + { + $classifier = new RequestIntentClassifier(routeLocalization: $this->disabledRouteLocalization()); + $profile = $classifier->classify(self::contentRequest('/de/admin', 'POST')); + $apiProfile = $classifier->classify(self::contentRequest('/de/api/v1/content/items', 'POST')); + + self::assertSame(RequestFamily::Browser, $profile->family()); + self::assertSame(RequestIntent::FormSubmit, $profile->intent()); + self::assertSame(RequestFamily::Browser, $apiProfile->family()); + self::assertSame(RequestIntent::FormSubmit, $apiProfile->intent()); + } + + private function routeLocalization(): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, true, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, $this->languageCatalog()); + } + + private function disabledRouteLocalization(): ContentRouteLocalization + { + return new ContentRouteLocalization(new Config($this->connection()), $this->languageCatalog()); + } + + private function languageCatalog(): TranslationLanguageCatalog + { + return new TranslationLanguageCatalog(dirname(__DIR__, 3)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private static function contentRequest(string $path, string $method = 'GET', ?string $locale = null): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_route', 'content_show'); + if (null !== $locale) { + $request->attributes->set('_locale', $locale); + } + + return $request; + } + + private static function localizedRequest(string $path, string $method, string $locale): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_locale', $locale); + + return $request; + } +} diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php new file mode 100644 index 00000000..c252a67e --- /dev/null +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -0,0 +1,141 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + $recorder->record('probe', 'security.probe.setup', 'visitor', 'visitor-id'); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItRecordsSignalsWithSharedRetentionAndPurgesExpiredRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + 'value' => '7', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'expires_at' => '2000-01-02 00:00:00', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.old', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'ip_hash', + 'subject_identifier' => 'old', + 'ip_derived' => 1, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'old', + 'visitor_id' => 'n/a', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); + $recorder->record( + 'probe', + 'security.probe.env', + 'ip_hash', + 'hash-value', + ipDerived: true, + severity: 'warning', + confidence: 140, + requestFamily: 'browser', + requestIntent: 'suspicious_probe', + path: '/.env', + httpStatus: 400, + ); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame('security.probe.env', $connection->fetchOne('SELECT reason_code FROM security_signal_event')); + self::assertSame(100, (int) $connection->fetchOne('SELECT confidence FROM security_signal_event')); + self::assertSame(1, (int) $connection->fetchOne('SELECT ip_derived FROM security_signal_event')); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM security_signal_event')); + self::assertSame('2026-06-23 12:00:00', $connection->fetchOne('SELECT expires_at FROM security_signal_event')); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php new file mode 100644 index 00000000..a36f7b3b --- /dev/null +++ b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php @@ -0,0 +1,97 @@ +isProbe('/.env')); + self::assertTrue($matcher->isProbe('/wp-login.php')); + self::assertTrue($matcher->isProbe('/backup-2026.sql')); + self::assertFalse($matcher->isProbe('/admin/packages/upload')); + } + + public function testItUsesConfiguredPatternsWhenProvided(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#/custom-probe(?:/|$)#i', + ]); + + self::assertTrue($matcher->isProbe('/custom-probe')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItFallsBackToDefaultsWhenConfiguredPatternsAreInvalid(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#unterminated', + '', + ]); + + self::assertTrue($matcher->isProbe('/.git/config')); + } + + public function testItParsesConfiguredPatternTextAsNewlineAndCsvValues(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, "#/custom-one(?:/|$)#i\n\"#/custom-two(?:/|$)#i\",\"#/custom-three(?:/|$)#i\"", ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/custom-one')); + self::assertTrue($matcher->isProbe('/custom-two')); + self::assertTrue($matcher->isProbe('/custom-three')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItPreservesCommasInsideOneLineRegexPatterns(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, '#/dump-[0-9]{4,6}\.sql$#', ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/dump-2026.sql')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItCachesConfiguredPatternsForTheServiceLifetime(): void + { + $connection = $this->createMock(Connection::class); + $cache = new ArrayAdapter(); + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with('SELECT value FROM config_entry WHERE config_key = ?', [SuspiciousProbePathMatcher::PATTERNS_KEY]) + ->willReturn(json_encode('#/cached-probe$#', JSON_THROW_ON_ERROR)); + + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + } + + public function testDefaultPatternTextContainsOneEditablePatternPerLine(): void + { + $lines = array_filter(explode("\n", SuspiciousProbePathMatcher::defaultPatternText())); + + self::assertSame(SuspiciousProbePathMatcher::DEFAULT_PATTERNS, array_values($lines)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php new file mode 100644 index 00000000..48dd417d --- /dev/null +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -0,0 +1,820 @@ +connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testStorageFailureFailsOpenWithDiagnosticsFlag(): void + { + $messages = new RecordingRateLimitMessageReporter(); + $result = $this->enforcer(cachePool: new FailingCachePool(), messages: $messages)->check($this->request('/home')); + + self::assertTrue($result->isAllowed()); + self::assertTrue($result->storageDegraded()); + self::assertSame(SecurityMessageCode::RATE_LIMIT_STORAGE_DEGRADED, $messages->records[0]['message']->code()); + } + + public function testLoginWorkflowRejectsBeforeWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginFormRendersDoNotSpendLoginWorkflowBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 6; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login'))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + + $result = $enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + } + + public function testPanicRecoveryLoginRenderAndSubmitFitBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'recovery-owner', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + public function testBypassLoginPostUsesLoginFailureBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginAttemptsShareSubmittedAccountAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(20 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.99'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLocalLoginExhaustionDoesNotSpendSubmittedAccountBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'local-block', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(60 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.90'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testPasswordResetAttemptsShareSubmittedEmailAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'target@example.test', + ], $this->server('203.0.113.'.(40 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'TARGET@EXAMPLE.TEST', + ], $this->server('203.0.113.100'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testPasswordResetTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('a', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.'.(100 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.110'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalPasswordResetExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ], $this->server('203.0.113.'.(70 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.95'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalRegistrationExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ], $this->server('203.0.113.'.(80 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'VICTIM-REGISTRATION@EXAMPLE.TEST', + ], $this->server('203.0.113.96'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testInvitationTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('b', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.'.(120 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.130'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendRegistrationAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ], $this->server('203.0.113.'.(140 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'GLOBAL-VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.150'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendPasswordResetAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ], $this->server('203.0.113.'.(160 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'GLOBAL-RESET@EXAMPLE.TEST', + ], $this->server('203.0.113.170'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testOwnerIsExemptFromOrdinaryRateLimitRejection(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testOwnerApiContextDoesNotBypassAuthenticationFailureBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'PATCH'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testAuthenticatedUsersReceiveWebsiteMultiplier(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER * 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/home')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testSchedulerRequestsAreNotOwnerExempt(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalOnlyAppliesToCronRun(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/not-found', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/de/cron/run', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/cron/run', 'GET'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'GET')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testAdminNavigationFallsBackToWebsiteBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/admin/logs'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/admin/logs')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testStrictSchedulerIntervalRejectsSecondRunWithinFifteenMinutes(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertGreaterThanOrEqual(1, $result->retryAfterSeconds() ?? 0); + } + + public function testSchedulerIntervalUsesStableSubmittedCredentialAcrossVisitorChanges(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalKeepsIpAnchorForAuthenticatedUsersWithRotatingCredentials(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token-a', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token-b', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + $result = $enforcer->check($this->request('/.env')); + + self::assertFalse($result->isAllowed()); + self::assertTrue($result->suspiciousProbe()); + self::assertFalse($result->storageDegraded()); + } + + public function testAdminAuthFailureBudgetUsesIpSecondaryAcrossVisitorChanges(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'PATCH', [], [ + 'REMOTE_ADDR' => '203.0.113.88', + 'HTTP_USER_AGENT' => 'AdminProbe/'.$i, + 'HTTP_AUTHORIZATION' => sprintf('Bearer admin%02d.invalid-secret', $i), + ]), RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyMutationsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 16; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.api_write', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyUnsafePreflightsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeySafePreflightsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/status', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testCredentialedNonBearerPreflightsSpendRequestedMethodBucket(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Basic credential-probe', + ]), RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadWriteOwnerApiKeyMutationsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testPanicAdminMutationConsumesWebsiteBucketWithoutStorageDegradation(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $messages = new RecordingRateLimitMessageReporter(); + $enforcer = $this->enforcer(config: $config, messages: $messages); + + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/admin/settings/security', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertFalse($result->storageDegraded()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + self::assertSame([], $messages->records); + } + + public function testSetupWizardPostsDoNotSpendSetupApplyBudget(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 12; ++$i) { + self::assertTrue($enforcer->check($this->request('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))->isAllowed()); + } + + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.setup_apply', $result->diagnosticsLabel()); + } + + public function testRepresentativeRequestPathsReachExpectedBuckets(): void + { + $cases = [ + ['/user/register', 'POST', ['email' => 'registration@example.test'], 'security.rate.registration', 4], + ['/user/reset-password', 'POST', ['email' => 'reset@example.test'], 'security.rate.password_reset', 4], + ['/contact', 'POST', [], 'security.rate.website_form', 3], + ['/api/v1/content/items', 'GET', [], 'security.rate.api_public_read', 31], + ['/api/v1/content/items', 'POST', [], 'security.rate.api_write', 16], + ['/cron/run', 'POST', [], 'security.rate.scheduler', 2], + ['/setup/review', 'POST', ['_setup_action' => 'apply'], 'security.rate.setup_apply', 3], + ['/admin/settings/security', 'POST', [], 'security.rate.website_burst', 8], + ['/admin/packages/upload', 'POST', [], 'security.rate.website_burst', 6], + ['/admin/logs/download', 'GET', [], 'security.rate.website_burst', 8], + ]; + + foreach ($cases as [$path, $method, $parameters, $label, $attempts]) { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < $attempts; ++$i) { + $result = $enforcer->check($this->request($path, $method, $parameters)); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed(), $path); + self::assertSame($label, $result->diagnosticsLabel(), $path); + } + } + + private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = null, ?RecordingRateLimitMessageReporter $messages = null): RateLimitEnforcer + { + $tokenStorage ??= new TokenStorage(); + $inspector = new AbuseRequestInspector( + new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ); + + return new RateLimitEnforcer( + $inspector, + $config ?? new Config($this->connection()), + new RateLimitPolicyCatalogue(), + new RateLimitSubjectSelector(), + new RateLimitLimiterFactory($cachePool ?? new ArrayAdapter()), + $messages ?? new RecordingRateLimitMessageReporter(), + ); + } + + /** + * @param array $parameters + * @param array $server + */ + private function request(string $path, string $method = 'GET', array $parameters = [], array $server = []): Request + { + return Request::create($path, $method, $parameters, server: [ + ...$this->server('203.0.113.9'), + ...$server, + ]); + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcerTest-'.$ip, + ]; + } + + private function tokenStorage(UserRole $role): TokenStorage + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000001', + 'rate_limit_'.$role->value, + 'rate-limit-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $tokenStorage; + } + + private function apiContext(ApiKeyStatus $status, UserRole $role): ApiRequestContext + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000101', + 'rate_limit_api_'.$role->value, + 'rate-limit-api-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + + return ApiRequestContext::fromApiKey(new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'rlapi', + str_repeat('a', 64), + 'encrypted', + $user, + $status, + )); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} + +final class FailingCachePool implements CacheItemPoolInterface +{ + public function getItem(string $key): CacheItemInterface + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function getItems(array $keys = []): iterable + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function hasItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function clear(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItems(array $keys): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function save(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function commit(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } +} diff --git a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php new file mode 100644 index 00000000..820a59b1 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php @@ -0,0 +1,85 @@ +descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $panic); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'website.deliberate.burst:visitor:profile-isolation'; + + for ($i = 0; $i < $panic->limit() - 1; ++$i) { + self::assertTrue($factory->consume($standard, $subjectKey, 1)); + } + + for ($i = 0; $i < $panic->limit(); ++$i) { + self::assertTrue($factory->consume($panic, $subjectKey, 1)); + } + + self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($panic, $subjectKey, 1)); + } + + public function testConsumeUsesConfiguredLockFactory(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('login.failure', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $lockFactory = new TrackingRateLimitLockFactory(); + $factory = new RateLimitLimiterFactory(new ArrayAdapter(), $lockFactory); + + self::assertTrue($factory->consume($descriptor, 'login.failure:visitor:lock-test', 1)); + self::assertGreaterThanOrEqual(1, $lockFactory->createdLocks); + } + + public function testAcceptsChecksCapacityWithoutSpendingCredits(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'scheduler.interval:visitor:accepts-test'; + + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->consume($descriptor, $subjectKey, 1)); + self::assertInstanceOf(\DateTimeImmutable::class, $factory->accepts($descriptor, $subjectKey, 1)); + } +} + +final class TrackingRateLimitLockFactory extends LockFactory +{ + public int $createdLocks = 0; + + public function __construct() + { + parent::__construct(new InMemoryStore()); + } + + public function createLock(string $resource, ?float $ttl = 300.0, bool $autoRelease = true): SharedLockInterface + { + ++$this->createdLocks; + + return parent::createLock($resource, $ttl, $autoRelease); + } +} diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php new file mode 100644 index 00000000..0111d196 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -0,0 +1,236 @@ +descriptor('login.failure'); + $burst = $catalogue->descriptor('website.deliberate.burst'); + $probe = $catalogue->descriptor('suspicious.probe'); + $captcha = $catalogue->descriptor('captcha.failure'); + + self::assertNotNull($login); + self::assertSame('login', $login->bucketFamily()); + self::assertSame(5, $login->limit()); + self::assertSame(900, $login->windowSeconds()); + self::assertTrue($login->resettable()); + self::assertNotNull($burst); + self::assertSame(30, $burst->limit()); + self::assertSame(60, $burst->windowSeconds()); + self::assertNotNull($probe); + self::assertSame(10, $probe->limit()); + self::assertSame(10, $probe->minimumLimit()); + self::assertSame(600, $probe->windowSeconds()); + self::assertNotNull($captcha); + self::assertTrue($captcha->resettable()); + } + + public function testStrictAndPanicProfilesDeriveFromStandardDescriptors(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(30, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(16, $strict->limit()); + self::assertSame(90, $strict->windowSeconds()); + self::assertSame(16, $panic->limit()); + self::assertSame(120, $panic->windowSeconds()); + } + + public function testRecoveryBucketsStayStableAcrossProfiles(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($panic); + self::assertSame($standard->limit(), $panic->limit()); + self::assertSame($standard->windowSeconds(), $panic->windowSeconds()); + } + + public function testProbeScalingKeepsOneActionFloorWhileExtendingWindow(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $strict = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Panic); + + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(10, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(10, $panic->limit()); + self::assertSame(1200, $panic->windowSeconds()); + } + + public function testPolicyUsesActionCostsAsCreditMultipliers(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $registration = $catalogue->descriptor('registration.hour'); + $apiWrite = $catalogue->descriptor('api.write'); + + self::assertNotNull($registration); + self::assertSame(15, $registration->limit()); + self::assertSame(10, $registration->minimumLimit()); + self::assertNotNull($apiWrite); + self::assertSame(300, $apiWrite->limit()); + self::assertSame(10, $apiWrite->minimumLimit()); + } + + public function testDescriptorsOwnEnforcementStages(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $recovery = $catalogue->descriptor('recovery.login.minute'); + $apiWrite = $catalogue->descriptor('api.write'); + $probe = $catalogue->descriptor('suspicious.probe'); + + self::assertNotNull($login); + self::assertTrue($login->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + self::assertFalse($login->handlesStage(RateLimitEnforcementStage::Ordinary)); + + self::assertNotNull($recovery); + self::assertTrue($recovery->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertFalse($recovery->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($apiWrite); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($probe); + self::assertTrue($probe->handlesStage(RateLimitEnforcementStage::SuspiciousProbe)); + self::assertFalse($probe->handlesStage(RateLimitEnforcementStage::Ordinary)); + } + + public function testDescriptorsOwnSubjectPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $adminMutation = $catalogue->descriptor('admin.mutation'); + $scheduler = $catalogue->descriptor('scheduler.interval'); + $website = $catalogue->descriptor('website.deliberate.burst'); + + self::assertNotNull($login); + self::assertTrue($login->subjectPolicy()->submittedAccountScope()); + + self::assertNotNull($adminMutation); + self::assertSame([ + AbuseSubjectType::ApiKey, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, + ], $adminMutation->subjectPolicy()->preferredTypes()); + + self::assertNotNull($scheduler); + self::assertTrue($scheduler->subjectPolicy()->ipSecondary()); + self::assertTrue($scheduler->subjectPolicy()->ipSecondaryWithAuthenticatedSubject()); + + self::assertNotNull($website); + self::assertTrue($website->subjectPolicy()->authenticatedMultiplier()); + } + + public function testProfileScalingKeepsMinimumCostedActionsAvailable(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + foreach ($catalogue->descriptors(RateLimitProfile::Panic) as $descriptor) { + self::assertGreaterThanOrEqual($descriptor->minimumLimit(), $descriptor->limit(), $descriptor->name()); + } + } + + public function testDescriptorFloorsCoverTwoActionsExceptExplicitIntervalPolicies(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $expectedMinimums = [ + 'login.failure' => 2, + 'recovery.login.minute' => 2, + 'recovery.login.hour' => 2, + 'registration.hour' => 10, + 'registration.day' => 10, + 'password_reset.hour' => 6, + 'password_reset.day' => 6, + 'captcha.failure' => 2, + 'website.deliberate.burst' => 16, + 'website.deliberate.sustained' => 16, + 'website.form' => 4, + 'website.prefetch.minute' => 2, + 'website.prefetch.sustained' => 2, + 'api.read' => 2, + 'api.public_read' => 2, + 'api.write' => 10, + 'scheduler.interval' => 1, + 'setup.apply' => 16, + 'admin.mutation' => 16, + 'upload_archive.validation' => 16, + 'download_diagnostics' => 8, + 'suspicious.probe' => 10, + ]; + + foreach ($expectedMinimums as $name => $minimum) { + $descriptor = $catalogue->descriptor($name); + self::assertNotNull($descriptor, $name); + self::assertSame($minimum, $descriptor->minimumLimit(), $name); + } + } + + public function testWebsiteBurstFloorCoversTwoHighCostCompanionActions(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($panic); + self::assertSame(16, $panic->minimumLimit()); + self::assertSame(16, $panic->limit()); + } + + public function testSchedulerProfileIntervalsUseExplicitCronPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(1, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(1, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(1, $panic->limit()); + self::assertSame(3600, $panic->windowSeconds()); + } + + public function testOffProfileDoesNotConsumeLimiterStorage(): void + { + self::assertFalse(RateLimitProfile::Off->consumesLimiterStorage()); + self::assertTrue(RateLimitProfile::Standard->consumesLimiterStorage()); + self::assertSame(RateLimitProfile::Standard, RateLimitProfile::fromMixed('unknown')); + } +} diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php new file mode 100644 index 00000000..8e8afbd2 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -0,0 +1,344 @@ +previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null; + $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null; + $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY); + $_SERVER[SetupCompletionMarker::KEY] = '1'; + } + + protected function tearDown(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + + if (null !== $this->previousServerValue) { + $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue; + } + + if (null !== $this->previousEnvValue) { + $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue; + } + + is_string($this->previousPutenvValue) + ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue) + : putenv(SetupCompletionMarker::KEY); + } + + /** + * @return iterable + */ + public static function excludedPathCases(): iterable + { + yield 'live api root' => ['/api/live', true]; + yield 'live api child' => ['/api/live/status', true]; + yield 'live api sibling' => ['/api/live-status', false]; + yield 'assets child' => ['/assets/app.css', true]; + yield 'assets sibling' => ['/assets-preview', false]; + yield 'build child' => ['/build/app.js', true]; + yield 'build sibling' => ['/builder', false]; + yield 'profiler root' => ['/_profiler', true]; + yield 'profiler child' => ['/_profiler/123', true]; + yield 'profiler sibling' => ['/_profilerfoo', false]; + yield 'toolbar child' => ['/_wdt/123', true]; + yield 'toolbar sibling' => ['/_wdtfoo', false]; + } + + #[DataProvider('excludedPathCases')] + public function testExcludedPathUsesSegmentBoundaries(string $path, bool $excluded): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + + self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); + } + + public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + $localized = Request::create('/de/api/live/status'); + $localized->attributes->set('_locale', 'de'); + + self::assertFalse($method->invoke($subscriber, $localized)); + self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status'))); + } + + public function testProbePriorityRunsBeforeResponseProducingGates(): void + { + $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + + self::assertSame(['onKernelRequestProbe', 4096], $events[0]); + self::assertGreaterThan(1024, $events[0][1]); + self::assertGreaterThan(768, $events[0][1]); + self::assertGreaterThan(512, $events[0][1]); + self::assertGreaterThan(256, $events[0][1]); + } + + public function testProbeHookSkipsFullEnforcerForNonProbePaths(): void + { + $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(); + $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $subscriber = new RateLimitRequestSubscriber( + $enforcer, + $responses, + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/home'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + + public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + putenv(SetupCompletionMarker::KEY); + $subscriber = $this->subscriberWithRealEnforcer(); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/.env'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode()); + self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testProbeHookUsesForcedBareResponseAfterSetupCompletion(): void
+    {
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/.env'),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestProbe($event);
+
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithUninitializedEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/setup/database', 'POST', ['_setup_action' => 'test_database']),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestOrdinary($event);
+
+        self::assertFalse($event->hasResponse());
+    }
+
+    public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
+    {
+        $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+        $paths->setValue($subscriber, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest');
+
+        self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/database', 'POST', [
+            '_setup_action' => 'test_database',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review/extra', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review', 'GET', [
+            '_setup_action' => 'apply',
+        ])));
+    }
+
+    public function testSetupApplyBeforeSetupCompletionUsesBareTooManyRequestsResponse(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = null;
+
+        for ($i = 0; $i < 6; ++$i) {
+            $event = new RequestEvent(
+                new RateLimitRequestSubscriberTestKernel(),
+                Request::create('/setup/review', 'POST', ['_setup_action' => 'apply'], server: [
+                    'REMOTE_ADDR' => '203.0.113.54',
+                    'HTTP_USER_AGENT' => 'SetupApplyLimiterTest',
+                ]),
+                HttpKernelInterface::MAIN_REQUEST,
+            );
+
+            $subscriber->onKernelRequestOrdinary($event);
+        }
+
+        self::assertNotNull($event);
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('retry-after:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+        self::assertNotNull($event->getResponse()->headers->get('Retry-After'));
+    }
+
+    private function subscriberWithUninitializedEnforcer(): RateLimitRequestSubscriber
+    {
+        return new RateLimitRequestSubscriber(
+            (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(),
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function subscriberWithRealEnforcer(): RateLimitRequestSubscriber
+    {
+        $inspector = new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+        $enforcer = new RateLimitEnforcer(
+            $inspector,
+            new Config($this->connection()),
+            new RateLimitPolicyCatalogue(),
+            new RateLimitSubjectSelector(),
+            new RateLimitLimiterFactory(new ArrayAdapter()),
+            new class implements MessageReporterInterface {
+                public function report(Message $message, array $context = []): Message
+                {
+                    return $message;
+                }
+
+                public function reportBatch(iterable $records): array
+                {
+                    $messages = [];
+                    foreach ($records as $record) {
+                        $messages[] = $record['message'];
+                    }
+
+                    return $messages;
+                }
+            },
+        );
+
+        return new RateLimitRequestSubscriber(
+            $enforcer,
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function responseRenderer(): RateLimitResponseRenderer
+    {
+        return new RateLimitResponseRenderer(
+            new HttpErrorRenderer(
+                (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(),
+                new SetupCompletionMarker(),
+                new AccessRequestMetadata(),
+                dirname(__DIR__, 3),
+                'test',
+            ),
+            (new ReflectionClass(ApiResponder::class))->newInstanceWithoutConstructor(),
+            new AccessRequestMetadata(),
+        );
+    }
+
+    private function connection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+        return $connection;
+    }
+}
+
+final class RateLimitRequestSubscriberTestKernel implements HttpKernelInterface
+{
+    public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
+    {
+        return new Response();
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php
new file mode 100644
index 00000000..992d02c2
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php
@@ -0,0 +1,243 @@
+services();
+        $request = $this->request('/user/login', 'POST');
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($request)->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testLoginSuccessResetClearsSubmittedAccountLoginAttempts(): void
+    {
+        [$enforcer, $resets] = $this->services();
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+                'username' => 'shared-admin',
+                'password' => 'wrong',
+            ], [
+                'REMOTE_ADDR' => '203.0.113.'.(20 + $i),
+            ]))->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.90',
+        ]))->isAllowed());
+
+        self::assertTrue($resets->resetLoginAttempts($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'correct',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.91',
+        ])));
+
+        self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.92',
+        ]))->isAllowed());
+    }
+
+    public function testLoginSuccessResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String);
+        [$enforcer, $resets] = $this->services(config: $config);
+        $request = $this->request('/user/login', 'POST');
+
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testCaptchaResetRequiresVerifiedProviderBackedSuccess(): void
+    {
+        [, $resets] = $this->services();
+        $request = $this->request('/captcha/submit', 'POST');
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'none', true));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'turnstile', false));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+    }
+
+    public function testCaptchaResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String);
+        $cache = new ArrayAdapter();
+        $factory = new RateLimitLimiterFactory($cache);
+        [, $resets] = $this->services(config: $config, factory: $factory);
+        $request = $this->request('/captcha/submit', 'POST');
+        $catalogue = new RateLimitPolicyCatalogue();
+        $descriptor = $catalogue->descriptor('captcha.failure', RateLimitProfile::Panic);
+        self::assertNotNull($descriptor);
+        $inspector = $this->inspector();
+        $selector = new RateLimitSubjectSelector();
+        $subjectKeys = $selector->subjectKeys($descriptor, $inspector->inspect($request)['subjects']);
+        self::assertNotSame([], $subjectKeys);
+
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+    }
+
+    public function testOffModeDoesNotTouchResetStorage(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String);
+        [, $resets] = $this->services(config: $config, factory: new RateLimitLimiterFactory(new ResetFailingCachePool()));
+
+        self::assertFalse($resets->resetLoginAttempts($this->request('/user/login', 'POST')));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+    }
+
+    public function testResetFailureReportsThroughMessageLayer(): void
+    {
+        $messages = new RecordingRateLimitMessageReporter();
+        [, $resets] = $this->services(factory: new RateLimitLimiterFactory(new ResetFailingCachePool()), messages: $messages);
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+        self::assertSame(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, $messages->records[0]['message']->code());
+        self::assertSame('security.rate_limit.reset', $messages->records[0]['context']['operation']);
+    }
+
+    /**
+     * @return array{0: RateLimitEnforcer, 1: RateLimitResetService}
+     */
+    private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array
+    {
+        $inspector = $this->inspector();
+        $catalogue = new RateLimitPolicyCatalogue();
+        $selector = new RateLimitSubjectSelector();
+        $factory ??= new RateLimitLimiterFactory(new ArrayAdapter());
+        $config ??= new Config($this->connection());
+        $messages ??= new RecordingRateLimitMessageReporter();
+
+        return [
+            new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $messages),
+            new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $messages),
+        ];
+    }
+
+    private function inspector(): AbuseRequestInspector
+    {
+        return new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+    }
+
+    /**
+     * @param array $parameters
+     * @param array $server
+     */
+    private function request(string $path, string $method, array $parameters = [], array $server = []): Request
+    {
+        return Request::create($path, $method, $parameters, server: [
+            'REMOTE_ADDR' => '203.0.113.50',
+            'HTTP_USER_AGENT' => 'RateLimitResetServiceTest',
+            ...$server,
+        ]);
+    }
+
+    private function connection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+        return $connection;
+    }
+}
+
+final class ResetFailingCachePool implements CacheItemPoolInterface
+{
+    public function getItem(string $key): CacheItemInterface
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function getItems(array $keys = []): iterable
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function hasItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function clear(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItems(array $keys): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function save(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function saveDeferred(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function commit(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
new file mode 100644
index 00000000..1747003b
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
@@ -0,0 +1,51 @@
+
+     */
+    public static function jsonSurfaceCases(): iterable
+    {
+        yield 'api v1' => ['/api/v1/status', true];
+        yield 'cron root' => ['/cron', true];
+        yield 'cron child' => ['/cron/run', true];
+        yield 'cron lookalike content' => ['/cronjobs', false];
+        yield 'browser content' => ['/docs', false];
+    }
+
+    #[DataProvider('jsonSurfaceCases')]
+    public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+
+        self::assertSame($json, $method->invoke($renderer, Request::create($path)));
+    }
+
+    public function testJsonSurfaceDoesNotUseLocalizedTechnicalPathSegments(): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+        $localized = Request::create('/de/cron/run');
+        $localized->attributes->set('_locale', 'de');
+
+        self::assertFalse($method->invoke($renderer, $localized));
+        self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run')));
+    }
+}
diff --git a/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
new file mode 100644
index 00000000..fd7b477e
--- /dev/null
+++ b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
@@ -0,0 +1,37 @@
+}>
+     */
+    public array $records = [];
+
+    public function report(Message $message, array $context = []): Message
+    {
+        $this->records[] = [
+            'message' => $message,
+            'context' => $context,
+        ];
+
+        return $message;
+    }
+
+    public function reportBatch(iterable $records): array
+    {
+        $messages = [];
+
+        foreach ($records as $record) {
+            $messages[] = $this->report($record['message'], $record['context'] ?? []);
+        }
+
+        return $messages;
+    }
+}
diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php
index c01ad736..fd9087ae 100644
--- a/tests/Security/SessionVisitorBindingSubscriberTest.php
+++ b/tests/Security/SessionVisitorBindingSubscriberTest.php
@@ -5,10 +5,19 @@
 namespace App\Tests\Security;
 
 use App\Core\Access\AccessActor;
+use App\Core\Log\AccessRequestMetadata;
 use App\Core\Log\AuditLoggerInterface;
+use App\Core\Log\DatabaseLogRetentionPolicy;
 use App\Core\Statistics\VisitorIdGenerator;
 use App\Entity\UserAccount;
+use App\Security\Abuse\AbuseRequestInspector;
+use App\Security\Abuse\AbuseSubjectResolver;
+use App\Security\Abuse\ActionCostCatalogue;
+use App\Security\Abuse\RequestIntentClassifier;
+use App\Security\Abuse\SecuritySignalRecorder;
 use App\Security\SessionVisitorBindingSubscriber;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DriverManager;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -81,6 +90,54 @@ public function testItTerminatesSessionsWhenTheBoundVisitorChanges(): void
         self::assertSame($currentVisitorId, $auditLogger->records[0]['context']['current_visitor_id']);
     }
 
+    public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void
+    {
+        $tokenStorage = new TokenStorage();
+        $user = $this->user();
+        $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()));
+        $auditLogger = new RecordingSessionAuditLogger();
+        $generator = new VisitorIdGenerator('test-secret');
+        $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']);
+        $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-session-mismatch');
+        $session = new Session(new MockArraySessionStorage());
+        $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234');
+        $request->setSession($session);
+        $connection = $this->signalConnection();
+
+        $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST);
+
+        (new SessionVisitorBindingSubscriber(
+            $tokenStorage,
+            $generator,
+            $auditLogger,
+            new AbuseRequestInspector(
+                new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'),
+                new RequestIntentClassifier(),
+                new ActionCostCatalogue(),
+            ),
+            new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)),
+            new AccessRequestMetadata(),
+        ))->onKernelRequest($event);
+
+        $row = $connection->fetchAssociative('SELECT * FROM security_signal_event');
+        self::assertIsArray($row);
+        self::assertSame('session', $row['signal_type']);
+        self::assertSame('security.signal.session_visitor_mismatch', $row['reason_code']);
+        self::assertSame('ERROR', $row['severity']);
+        self::assertSame(90, (int) $row['confidence']);
+        self::assertSame('user', $row['subject_type']);
+        self::assertSame($user->uid(), $row['subject_identifier']);
+        self::assertSame('request-session-mismatch', $row['request_id']);
+        self::assertSame($generator->generate($request), $row['visitor_id']);
+
+        $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR);
+        self::assertSame('previousVisitorId1234', $context['previous_visitor_id']);
+        self::assertSame($generator->generate($request), $context['current_visitor_id']);
+        self::assertSame(1, $context['change_count']);
+        self::assertIsString($context['ip_bucket']);
+        self::assertNotSame('', $context['ip_bucket']);
+    }
+
     public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void
     {
         $tokenStorage = new TokenStorage();
@@ -119,6 +176,15 @@ private function user(): UserAccount
             'hash',
         );
     }
+
+    private function signalConnection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+        $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)');
+
+        return $connection;
+    }
 }
 
 final class RecordingSessionAuditLogger implements AuditLoggerInterface
diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php
index 13566de4..c73e11fe 100644
--- a/tests/Setup/SetupDefaultSeedTest.php
+++ b/tests/Setup/SetupDefaultSeedTest.php
@@ -5,11 +5,16 @@
 namespace App\Tests\Setup;
 
 use App\Api\ApiFeaturePolicy;
+use App\Core\AdminAcl\AdminFeatureDefaults;
+use App\Core\AdminAcl\AdminFeatureOverrideStore;
 use App\Core\Config\ConfigDefaultProviderInterface;
+use App\Core\Geo\MaxMindGeoIpConfig;
+use App\Core\Log\DatabaseLogRetentionPolicy;
 use App\Setup\DatabaseDriver;
 use App\Setup\SetupDefaultSeed;
 use App\Setup\SetupInput;
 use App\Scheduler\SchedulerSettings;
+use App\Security\Abuse\SuspiciousProbePathMatcher;
 use App\Security\UserFlowConfig;
 use PHPUnit\Framework\TestCase;
 
@@ -29,6 +34,13 @@ public function testItBuildsInputAwareConfigDefaults(): void
         self::assertTrue($settings[ApiFeaturePolicy::ENABLED_KEY]);
         self::assertFalse($settings[ApiFeaturePolicy::CORS_ENABLED_KEY]);
         self::assertSame([], $settings[ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY]);
+        self::assertFalse($settings[MaxMindGeoIpConfig::ENABLED_KEY]);
+        self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]);
+        self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]);
+        self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]);
+        self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]);
+        self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]);
+        self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]);
     }
 
     public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void
@@ -64,8 +76,16 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues(
             UserFlowConfig::REGISTRATION_MODE_KEY,
             \App\Core\Log\ConfigAuditLogPolicy::ENABLED_KEY,
             \App\Core\Log\ConfigAuditLogPolicy::EVENTS_KEY,
+            DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY,
+            SuspiciousProbePathMatcher::PATTERNS_KEY,
             \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY,
             \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY,
+            MaxMindGeoIpConfig::ENABLED_KEY,
+            MaxMindGeoIpConfig::DATABASE_PATH_KEY,
+            MaxMindGeoIpConfig::LICENSE_KEY_KEY,
             ApiFeaturePolicy::ENABLED_KEY,
             ApiFeaturePolicy::CORS_ENABLED_KEY,
             ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY,
@@ -73,6 +93,7 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues(
             SchedulerSettings::GET_AUTH_ENABLED_KEY,
             SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY,
             SchedulerSettings::WEB_TRIGGER_ENABLED_KEY,
+            AdminFeatureOverrideStore::CONFIG_KEY,
         ];
         $inputOnlyKeys = [
             'site.title',
diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php
index dc28d08f..e1550771 100644
--- a/tests/Setup/SetupRunnerTest.php
+++ b/tests/Setup/SetupRunnerTest.php
@@ -7,6 +7,7 @@
 use App\Core\ActionLog\ActionLog;
 use App\Core\Asset\AssetMessageCode;
 use App\Core\Asset\AssetMessageKey;
+use App\Core\Geo\MaxMindGeoIpConfig;
 use App\Core\Process\PhpCliBinaryPreferenceStore;
 use App\Core\Process\PhpCliBinaryValidator;
 use App\Database\DatabaseReadyState;
@@ -121,6 +122,10 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
 
         $pdo = new PDO('sqlite:'.$databasePath);
         $configRows = $pdo->query('SELECT config_key, value FROM config_entry')->fetchAll(PDO::FETCH_KEY_PAIR);
+        $geoIpLicenseSensitive = $pdo->query(sprintf(
+            "SELECT sensitive FROM config_entry WHERE config_key = '%s'",
+            MaxMindGeoIpConfig::LICENSE_KEY_KEY,
+        ))->fetchColumn();
         $aclGroups = $pdo->query('SELECT identifier, min_role FROM acl_group WHERE json_extract(metadata, "$.seeded_by") = "setup" ORDER BY min_role')->fetchAll(PDO::FETCH_ASSOC);
         $adminUser = $pdo->query("SELECT password_hash, role FROM user_account WHERE username = 'admin'")->fetch(PDO::FETCH_ASSOC);
         $stateMarkers = $pdo->query("SELECT marker_key, marker_value FROM state_marker WHERE subject_type = 'user_account' ORDER BY marker_key")->fetchAll(PDO::FETCH_KEY_PAIR);
@@ -129,6 +134,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
         $homeTitle = $pdo->query(sprintf("SELECT field_content FROM content_field_value WHERE revision_uid = '%s' AND field_identifier = 'title' AND language = '%s'", $seed->homeContentRevision()['uid'], $input->language()))->fetchColumn();
 
         self::assertSame($seed->configMap($input), $this->decodedConfigRows($configRows, array_keys($seed->configMap($input))));
+        self::assertSame(1, (int) $geoIpLicenseSensitive);
         self::assertSame(array_map(static fn (array $group): array => [
             'identifier' => $group['identifier'],
             'min_role' => $group['min_role'],
diff --git a/tests/View/Http/HttpErrorRendererTest.php b/tests/View/Http/HttpErrorRendererTest.php
new file mode 100644
index 00000000..a6e6a391
--- /dev/null
+++ b/tests/View/Http/HttpErrorRendererTest.php
@@ -0,0 +1,125 @@
+previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null;
+        $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null;
+        $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY);
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+    }
+
+    protected function tearDown(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+
+        if (null !== $this->previousServerValue) {
+            $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue;
+        }
+
+        if (null !== $this->previousEnvValue) {
+            $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue;
+        }
+
+        is_string($this->previousPutenvValue)
+            ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue)
+            : putenv(SetupCompletionMarker::KEY);
+    }
+
+    /**
+     * @return iterable
+     */
+    public static function setupBareStatusCases(): iterable
+    {
+        foreach (Response::$statusTexts as $statusCode => $statusText) {
+            if ($statusCode >= 400 && $statusCode < 600) {
+                yield sprintf('%d %s', $statusCode, $statusText) => [$statusCode];
+            }
+        }
+    }
+
+    #[DataProvider('setupBareStatusCases')]
+    public function testItReturnsBareKnownErrorResponsesBeforeSetupCompletion(int $statusCode): void
+    {
+        $response = $this->renderer()->resolve($statusCode, Request::create('/setup/missing'));
+
+        self::assertSame($statusCode, $response->getStatusCode());
+        self::assertStringContainsString(sprintf(
+            '%d - %s',
+            $statusCode,
+            htmlspecialchars(Response::$statusTexts[$statusCode], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+        ), (string) $response->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $response->getContent());
+        self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+        self::assertStringContainsString('text/html', (string) $response->headers->get('Content-Type'));
+    }
+
+    public function testBareResponseKeepsAdditionalHeadersAndContext(): void
+    {
+        $request = Request::create('/setup/review');
+        $request->attributes->set('_access_request_id', 'req-test-123');
+        $response = $this->renderer()->bare(Response::HTTP_TOO_MANY_REQUESTS, $request, [
+            'bare_context' => 'retry-after: 60',
+        ], ['Retry-After' => '60']);
+
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $response->getContent());
+        self::assertStringContainsString('

retry-after: 60

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-test-123
', (string) $response->getContent()); + self::assertSame('60', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + public function testResolveCanForceBareResponseAfterSetupCompletion(): void + { + $_SERVER[SetupCompletionMarker::KEY] = '1'; + $request = Request::create('/blocked'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'req-forced'); + $response = $this->renderer()->resolve(Response::HTTP_FORBIDDEN, $request, [ + 'bare_context' => '', + ], forceBare: true); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertStringContainsString('403 - Forbidden', (string) $response->getContent()); + self::assertStringContainsString('

<blocked>

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-forced
', (string) $response->getContent()); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + dirname(__DIR__, 2), + 'test', + ); + } +} diff --git a/tests/View/Twig/ViewTwigExtensionTest.php b/tests/View/Twig/ViewTwigExtensionTest.php index 07db3cac..6b489efc 100644 --- a/tests/View/Twig/ViewTwigExtensionTest.php +++ b/tests/View/Twig/ViewTwigExtensionTest.php @@ -18,7 +18,7 @@ public function testItExposesViewGlobalsFunctionsAndMarkdownFilter(): void '{{ view_context().system_package.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ package_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}', )->render(); - self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.4|

ok

', $html); + self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.5|

ok

', $html); } public function testItRendersSafeHtmlAttributes(): void diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index eef80e73..13c73baf 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -29,9 +29,11 @@ admin: user_settings: 'Benutzer' mail_settings: 'Mail' security_settings: 'Sicherheit' + logging_settings: 'Logs' package_settings: 'Pakete' statistics_settings: 'Statistiken' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System-Info' actions: @@ -41,6 +43,8 @@ admin: label: 'Assets neu bauen' cache_clear: label: 'Cache leeren' + geoip_database_update: + label: 'GeoIP2-Datenbank laden' dashboard: title: 'Admin-Dashboard' foundation_title: 'Backend-Fundament' @@ -484,6 +488,9 @@ admin: mercure_health: label: 'Mercure-Health' description: 'Prüft den Mercure-Hub und startet den lokalen Hub, sofern unterstützt.' + geoip2_database_update: + label: 'GeoIP2-Datenbankupdate' + description: 'Lädt die lokale MaxMind GeoIP2-City-Datenbank herunter und ersetzt sie atomar, wenn ein License-Key konfiguriert ist.' table: job: 'Job' source: 'Quelle' @@ -593,19 +600,21 @@ admin: foundation_text: 'Operations-Logs, Audit-Ansichten, Paketdiagnostik und sicherheitsrelevante Ereignisse docken hier an.' empty_value: 'n/a' sources: + label: 'Logquellen' application: 'Anwendung' message: 'Meldungen' audit: 'Audit' access: 'Zugriffe' + security_signal: 'Security-Signale' filters: title: 'Filter' source: 'Log' level: 'Level' - any_level: 'Alle Level' search: 'Suche' window: 'Zeitraum' match: 'Treffer' audit_action: 'Audit-Aktion' + signal_reason: 'Signal-Grund' per_page: 'Zeilen' limit: 'Limit' contains: 'Enthält' @@ -620,8 +629,6 @@ admin: entries: title: 'Einträge' empty: 'Keine Log-Einträge entsprechen den aktuellen Filtern.' - no_files: 'Für diese Auswahl existiert noch keine Log-Datei.' - files: 'Gelesen wird: %files%' open: 'Details' page_summary: 'Seite %page% von %pages% · %total% Einträge' previous: 'Zurück' @@ -637,6 +644,8 @@ admin: location: 'Ort' user: 'Nutzer' action: 'Aktion' + security_signal: 'Signal' + subject: 'Subjekt' channel: 'Channel' context: 'Kontext' details: 'Details' @@ -675,6 +684,10 @@ admin: all: 'Alle gespeicherten Events' settings: title: 'Einstellungen' + acl: + title: 'ACL' + foundation_title: 'ACL-Matrix' + foundation_text: 'Owner-gesteuerte Feature-Berechtigungen für Admin-, Editor- und zukünftige Frontend-Surfaces.' redirect_title: 'Einstellungsbereiche' redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Pakete mit einfachen Setting-Definitionen erscheinen unter den Paket-Einstellungen.' general: @@ -696,11 +709,29 @@ admin: security: title: 'Sicherheits-Einstellungen' foundation_title: 'Sicherheits-Konfigurationsfundament' - foundation_text: 'Sicherheits-, Captcha-, Logging- und Missbrauchsschutz-Einstellungen docken hier an.' + foundation_text: 'Sicherheits-, Captcha-, passive Signal-Retention- und Missbrauchsschutz-Einstellungen docken hier an.' + logging: + title: 'Log-Einstellungen' + foundation_title: 'Datenbank-Log-Projektionen' + foundation_text: 'Message-, Audit- und Access-Logs behalten separat ihre festen rotierenden File-Logs. Diese Einstellungen steuern, wie lange die durchsuchbaren Datenbank-Kopien verfügbar bleiben.' statistics: title: 'Statistik-Einstellungen' foundation_title: 'Statistik-Konfiguration' - foundation_text: 'Aufzeichnung und Anzeige von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + foundation_text: 'Aufzeichnung, Anzeige und GeoIP-Anreicherung von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + geoip: + status: + title: 'GeoIP2-Status' + provider: 'Provider' + state: 'Status' + database_edition: 'Datenbank-Edition' + database_build_date: 'Datenbank-Build-Datum' + failure_code: 'Fehlercode' + values: + ready: 'Bereit' + disabled: 'Deaktiviert' + unconfigured: 'Nicht konfiguriert' + unavailable: 'Nicht verfügbar' + unknown: 'Unbekannt' api: title: 'API-Einstellungen' foundation_title: 'API-Verfügbarkeit' @@ -738,6 +769,131 @@ admin: required: 'Dieses Feld ist erforderlich.' save_failed: 'Die Einstellungen konnten nicht gespeichert werden.' default_acl_group_unavailable: 'Trage eine bestehende ACL-Gruppe mit Mindestrolle User oder niedriger ein oder lasse das Feld leer.' + fields: + captcha_enabled: + label: 'Captcha aktivieren' + captcha_provider: + label: 'Captcha-Provider' + captcha_preview: + label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' + audit_enabled: + label: 'Audit-Logging aktivieren' + audit_events: + label: 'Audit-Ereigniskategorien' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + options: + captcha: + none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' + audit: + authentication: 'Authentifizierungsereignisse' + backend_actions: 'Backend-Wartungsaktionen' + operations: 'Operations-Wartungsaktionen' + packages: 'Paket-Lifecycle-Aktionen' + settings: 'Einstellungsänderungen' + other: 'Weitere zukünftige Audit-Ereignisse' + acl: + actions: + save: 'ACL-Matrix speichern' + categories: + diagnostics: 'Diagnostik' + operations: 'Operationen' + packages: 'Pakete' + package_settings: 'Paket-Einstellungen' + settings: 'Einstellungen' + system: 'System' + users: 'Benutzer' + empty: 'Für diese Surface sind noch keine Feature-Flags registriert.' + groups: + empty: 'Keine ACL-Gruppen können diese Surface gewähren.' + non_configurable: 'Read-only' + read_only_action: 'Du darfst diese Aktion prüfen, aber die aktuelle ACL-Policy erlaubt keine Mutation.' + states: + inherit: 'Erben' + denied: 'Verweigert' + visible: 'Sichtbar' + mutable: 'Mutierbar' + surfaces: + label: 'ACL-Surfaces' + admin: 'Admin' + admin_text: 'Administrative Feature-Flags. ACL-Gruppen können Berechtigungen nur an Benutzer vergeben, die bereits das Admin-Surface-Gate passieren.' + editor: 'Editor' + editor_text: 'Für zukünftige Editor-Feature-Flags vorbereitet. Editor-Provider können hier später Zeilen registrieren.' + frontend: 'Frontend' + frontend_text: 'Reserviert für ausdrücklich entworfene zukünftige Frontend-Features wie Inline-Editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL-Gruppen-Grants' + features: + admin_settings_security: + label: 'Sicherheits-Einstellungen' + description: 'Security-Policy, Audit-Kategorien, Signal-Retention und verdächtige Probe-Pfade.' + admin_settings_logging: + label: 'Log-Retention-Einstellungen' + description: 'Datenbank-Lookup-Retention für Message-, Audit- und Access-Log-Projektionen.' + admin_settings_statistics: + label: 'Statistik-Einstellungen' + description: 'Aufzeichnung und Anzeige von Zugriffsstatistiken.' + admin_settings_statistics_geoip: + label: 'GeoIP-Einstellungen' + description: 'GeoIP-Aktivierung, lokaler Datenbankpfad, geschützter MaxMind-License-Key und Status.' + admin_settings_api: + label: 'API-Einstellungen' + description: 'Globale API-Verfügbarkeit und CORS-Erweiterungen.' + admin_settings_scheduler: + label: 'Scheduler-Einstellungen' + description: 'Scheduler-Aktivierung, Web-Triggering, GET-Authentifizierung und Package-Action-Queues.' + admin_settings_packages: + label: 'Paket-Einstellungen' + description: 'Core-Einstellungen für Paket-Updates und paket-eigene Einstellungsseiten.' + admin_settings_package: + description: 'Paket-eigene Einstellungsseite für ein aktives Paket.' + admin_logs: + label: 'Logs' + description: 'Administrative Log-Review-Flächen.' + admin_packages: + label: 'Pakete und Themes' + description: 'Paket- und Themeverwaltung inklusive Installation, Aktivierung, Deaktivierung, Reparatur, Löschung und Purge-Workflows.' + admin_packages_self_update: + label: 'System-Paket-Self-Update' + description: 'Owner-only Self-Update-Workflows für das System-Paket.' + admin_backup_restore: + label: 'Backup, Export und Restore' + description: 'Full-Data-Backup, Export, Download und Restore-Workflows.' + admin_operations: + label: 'Operationen' + description: 'Live-Operation-Inspektion und operative Statusflächen.' + admin_actions_maintenance: + label: 'Wartungsaktionen' + description: 'Manuelle Wartungsaktionen wie Cache-Clear und Asset-Rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler-Task-Inspektion und Run-Controls.' + admin_users: + label: 'Benutzer' + description: 'Benutzeradministration und Account-Verwaltung.' + admin_users_acl: + label: 'Benutzer-ACL-Gruppen' + description: 'ACL-Gruppenadministration und Membership-Review.' + admin_users_review: + label: 'Benutzer-Reviews' + description: 'Queues für Einladungen, Registrierungen und Benutzer-Reviews.' + admin_support: + label: 'Support-Bundles' + description: 'Erzeugung und Download von Diagnose- oder Support-Bundles.' fields: site_title: label: 'Seitentitel' @@ -786,10 +942,38 @@ admin: label: 'Captcha-Provider' captcha_preview: label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: label: 'Audit-Ereigniskategorien' + message_log_retention_days: + label: 'Datenbank-Retention für Meldungslogs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. File-Logs behalten separat ihre feste 30-Tage-Rotation.' + audit_log_retention_days: + label: 'Datenbank-Retention für Audit-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Audit-Daten Request-bezogene Identifier enthalten können.' + access_log_retention_days: + label: 'Datenbank-Retention für Access-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Access-Logs IP-abgeleitete Daten enthalten können.' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + geoip_enabled: + label: 'GeoIP-Lookups aktivieren' + help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' + geoip_license_link: + label: 'Kostenlosen GeoLite2-License-Key bei MaxMind erhalten' + geoip_database_path: + label: 'MaxMind-Datenbankpfad' + help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Downloads werden atomar in diesen Pfad geschrieben.' + geoip_license_key: + label: 'MaxMind-License-Key' + help: 'Wird als sensitive Konfiguration gespeichert. Ein gespeicherter Key aktiviert die GeoIP2-Download-Aktion auf dieser Seite.' statistics_enabled: label: 'Zugriffsstatistiken aktivieren' statistics_respect_dnt: @@ -826,6 +1010,11 @@ admin: setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 75db020e..0f190e2d 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -106,8 +106,24 @@ message: snapshot_store_failed: 'Speicherung des Zugriffsstatistik-Snapshots ist fehlgeschlagen.' cleanup_failed: 'Aufbewahrungs-Cleanup der Zugriffsstatistiken ist fehlgeschlagen.' trace_id_invalid: 'Zugriffsstatistiken haben eine ungültige %label% erhalten.' + geoip: + download: + missing_license_key: 'Der GeoIP2-Datenbankdownload benötigt einen MaxMind-License-Key.' + invalid_license_key: 'MaxMind hat den GeoIP2-Datenbankdownload abgelehnt. Prüfe den konfigurierten License-Key.' + server_unreachable: 'Der MaxMind-Downloadserver konnte nicht erreicht werden. Versuche es später erneut.' + failed: 'Der GeoIP2-Datenbankdownload ist fehlgeschlagen. Prüfe den Diagnosekontext für den HTTP-Status.' + archive_invalid: 'Das heruntergeladene GeoIP2-Archiv konnte nicht entpackt werden.' + database_missing: 'Das heruntergeladene GeoIP2-Archiv enthielt keine Datenbankdatei.' + database_invalid: 'Die heruntergeladene GeoIP2-Datenbank konnte nicht validiert werden.' + write_failed: 'Die GeoIP2-Datenbank konnte nicht geschrieben werden.' + completed: 'GeoIP2-Datenbank wurde unter "%path%" aktualisiert.' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET-Rotation-Recovery konnte nicht alle Owner-Reset-Links zustellen. Nutze die Emergency-Recovery-Datei, sofern vorhanden, oder führe "%command%" für die gelisteten Owner manuell aus.' + rate_limit: + exceeded: 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.' + request_rejected: 'Die Anfrage konnte nicht akzeptiert werden.' + storage_degraded: 'Rate-Limit-Speicher war nicht verfügbar; die Anfrage wurde zugelassen.' + reset_degraded: 'Rate-Limit-Reset-Speicher war nicht verfügbar.' event: hook: invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.' diff --git a/translations/languages/de/setup.yaml b/translations/languages/de/setup.yaml index a24b8f79..5f61cf7e 100644 --- a/translations/languages/de/setup.yaml +++ b/translations/languages/de/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Website-URL' registration_mode: label: 'Benutzerregistrierung' + options: + disabled: 'Deaktiviert' + admin_approval: 'Admin-Freigabe' + auto_approval: 'Automatische Freigabe' username_change_enabled: label: 'Benutzernamenänderungen erlauben' route_prefixes_enabled: diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index e35dc84b..f5a25ba4 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Zugriff verweigert' message: 'Diese Route ist geschützt oder nicht für direkten Zugriff verfügbar.' + 400: + title: 'Ungültige Anfrage' + message: 'Die Anfrage konnte nicht akzeptiert werden.' 404: title: 'Seite nicht gefunden' message: 'Die angefragte Seite wurde nicht gefunden oder ist nicht veröffentlicht.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c9bf50b3..2fecffd0 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -29,9 +29,11 @@ admin: user_settings: 'Users' mail_settings: 'Mail' security_settings: 'Security' + logging_settings: 'Logs' package_settings: 'Packages' statistics_settings: 'Statistics' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System information' actions: @@ -41,6 +43,8 @@ admin: label: 'Rebuild assets' cache_clear: label: 'Clear cache' + geoip_database_update: + label: 'Download GeoIP2 database' dashboard: title: 'Admin dashboard' foundation_title: 'Backend foundation' @@ -484,6 +488,9 @@ admin: mercure_health: label: 'Mercure health' description: 'Checks the Mercure hub and starts the local hub when supported.' + geoip2_database_update: + label: 'GeoIP2 database update' + description: 'Downloads and atomically replaces the local MaxMind GeoIP2 City database when a license key is configured.' table: job: 'Job' source: 'Source' @@ -593,19 +600,21 @@ admin: foundation_text: 'Operational logs, audit views, package diagnostics, and security-relevant events will attach here.' empty_value: 'n/a' sources: + label: 'Log sources' application: 'Application' message: 'Messages' audit: 'Audit' access: 'Access' + security_signal: 'Security signals' filters: title: 'Filters' source: 'Log' level: 'Level' - any_level: 'Any level' search: 'Search' window: 'Time window' match: 'Match' audit_action: 'Audit action' + signal_reason: 'Signal reason' per_page: 'Rows' limit: 'Limit' contains: 'Contains' @@ -620,8 +629,6 @@ admin: entries: title: 'Entries' empty: 'No log entries match the current filters.' - no_files: 'No log file exists for this selection yet.' - files: 'Reading: %files%' open: 'Details' page_summary: 'Page %page% of %pages% · %total% entries' previous: 'Previous' @@ -637,6 +644,8 @@ admin: location: 'Location' user: 'User' action: 'Action' + security_signal: 'Signal' + subject: 'Subject' channel: 'Channel' context: 'Context' details: 'Details' @@ -675,6 +684,10 @@ admin: all: 'All stored events' settings: title: 'Settings' + acl: + title: 'ACL' + foundation_title: 'ACL matrix' + foundation_text: 'Owner-controlled feature permissions for administrative, editor, and future frontend surfaces.' redirect_title: 'Settings sections' redirect_text: 'General settings are the first built-in settings section. Active packages with simple setting definitions appear under package settings.' general: @@ -696,11 +709,29 @@ admin: security: title: 'Security settings' foundation_title: 'Security configuration foundation' - foundation_text: 'Security, captcha, logging, and abuse-control settings will attach here.' + foundation_text: 'Security, captcha, passive-signal retention, and abuse-control settings will attach here.' + logging: + title: 'Log settings' + foundation_title: 'Database log projections' + foundation_text: 'Message, audit, and access logs keep their fixed rotating file logs separately. These settings control how long the searchable database copies stay available.' statistics: title: 'Statistics settings' foundation_title: 'Statistics configuration' - foundation_text: 'Access statistics recording and display can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + foundation_text: 'Access statistics recording, display, and GeoIP enrichment can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + geoip: + status: + title: 'GeoIP2 status' + provider: 'Provider' + state: 'State' + database_edition: 'Database edition' + database_build_date: 'Database build date' + failure_code: 'Failure code' + values: + ready: 'Ready' + disabled: 'Disabled' + unconfigured: 'Unconfigured' + unavailable: 'Unavailable' + unknown: 'Unknown' api: title: 'API settings' foundation_title: 'API availability' @@ -738,6 +769,131 @@ admin: required: 'This field is required.' save_failed: 'The settings could not be saved.' default_acl_group_unavailable: 'Enter an existing ACL group with minimum role User or lower, or leave the field empty.' + fields: + captcha_enabled: + label: 'Enable captcha' + captcha_provider: + label: 'Captcha provider' + captcha_preview: + label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' + audit_enabled: + label: 'Enable audit logging' + audit_events: + label: 'Audit event categories' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' + options: + captcha: + none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' + audit: + authentication: 'Authentication events' + backend_actions: 'Backend maintenance actions' + operations: 'Operations maintenance actions' + packages: 'Package lifecycle actions' + settings: 'Settings changes' + other: 'Other future audit events' + acl: + actions: + save: 'Save ACL matrix' + categories: + diagnostics: 'Diagnostics' + operations: 'Operations' + packages: 'Packages' + package_settings: 'Package settings' + settings: 'Settings' + system: 'System' + users: 'Users' + empty: 'No feature flags are registered for this surface yet.' + groups: + empty: 'No ACL groups can grant this surface.' + non_configurable: 'Read-only' + read_only_action: 'You may review this action, but mutation is not allowed by the current ACL policy.' + states: + inherit: 'Inherit' + denied: 'Denied' + visible: 'Visible' + mutable: 'Mutable' + surfaces: + label: 'ACL surfaces' + admin: 'Admin' + admin_text: 'Administrative feature flags. ACL groups can grant permissions only to users that already pass the Admin surface gate.' + editor: 'Editor' + editor_text: 'Prepared for future editor feature flags. Editor providers can register rows here later.' + frontend: 'Frontend' + frontend_text: 'Reserved for explicitly designed future frontend features such as inline editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL group grants' + features: + admin_settings_security: + label: 'Security settings' + description: 'Security policy, audit categories, signal retention, and suspicious probe patterns.' + admin_settings_logging: + label: 'Log retention settings' + description: 'Database lookup retention for message, audit, and access log projections.' + admin_settings_statistics: + label: 'Statistics settings' + description: 'Access statistics recording and display settings.' + admin_settings_statistics_geoip: + label: 'GeoIP settings' + description: 'GeoIP enablement, local database path, protected MaxMind license key, and status.' + admin_settings_api: + label: 'API settings' + description: 'Global API availability and CORS expansion settings.' + admin_settings_scheduler: + label: 'Scheduler settings' + description: 'Scheduler enablement, web triggering, GET authentication, and package action queues.' + admin_settings_packages: + label: 'Package settings' + description: 'Core package-update settings and package-owned settings pages.' + admin_settings_package: + description: 'Package-owned settings page for an active package.' + admin_logs: + label: 'Logs' + description: 'Administrative log review surfaces.' + admin_packages: + label: 'Packages and themes' + description: 'Package and theme management, including install, activation, deactivation, repair, deletion, and purge workflows.' + admin_packages_self_update: + label: 'System package self-update' + description: 'Owner-only system package self-update workflows.' + admin_backup_restore: + label: 'Backup, export, and restore' + description: 'Full-data backup, export, download, and restore workflows.' + admin_operations: + label: 'Operations' + description: 'Live operation inspection and operational status surfaces.' + admin_actions_maintenance: + label: 'Maintenance actions' + description: 'Manual maintenance actions such as cache clear and asset rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler task inspection and run controls.' + admin_users: + label: 'Users' + description: 'User administration and account management.' + admin_users_acl: + label: 'User ACL groups' + description: 'ACL group administration and membership review.' + admin_users_review: + label: 'User reviews' + description: 'User invitation, registration, and review queues.' + admin_support: + label: 'Support bundles' + description: 'Diagnostic or support-bundle generation and download.' fields: site_title: label: 'Site title' @@ -786,10 +942,38 @@ admin: label: 'Captcha provider' captcha_preview: label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' audit_enabled: label: 'Enable audit logging' audit_events: label: 'Audit event categories' + message_log_retention_days: + label: 'Message log database retention' + help: 'Database lookup copy retention in days. File logs keep their fixed 30-day rotation separately.' + audit_log_retention_days: + label: 'Audit log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because audit data can contain request-derived identifiers.' + access_log_retention_days: + label: 'Access log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because access logs can contain IP-derived data.' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' + geoip_enabled: + label: 'Enable GeoIP lookups' + help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' + geoip_license_link: + label: 'Get a free GeoLite2 license key from MaxMind' + geoip_database_path: + label: 'MaxMind database path' + help: 'Project-relative path to the local .mmdb database. Downloads are written atomically to this path.' + geoip_license_key: + label: 'MaxMind license key' + help: 'Stored as sensitive configuration. Saving a key enables the GeoIP2 database download action on this page.' statistics_enabled: label: 'Enable access statistics' statistics_respect_dnt: @@ -826,6 +1010,11 @@ admin: setup_warnings: 'Setup warnings' captcha: none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index d88650cc..f2be7e66 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -106,8 +106,24 @@ message: snapshot_store_failed: 'Access statistics snapshot storage failed.' cleanup_failed: 'Access statistics retention cleanup failed.' trace_id_invalid: 'Access statistics received an invalid %label%.' + geoip: + download: + missing_license_key: 'GeoIP2 database download needs a MaxMind license key.' + invalid_license_key: 'MaxMind rejected the GeoIP2 database download. Check the configured license key.' + server_unreachable: 'The MaxMind download server could not be reached. Try again later.' + failed: 'The GeoIP2 database download failed. Check the diagnostic context for the HTTP status.' + archive_invalid: 'The downloaded GeoIP2 archive could not be extracted.' + database_missing: 'The downloaded GeoIP2 archive did not contain a database file.' + database_invalid: 'The downloaded GeoIP2 database could not be validated.' + write_failed: 'The GeoIP2 database could not be written.' + completed: 'GeoIP2 database was updated at "%path%".' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET rotation recovery could not deliver every owner reset link. Use the emergency recovery file when available, or run "%command%" manually for the listed owners.' + rate_limit: + exceeded: 'Too many requests. Please wait a moment before trying again.' + request_rejected: 'The request could not be accepted.' + storage_degraded: 'Rate-limit storage was unavailable; the request was allowed.' + reset_degraded: 'Rate-limit reset storage was unavailable.' event: hook: invalid: 'Event hook "%event%" is not a valid public hook definition.' diff --git a/translations/languages/en/setup.yaml b/translations/languages/en/setup.yaml index fedaf852..9e2f0ab3 100644 --- a/translations/languages/en/setup.yaml +++ b/translations/languages/en/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Site URL' registration_mode: label: 'User registration' + options: + disabled: 'Disabled' + admin_approval: 'Admin approval' + auto_approval: 'Auto approval' username_change_enabled: label: 'Allow username changes' route_prefixes_enabled: diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 864c372e..bc4002b6 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Access denied' message: 'This route is protected or not available for direct access.' + 400: + title: 'Bad request' + message: 'The request could not be accepted.' 404: title: 'Page not found' message: 'The requested page could not be found or is not published.'