Skip to content

Commit a9f7b18

Browse files
committed
Cache-safety: mark auth-scoped responses private/no-store (#200)
Route, ResourceManifest and ComponentPosition responses are served from an identical URL but vary by the authenticated session, with no distinguishing URL or query marker for a shared cache to key on. Add CacheHeadersEventListener (kernel.response, POST_RESPOND): when the request is authenticated and the resource is in the affected set, override API Platform's default `public` with `Cache-Control: private, no-store`. Anonymous/unaffected responses stay publicly cacheable, so the only variant a shared cache stores is the published one - matching Souin's edge rule. `no-store` is the marker the module's service-worker cacheWillUpdate drops on (cwa-nuxt-module #258). - No Vary: Cookie (would collapse static cache-hit rate); existing Vary: path on dynamic positions untouched. - Affected set = configurable http_cache.personalised_resource_classes (default Route, ResourceManifest, ComponentPosition) plus any Publishable resource, matched dynamically. - Behat features/main/cache_headers.feature covers authed/anon per resource type and an unaffected type (Layout) staying public.
1 parent 9d56deb commit a9f7b18

6 files changed

Lines changed: 195 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,3 +694,18 @@ References: `src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`, `src/
694694
</details>
695695

696696
> **⚠ 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).
697+
698+
---
699+
700+
### #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**
701+
702+
**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).
703+
704+
**Design decisions (agreed with Daniel):**
705+
- **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.)
706+
- **Personalisation gate = authenticated token**, not cookie presence — a stale/invalid cookie on an otherwise-anonymous request keeps the response cacheable.
707+
- **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.
708+
709+
**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`.
710+
711+
References: `src/EventListener/Api/CacheHeadersEventListener.php`, `src/DependencyInjection/Configuration.php` (`addHttpCacheNode`), `src/DependencyInjection/SilverbackApiComponentsExtension.php`, `src/Resources/config/services.php`.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
Feature: Cache-safety headers so shared caches can distinguish public from personalised responses
2+
In order for CDNs, reverse proxies and service workers to cache API responses safely
3+
As a consumer of the API
4+
I need authenticated responses on affected resource types marked non-cacheable, while public
5+
responses and unaffected resource types stay cacheable
6+
7+
Background:
8+
Given I add "Accept" header equal to "application/ld+json"
9+
And I add "Content-Type" header equal to "application/ld+json"
10+
11+
@loginAdmin
12+
Scenario Outline: Authenticated GETs of affected resource types are marked non-cacheable
13+
Given <setup>
14+
When <request>
15+
Then the response status code should be 200
16+
And the header "Cache-Control" should contain "private"
17+
And the header "Cache-Control" should contain "no-store"
18+
Examples:
19+
| setup | request |
20+
| there is a Route "/contact" with a page | I send a "GET" request to "/_/routes//contact" |
21+
| there is a PageData resource with the route path "/my-route" | I send a "GET" request to "/_/resource_manifest//my-route" |
22+
| there is a ComponentGroup with 1 components | I send a "GET" request to the resource "position_0" |
23+
| 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" |
24+
25+
Scenario Outline: Anonymous GETs of affected resource types stay publicly cacheable
26+
Given <setup>
27+
When <request>
28+
Then the response status code should be 200
29+
And the header "Cache-Control" should contain "public"
30+
And the header "Cache-Control" should not contain "no-store"
31+
Examples:
32+
| setup | request |
33+
| there is a Route "/contact" with a page | I send a "GET" request to "/_/routes//contact" |
34+
| there is a PageData resource with the route path "/my-route" | I send a "GET" request to "/_/resource_manifest//my-route" |
35+
| there is a ComponentGroup with 1 components | I send a "GET" request to the resource "position_0" |
36+
37+
@loginAdmin
38+
Scenario: An authenticated GET of an unaffected resource type stays publicly cacheable
39+
Given there is a Layout
40+
When I send a "GET" request to the resource "layout"
41+
Then the response status code should be 200
42+
And the header "Cache-Control" should contain "public"
43+
And the header "Cache-Control" should not contain "no-store"

src/DependencyInjection/Configuration.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
namespace Silverback\ApiComponentsBundle\DependencyInjection;
1313

14+
use Silverback\ApiComponentsBundle\ApiResource\ResourceManifest;
15+
use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition;
16+
use Silverback\ApiComponentsBundle\Entity\Core\Route;
1417
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
1518
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
1619
use Symfony\Component\Config\Definition\ConfigurationInterface;
@@ -39,10 +42,33 @@ public function getConfigTreeBuilder(): TreeBuilder
3942
$this->addPublishableNode($rootNode);
4043
$this->addEnabledComponentsNode($rootNode);
4144
$this->addUserNode($rootNode);
45+
$this->addHttpCacheNode($rootNode);
4246

4347
return $treeBuilder;
4448
}
4549

50+
private function addHttpCacheNode(ArrayNodeDefinition $rootNode): void
51+
{
52+
$rootNode
53+
->children()
54+
->arrayNode('http_cache')
55+
->addDefaultsIfNotSet()
56+
->info('Cache-safety headers for responses that vary by the authenticated session.')
57+
->children()
58+
->arrayNode('personalised_resource_classes')
59+
->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.')
60+
->scalarPrototype()->end()
61+
->defaultValue([
62+
Route::class,
63+
ResourceManifest::class,
64+
ComponentPosition::class,
65+
])
66+
->end()
67+
->end()
68+
->end()
69+
->end();
70+
}
71+
4672
private function addMercureNode(ArrayNodeDefinition $rootNode): void
4773
{
4874
$rootNode

src/DependencyInjection/SilverbackApiComponentsExtension.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ public function load(array $configs, ContainerBuilder $container): void
121121
$definition = $container->findDefinition(PublishableStatusChecker::class);
122122
$definition->setArgument('$permission', $config['publishable']['permission']);
123123

124+
$definition = $container->getDefinition('silverback.api_components.event_listener.api.cache_headers');
125+
$definition->setArgument('$personalisedResourceClasses', $config['http_cache']['personalised_resource_classes']);
126+
124127
$definition = $container->findDefinition(MetadataNormalizer::class);
125128
$definition->setArgument('$metadataKey', $config['metadata_key']);
126129

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\EventListener\Api;
13+
14+
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
15+
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
16+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
17+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
18+
use Symfony\Component\Security\Core\User\UserInterface;
19+
20+
/**
21+
* Several resource responses are served from an identical URL but vary by the authenticated
22+
* session: a draft is returned to a permitted user and the published version to everyone else
23+
* (Route, ResourceManifest), and ComponentPosition rewrites its component IRI / exposes admin-only
24+
* groups by role. There is no distinguishing URL and no query marker, so a shared cache cannot tell
25+
* a public response from a personalised one.
26+
*
27+
* This listener makes that decision legible in the response itself: when an affected resource is
28+
* requested by an authenticated user, its response is marked `private, no-store` so no shared cache
29+
* (CDN, reverse proxy, or service worker) ever stores it. Anonymous requests are left on API
30+
* Platform's public cache headers, so the only variant a shared cache retains is the published one —
31+
* the same rule the edge cache already enforces by excluding cookie-bearing requests.
32+
*
33+
* @author Daniel West <daniel@silverback.is>
34+
*/
35+
final class CacheHeadersEventListener
36+
{
37+
private readonly PublishableAttributeReader $publishableAttributeReader;
38+
39+
/**
40+
* @param array<class-string> $personalisedResourceClasses
41+
*/
42+
public function __construct(
43+
private readonly TokenStorageInterface $tokenStorage,
44+
PublishableStatusChecker $publishableStatusChecker,
45+
private readonly array $personalisedResourceClasses = [],
46+
) {
47+
$this->publishableAttributeReader = $publishableStatusChecker->getAttributeReader();
48+
}
49+
50+
public function onPostRespond(ResponseEvent $event): void
51+
{
52+
$request = $event->getRequest();
53+
if (!$request->isMethodCacheable()) {
54+
return;
55+
}
56+
57+
$resourceClass = $request->attributes->get('_api_resource_class');
58+
if (!\is_string($resourceClass) || !$this->isPersonalisableResource($resourceClass)) {
59+
return;
60+
}
61+
62+
if (!$this->isAuthenticated()) {
63+
return;
64+
}
65+
66+
$response = $event->getResponse();
67+
// The body may carry draft or role-specific data tied to this authenticated session, so it
68+
// must never be stored by a shared cache. `private` overrides API Platform's default
69+
// `public`; `no-store` is the authoritative marker a service worker's cacheWillUpdate drops.
70+
$response->setPrivate();
71+
$response->headers->removeCacheControlDirective('s-maxage');
72+
$response->headers->addCacheControlDirective('no-store');
73+
}
74+
75+
private function isPersonalisableResource(string $resourceClass): bool
76+
{
77+
foreach ($this->personalisedResourceClasses as $affectedClass) {
78+
if (is_a($resourceClass, $affectedClass, true)) {
79+
return true;
80+
}
81+
}
82+
83+
// Any resource configured as Publishable varies by auth (draft vs published) even when it is
84+
// an app-defined component that cannot be enumerated in the configured list above.
85+
return $this->publishableAttributeReader->isConfigured($resourceClass);
86+
}
87+
88+
private function isAuthenticated(): bool
89+
{
90+
$token = $this->tokenStorage->getToken();
91+
92+
return null !== $token && $token->getUser() instanceof UserInterface;
93+
}
94+
}

src/Resources/config/services.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
use Silverback\ApiComponentsBundle\Event\ImagineStoreEvent;
6767
use Silverback\ApiComponentsBundle\Event\JWTRefreshedEvent;
6868
use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent;
69+
use Silverback\ApiComponentsBundle\EventListener\Api\CacheHeadersEventListener;
6970
use Silverback\ApiComponentsBundle\EventListener\Api\CollectionApiEventListener;
7071
use Silverback\ApiComponentsBundle\EventListener\Api\ComponentPositionEventListener;
7172
use Silverback\ApiComponentsBundle\EventListener\Api\ComponentUsageEventListener;
@@ -1712,6 +1713,19 @@
17121713
->tag('kernel.event_listener', ['event' => ViewEvent::class, 'priority' => EventPriorities::PRE_WRITE, 'method' => 'onPreWrite'])
17131714
->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'priority' => EventPriorities::POST_RESPOND, 'method' => 'onPostRespond']);
17141715

1716+
$services
1717+
->set('silverback.api_components.event_listener.api.cache_headers')
1718+
->class(CacheHeadersEventListener::class)
1719+
->args(
1720+
[
1721+
new Reference(TokenStorageInterface::class),
1722+
new Reference(PublishableStatusChecker::class),
1723+
[],
1724+
]
1725+
)
1726+
->tag('kernel.event_listener', ['event' => ResponseEvent::class, 'priority' => EventPriorities::POST_RESPOND, 'method' => 'onPostRespond']);
1727+
$services->alias(CacheHeadersEventListener::class, 'silverback.api_components.event_listener.api.cache_headers');
1728+
17151729
$services
17161730
->set('silverback.metadata_provider.page_data')
17171731
->class(PageDataMetadataProvider::class)

0 commit comments

Comments
 (0)