diff --git a/CLAUDE.md b/CLAUDE.md index c515cda3..b99b0e45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -694,3 +694,18 @@ References: `src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`, `src/ > **⚠ Note:** the older `resource_iris: string[][]` description in the manifest architecture sections and design-decisions list above (e.g. "`resource_iris` is `string[][]`") is now **superseded by #197** — the shape is `NestedJsonStructure[]` (depth-indexed array of `{ iri, children }` trees). + +--- + +### #200 — Cache-safety headers: mark auth-scoped responses non-cacheable so shared caches can distinguish public from personalised (front-end: cwa-nuxt-module #258) ✓ **DONE** + +**Implemented.** Several responses are served from an identical URL but vary by the authenticated session — `Route` and `ResourceManifest` return a draft to a permitted user and the published version otherwise; `ComponentPosition` rewrites its component IRI / exposes admin-only groups by role — with no distinguishing URL or query marker. New `kernel.response` listener `CacheHeadersEventListener` (`src/EventListener/Api/CacheHeadersEventListener.php`, service `silverback.api_components.event_listener.api.cache_headers`, tagged `POST_RESPOND`) marks such responses **`Cache-Control: private, no-store`** (via `Response::setPrivate()` + `addCacheControlDirective('no-store')`, and drops `s-maxage`) **only when the request is authenticated** (`TokenStorageInterface` token whose user is a `UserInterface`) **and** the resource is affected. Anonymous requests are left untouched on API Platform's default `public` (set upstream by `AddHeadersProcessor`, a state processor that runs before this listener), so the only variant a shared cache ever stores is the published one — matching the rule Souin already enforces at the edge by excluding cookie-bearing requests. `no-store` is the authoritative marker the module's service-worker `cacheWillUpdate` drops on (cwa-nuxt-module #258). + +**Design decisions (agreed with Daniel):** +- **No `Vary: Cookie`.** Many cookies churn, so varying on `Cookie` would collapse the shared-cache hit rate. Instead of varying, an authenticated response is simply marked non-cacheable; the cacheable anonymous variant needs no cookie dimension. (The existing `Vary: path` on dynamic `ComponentPosition` GETs — `ComponentPositionEventListener` — is unrelated and untouched.) +- **Personalisation gate = authenticated token**, not cookie presence — a stale/invalid cookie on an otherwise-anonymous request keeps the response cacheable. +- **Affected-resource set is an explicit, configurable allow-list**, maximising static cache hits. Config node `silverback_api_components.http_cache.personalised_resource_classes` (default `[Route, ResourceManifest, ComponentPosition]`, wired via `SilverbackApiComponentsExtension` → `$personalisedResourceClasses` arg). Any **Publishable**-configured resource is treated as personalised *in addition* to the list (matched dynamically via `PublishableAttributeReader::isConfigured()`), so app-defined publishable components are covered without enumeration. A resource **not** in the set (e.g. `Layout`) stays publicly cacheable even for authenticated users. + +**Behat:** `features/main/cache_headers.feature` — scenario outlines assert authenticated GETs of Route / ResourceManifest / ComponentPosition / Publishable → `private` + `no-store`; anonymous GETs of the same → `public`, no `no-store`; and an authenticated GET of an unaffected type (`Layout`) → still `public`. + +References: `src/EventListener/Api/CacheHeadersEventListener.php`, `src/DependencyInjection/Configuration.php` (`addHttpCacheNode`), `src/DependencyInjection/SilverbackApiComponentsExtension.php`, `src/Resources/config/services.php`. diff --git a/features/main/cache_headers.feature b/features/main/cache_headers.feature new file mode 100644 index 00000000..1da39be8 --- /dev/null +++ b/features/main/cache_headers.feature @@ -0,0 +1,43 @@ +Feature: Cache-safety headers so shared caches can distinguish public from personalised responses + In order for CDNs, reverse proxies and service workers to cache API responses safely + As a consumer of the API + I need authenticated responses on affected resource types marked non-cacheable, while public + responses and unaffected resource types stay cacheable + + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + + @loginAdmin + Scenario Outline: Authenticated GETs of affected resource types are marked non-cacheable + Given + When + Then the response status code should be 200 + And the header "Cache-Control" should contain "private" + And the header "Cache-Control" should contain "no-store" + Examples: + | setup | request | + | there is a Route "/contact" with a page | I send a "GET" request to "/_/routes//contact" | + | there is a PageData resource with the route path "/my-route" | I send a "GET" request to "/_/resource_manifest//my-route" | + | there is a ComponentGroup with 1 components | I send a "GET" request to the resource "position_0" | + | there is a published resource with a draft set to publish at "2999-12-31T23:59:59+00:00" | I send a "GET" request to the resource "publishable_published" | + + Scenario Outline: Anonymous GETs of affected resource types stay publicly cacheable + Given + When + Then the response status code should be 200 + And the header "Cache-Control" should contain "public" + And the header "Cache-Control" should not contain "no-store" + Examples: + | setup | request | + | there is a Route "/contact" with a page | I send a "GET" request to "/_/routes//contact" | + | there is a PageData resource with the route path "/my-route" | I send a "GET" request to "/_/resource_manifest//my-route" | + | there is a ComponentGroup with 1 components | I send a "GET" request to the resource "position_0" | + + @loginAdmin + Scenario: An authenticated GET of an unaffected resource type stays publicly cacheable + Given there is a Layout + When I send a "GET" request to the resource "layout" + Then the response status code should be 200 + And the header "Cache-Control" should contain "public" + And the header "Cache-Control" should not contain "no-store" diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9599858d..c451e28f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -11,6 +11,9 @@ namespace Silverback\ApiComponentsBundle\DependencyInjection; +use Silverback\ApiComponentsBundle\ApiResource\ResourceManifest; +use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition; +use Silverback\ApiComponentsBundle\Entity\Core\Route; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -39,10 +42,33 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addPublishableNode($rootNode); $this->addEnabledComponentsNode($rootNode); $this->addUserNode($rootNode); + $this->addHttpCacheNode($rootNode); return $treeBuilder; } + private function addHttpCacheNode(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('http_cache') + ->addDefaultsIfNotSet() + ->info('Cache-safety headers for responses that vary by the authenticated session.') + ->children() + ->arrayNode('personalised_resource_classes') + ->info('Resource classes whose GET responses are marked `private, no-store` for authenticated users. Publishable resources are always treated as personalised in addition to this list.') + ->scalarPrototype()->end() + ->defaultValue([ + Route::class, + ResourceManifest::class, + ComponentPosition::class, + ]) + ->end() + ->end() + ->end() + ->end(); + } + private function addMercureNode(ArrayNodeDefinition $rootNode): void { $rootNode diff --git a/src/DependencyInjection/SilverbackApiComponentsExtension.php b/src/DependencyInjection/SilverbackApiComponentsExtension.php index 4cd49647..102a5136 100644 --- a/src/DependencyInjection/SilverbackApiComponentsExtension.php +++ b/src/DependencyInjection/SilverbackApiComponentsExtension.php @@ -121,6 +121,9 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->findDefinition(PublishableStatusChecker::class); $definition->setArgument('$permission', $config['publishable']['permission']); + $definition = $container->getDefinition('silverback.api_components.event_listener.api.cache_headers'); + $definition->setArgument('$personalisedResourceClasses', $config['http_cache']['personalised_resource_classes']); + $definition = $container->findDefinition(MetadataNormalizer::class); $definition->setArgument('$metadataKey', $config['metadata_key']); diff --git a/src/EventListener/Api/CacheHeadersEventListener.php b/src/EventListener/Api/CacheHeadersEventListener.php new file mode 100644 index 00000000..2392fada --- /dev/null +++ b/src/EventListener/Api/CacheHeadersEventListener.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silverback\ApiComponentsBundle\EventListener\Api; + +use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader; +use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +/** + * Several resource responses are served from an identical URL but vary by the authenticated + * session: a draft is returned to a permitted user and the published version to everyone else + * (Route, ResourceManifest), and ComponentPosition rewrites its component IRI / exposes admin-only + * groups by role. There is no distinguishing URL and no query marker, so a shared cache cannot tell + * a public response from a personalised one. + * + * This listener makes that decision legible in the response itself: when an affected resource is + * requested by an authenticated user, its response is marked `private, no-store` so no shared cache + * (CDN, reverse proxy, or service worker) ever stores it. Anonymous requests are left on API + * Platform's public cache headers, so the only variant a shared cache retains is the published one — + * the same rule the edge cache already enforces by excluding cookie-bearing requests. + * + * @author Daniel West + */ +final class CacheHeadersEventListener +{ + private readonly PublishableAttributeReader $publishableAttributeReader; + + /** + * @param array $personalisedResourceClasses + */ + public function __construct( + private readonly TokenStorageInterface $tokenStorage, + PublishableStatusChecker $publishableStatusChecker, + private readonly array $personalisedResourceClasses = [], + ) { + $this->publishableAttributeReader = $publishableStatusChecker->getAttributeReader(); + } + + public function onPostRespond(ResponseEvent $event): void + { + $request = $event->getRequest(); + if (!$request->isMethodCacheable()) { + return; + } + + $resourceClass = $request->attributes->get('_api_resource_class'); + if (!\is_string($resourceClass) || !$this->isPersonalisableResource($resourceClass)) { + return; + } + + if (!$this->isAuthenticated()) { + return; + } + + $response = $event->getResponse(); + // The body may carry draft or role-specific data tied to this authenticated session, so it + // must never be stored by a shared cache. `private` overrides API Platform's default + // `public`; `no-store` is the authoritative marker a service worker's cacheWillUpdate drops. + $response->setPrivate(); + $response->headers->removeCacheControlDirective('s-maxage'); + $response->headers->addCacheControlDirective('no-store'); + } + + private function isPersonalisableResource(string $resourceClass): bool + { + foreach ($this->personalisedResourceClasses as $affectedClass) { + if (is_a($resourceClass, $affectedClass, true)) { + return true; + } + } + + // Any resource configured as Publishable varies by auth (draft vs published) even when it is + // an app-defined component that cannot be enumerated in the configured list above. + return $this->publishableAttributeReader->isConfigured($resourceClass); + } + + private function isAuthenticated(): bool + { + $token = $this->tokenStorage->getToken(); + + return null !== $token && $token->getUser() instanceof UserInterface; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 1a4723df..90b464b1 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -66,6 +66,7 @@ use Silverback\ApiComponentsBundle\Event\ImagineStoreEvent; use Silverback\ApiComponentsBundle\Event\JWTRefreshedEvent; use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent; +use Silverback\ApiComponentsBundle\EventListener\Api\CacheHeadersEventListener; use Silverback\ApiComponentsBundle\EventListener\Api\CollectionApiEventListener; use Silverback\ApiComponentsBundle\EventListener\Api\ComponentPositionEventListener; use Silverback\ApiComponentsBundle\EventListener\Api\ComponentUsageEventListener; @@ -1712,6 +1713,19 @@ ->tag('kernel.event_listener', ['event' => ViewEvent::class, 'priority' => EventPriorities::PRE_WRITE, 'method' => 'onPreWrite']) ->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'priority' => EventPriorities::POST_RESPOND, 'method' => 'onPostRespond']); + $services + ->set('silverback.api_components.event_listener.api.cache_headers') + ->class(CacheHeadersEventListener::class) + ->args( + [ + new Reference(TokenStorageInterface::class), + new Reference(PublishableStatusChecker::class), + [], + ] + ) + ->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'priority' => EventPriorities::POST_RESPOND, 'method' => 'onPostRespond']); + $services->alias(CacheHeadersEventListener::class, 'silverback.api_components.event_listener.api.cache_headers'); + $services ->set('silverback.metadata_provider.page_data') ->class(PageDataMetadataProvider::class)