Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,3 +694,18 @@ References: `src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`, `src/
</details>

> **⚠ 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`.
43 changes: 43 additions & 0 deletions features/main/cache_headers.feature
Original file line number Diff line number Diff line change
@@ -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 <setup>
When <request>
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 <setup>
When <request>
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"
26 changes: 26 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/DependencyInjection/SilverbackApiComponentsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
94 changes: 94 additions & 0 deletions src/EventListener/Api/CacheHeadersEventListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* 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 <daniel@silverback.is>
*/
final class CacheHeadersEventListener
{
private readonly PublishableAttributeReader $publishableAttributeReader;

/**
* @param array<class-string> $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;
}
}
14 changes: 14 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading