Skip to content

fix(admin): offer purge controls to admins who may only purge - #257

Merged
derrickmehaffy merged 3 commits into
mainfrom
fix/purge-only-permission
Oct 13, 2026
Merged

derrickmehaffy merged 3 commits into
mainfrom
fix/purge-only-permission

Conversation

@derrickmehaffy

@derrickmehaffy derrickmehaffy commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What changed and why

The failure

An admin granted Purge cache (plugin::rest-cache.cache.purge) but not Read cache strategy configuration (cache.read-strategy) was never offered a purge control.

Every purge control in the content manager (the list-view Purge REST Cache button and the edit-view document action) decided whether to render by reading GET /rest-cache/config/strategy and checking that the content type is in it. That route requires cache.read-strategy. For this admin it answers 403, data stays undefined, and every control returns null. The server would have accepted the purge: POST /rest-cache/purge only needs cache.purge. So the role worked over the API and did nothing in the panel.

The fix

Without relaxing the strategy route, which carries the whole resolved strategy (hitpass, keys, routes) and is more than someone allowed to purge needs to see:

  • Server: new GET /rest-cache/purge/content-types, gated by admin::isAuthenticatedAdmin + cache.purge (the same policy as POST /purge). Returns { "contentTypes": ["api::article.article", ...] } and nothing else. It reveals nothing cache.purge did not already: POST /purge answers 400 contentType is not cached for any uid outside the strategy, so the list was already discoverable one uid at a time. The response type PurgeableContentTypesResponse is documented as "deliberately only the uids" so it does not grow into a less-guarded copy of StrategyResponse.
  • Admin: usePurgeableContentTypes() in services/restCache.ts returns the uids from the strategy when it loads, and only after the strategy request comes back 403 asks the new endpoint. useEndpoint gained an enabled flag so the fallback hook can be called unconditionally but not fetch. PurgeCacheButton and useCachedDocument().isEligible read from it.
  • The edit view's cache panel still requires the strategy. Its whole content is the configured max age, which a purge-only admin may not read, so it stays hidden for them (if (!isEligible || !config) return null). The Playwright spec asserts it is absent.
  • Built on useFetchClient only. No adminApi, useRBAC or Page.Protect. The permission is derived from the 403, per AGENTS.md.
  • The 403 check is structural and reads error.status as well as error.response.status. The host's FetchError sets the code on status, and its response is { data } with no status.

Why this is not breaking

  • GET /rest-cache/config/strategy, /stats, /config/provider and POST /purge: unchanged routes, policies and responses. No permission is widened. The jest spec asserts the strategy route still answers 403 to the purge-only admin, and the Playwright spec asserts the panel saw that 403.
  • An admin who can read the strategy makes exactly the same requests as before. The fallback is never fetched for them. The Playwright spec asserts this by recording their requests. They see exactly the same controls, since isEligible is computed from the same uid list the strategy gives.
  • An admin with neither permission gets a 403 from both endpoints and sees no control, as before.
  • An admin with cache.read-strategy but not cache.purge is unchanged too: still shown the control, still refused by the server with a 403 notification.
  • Nothing about caching, keys, Cache-Control or bypass is touched.
  • Additive only: one new route, one new exported type, one new optional hook parameter.

Tests

shared/tests/admin-purge-permission.test.js (jest). Creates two admins through the admin API, one holding only cache.purge and one holding no plugin permission, and asserts:

  • the route is mounted and requires auth (401, not 404);
  • 403 without cache.purge;
  • 200 with cache.purge, the body has exactly one key contentTypes, and it equals cacheConfig.getUids();
  • GET /rest-cache/config/strategy is still 403 for the purge-only admin.

e2e/tests/purge-permission.spec.ts (Playwright). Creates a role with content-manager.explorer.read on articles + cache.purge, registers a user, logs in as them, and asserts:

  • the list view's strategy request got a 403 (the fix was not made by relaxing it), the Purge REST Cache button is visible, and a purge succeeds;
  • the edit view's More document actions menu offers Purge REST Cache, and the cache panel is absent (its only content is the max age, which this admin may not read).

A second describe block logs in as the super admin (who holds cache.read-strategy), records every request across the list and edit views, and asserts /rest-cache/purge/content-types is never requested. It first checks that the strategy request was recorded, so it cannot pass on an empty log.

Before (unfixed main), jest:

    ✕ is mounted and requires authentication (8 ms)
    ✕ is forbidden without cache.purge (4 ms)
    ✕ returns only the cached content type uids with cache.purge (4 ms)
    ✓ leaves the strategy route behind cache.read-strategy (5 ms)
    Expected: 401
    Received: 404
    Expected: 403
    Received: 404
    Expected: 200
    Received: 404
Tests:       3 failed, 1 passed, 4 total

Before (unfixed main), Playwright:

  ✘  1 … › purge permission without strategy access › is offered the list-view purge, and it works (13.3s)
  ✘  2 … › purge permission without strategy access › is offered the edit-view purge action (8.8s)
  1) … is offered the list-view purge, and it works
    Error: expect(locator).toBeVisible() failed
    Locator: getByRole('button', { name: 'Purge REST Cache' }).first()
    Expected: visible
    Timeout: 10000ms
    Error: element(s) not found
  2) … is offered the edit-view purge action
    Error: expect(locator).toBeVisible() failed
    Locator: getByRole('menuitem', { name: 'Purge REST Cache' })
    Expected: visible
    Error: element(s) not found
  2 failed

Test 1 got past expect((await strategy).status()).toBe(403) before failing, so the 403 was there on main and is still there after the fix.

Each new assertion, seen red on purpose:

With the panel's !config guard removed (if (!isEligible) return null), the purge-only admin's edit view rendered "Responses for this entry are cached for up to - after they are first requested.":

  1) … › purge permission without strategy access › is offered the edit-view purge action
    Error: expect(locator).toHaveCount(expected) failed
    Locator:  getByText(/cached for up to/i)
    Expected: 0
    Received: 1
  1 failed
  2 passed

With the fallback hook's enabled forced to true:

  ✘  3 … › purge permission with strategy access › never asks for the purge-only content type list (4.6s)
    Error: expect(received).toEqual(expected) // deep equality
    - Array []
    + Array [
    +   "/rest-cache/purge/content-types",
    +   "/rest-cache/purge/content-types",
    + ]
  1 failed
  2 passed

After:

    ✓ is mounted and requires authentication (10 ms)
    ✓ is forbidden without cache.purge (17 ms)
    ✓ returns only the cached content type uids with cache.purge (17 ms)
    ✓ leaves the strategy route behind cache.read-strategy (14 ms)
Tests:       4 passed, 4 total

  ✓  1 … › purge permission without strategy access › is offered the list-view purge, and it works (3.7s)
  ✓  2 … › purge permission without strategy access › is offered the edit-view purge action (4.4s)
  ✓  3 … › purge permission with strategy access › never asks for the purge-only content type list (9.4s)
  3 passed (1.5m)

Closes #

Checklist

  • Tests added or updated. For a bug fix: a spec in shared/tests/ that fails before the change. Browser-visible behaviour goes in e2e/tests/.
  • pnpm run test:e2e:memory passes. (24 suites, 139 tests)
  • pnpm run test:lint passes (tsc --noEmit over server/ and admin/, plus eslint and prettier on the providers).
  • Touches admin/? pnpm run test:admin was run and passes. (16 passed)
  • Touches a provider or the published package shape? Not applicable. No provider change; one additive route.
  • Any duration I touched is milliseconds. No duration touched.
  • User-visible change? docs/guide/ updated in this PR: the route is in reference/routes.md, and admin/index.md says what a purge-only admin sees.
  • Conventional commit subjects, branched from main.

Notes for the reviewer

  • The new path is /purge/content-types, under the purge routes, because it belongs to the purge permission rather than to config/*.
  • I did not add a "since" badge to the routes table because I don't know the next version number.
  • This PR overlaps textually with the purge-controls PR (fix(admin): purge the open entry from the edit view, and let the purge button be used twice #253) in useCachedDocument.ts and PurgeCacheButton/index.tsx, in different hunks. git merge-tree reports a clean merge.
  • Out of scope, but noticed: SettingsPage's own isForbidden reads only error.response.status, which the host's FetchError does not set, so a 403 there would render the generic error page instead of Page.NoPermissions. The settings link is permission-gated by the admin, so this is hard to reach. I left it alone.

Every purge control decided whether to render from GET
/rest-cache/config/strategy, which requires cache.read-strategy. An admin
granted cache.purge without it got a 403 there, so the list-view button and
the edit-view action both hid themselves, and the only thing the permission
is for was nowhere in the panel. The server would have accepted the purge.

Relaxing the strategy route would have handed the whole resolved strategy -
hitpass, keys, routes - to anyone allowed to purge. Instead, add
GET /rest-cache/purge/content-types, gated by cache.purge, returning only
the cached uids. That reveals nothing cache.purge did not already: POST
/purge answers 400 for any uid that is not cached.

The admin asks it only after the strategy request comes back 403, so an
admin who can read the strategy makes exactly the requests they made
before and sees exactly the same controls. An admin with neither
permission gets a 403 from both and sees nothing, as before. The edit
view's cache panel still needs the strategy - its content is the max age -
so it stays hidden for a purge-only admin.

The 403 check reads `status` as well as `response.status`: the host's
FetchError puts the code on the former.
Two claims in this change had nothing holding them.

The edit-view panel's `!config` guard: without it a purge-only admin is
eligible, has no strategy, and the panel reads "cached for up to - after
they are first requested". The edit-view spec now asserts the panel is
absent once the purge action has proved the admin eligible. Seen red with
the guard removed: the sentence above rendered and the count was 1.

"An admin who can read the strategy makes exactly the same requests as
before": a new spec records every request the super admin makes across the
list and edit views and asserts /rest-cache/purge/content-types is never
among them, after checking the strategy request was recorded so it cannot
pass on an empty log. Seen red with the fallback's `enabled` forced to
true: the endpoint was requested twice.
@derrickmehaffy
derrickmehaffy merged commit de176e6 into main Oct 13, 2026
13 checks passed
@derrickmehaffy
derrickmehaffy deleted the fix/purge-only-permission branch October 13, 2026 05:51
@github-actions github-actions Bot mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant