Skip to content

feat(user-access-policy): arbitrary-header match dimension for access rules - #2795

Open
HughParry wants to merge 6 commits into
mainfrom
feat/header-restriction-access-rules
Open

HughParry wants to merge 6 commits into
mainfrom
feat/header-restriction-access-rules

Conversation

@HughParry

@HughParry HughParry commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Paired with the portal PR in captcha-private (#3738). Merge that one after this.

What

Adds an arbitrary-header match dimension to the user-access-policy engine, so a Block/Restrict rule can target a named request header. Companion to the os dimension (#2786) but for headers, with substring contains and allow-list support.

Design

Header matching can't be a Redis TAG query — substring contains and per-rule operators aren't expressible, and an allow-list rule must fire even when the request omits the header. So:

  • New rule fields: headerName, headerValue, headerOperator (equals/contains/notEquals/notContains), plus a headerMatch sentinel.
  • headerMatch (always "1" on every request scope — see getRequestUserScope, and on every header rule) is the one indexed field. It makes every header rule a matching candidate for every request; Redis narrows nothing else about it.
  • The concrete condition is evaluated in code (accessRuleHeaderMatches) against the raw request headers, threaded into ruleApplies via getPrioritisedAccessRule / getPrioritisedAccessPolicies. Checked at both the request-time block middleware and the verify-path hard-block.
  • Allow-list desugars to the negated operators (notEquals/notContains = "block unless the header matches", including when it's absent) — the same trick the OS allow-list uses to block the complement. The portal owns that desugaring.
  • A header rule counts as one specificity point (headerMatch only), mirroring the other scalar dimensions.

Interaction with the verdict cache (from the main merge)

HardBlockVerdictCache landed on main after this branch was opened. Its key, hardBlockCacheKey(clientId, userScope, blockOnly), is built from the user scope alone — it carries no notion of an arbitrary request header.

Caching the ranked list under that key would let one request's header-rule verdict be served to a different request that shares a user scope but sends different headers, for the length of the cache TTL. So the merge moves the cache boundary: the cache and the per-request memo now store the candidate list from findRules (the Redis round-trip is what the cache exists to absorb), and rankCandidateRules — which is where accessRuleHeaderMatches runs — is called per request, outside the cache.

Net effect: same number of storage calls, ranking is no longer cached. Cache size() semantics are unchanged (still one entry per key), so the storm integration tests are unaffected.

Unrelated commit riding along: the unused undici dependency

748cf900f drops undici from dev/prosoponator-bot. It is declared there but never imported anywhere in the package, so lint:refs reports it as an unnecessary dependency.

It is not visible on captcha CI, only downstream: captcha-private pins a captcha commit predating the dependency being added, so its lint:refs never saw it, and any pin bump past that point fails the check — which is what blocked #3738. Removed rather than added to the downstream lint's ignore list, since it is genuinely unused. Deliberately placed before the main merge in this branch's history so #3738 can pin it without also taking the v3.7.6 package-version bumps (see that PR for why).

Notes / trade-offs

  • redisRulesSplitQuery.ts enumerates its scope fields in a hardcoded list that predates both os and headerMatch. Header rules are still fetched on the hot path, but via the no-user-scope fall-through probe rather than a dedicated one, so they share that probe's SPLIT_MAX_CANDIDATES_PER_SUB (500) budget with os rules and genuine client-wide rules. Worth deriving that list from userScopeSchema the way redisRulesQuery.ts does — happy to do it here or as a follow-up.
  • Like os/countryCode, header rules are low-specificity, so in an account with many higher-specificity rules they can be crowded out of a candidate cap. For a deny-list that loses a block; for an allow-list it means the gate silently stops enforcing, which is the more consequential direction.
  • requestHeaders currently defaults to {} on rankCandidateRules / getPrioritisedAccessRule / getPrioritisedAccessPolicies. A caller that omits it gets "no header rule matches" with no error. Both real call paths pass it; consider making it required so a future one can't skip it.
  • headerName/headerValue/headerOperator are indexed only to satisfy the schema exhaustiveness check; their index entries are never queried.
  • Deploy ordering: the index gains four fields and the schema-derived greedy query starts emitting ismissing(@headerMatch). os shipped the same way in feat(user-access-policy): OS match dimension for access rules #2786, so the rebuild path handles it — just land this before anything writes header rules.

Tests

  • headerMatch.unit.test.ts — operator semantics (incl. absent-header and negation cases).
  • blacklistRequestInspector.unit.test.ts — rankCandidateRules with deny/allow header rules + sentinel gating.
  • Updated redisRulesQuery / transformRule snapshots for the new fields.

CI note: tests / lint / typecheck / cypress are not reporting

Those four workflow runs come back conclusion: action_required with zero jobs, so the suites never execute. This is not specific to this PR — it started repo-wide this morning and also hit ci/mobile-demo-ci:

workflow run branch result
08:11 fix/error-label-selectable success
08:40 ci/stale-pr-to-draft success
09:19 ci/mobile-demo-ci action_required
09:41 / 09:52 this branch action_required

POST /actions/runs/{id}/approve is rejected ("not from a fork pull request or queued by the Actions bot"), a manual workflow_dispatch comes back action_required just as fast, actions/permissions shows enabled: true / allowed_actions: all, and there are no pending deployments. So it needs someone with org/repo admin to look at whatever changed between 08:40 and 09:19 — the reporting check contexts that do pass come from changesets.yml, not from the real suites.

Because of that, this branch's suites were run locally against the merged tree instead:

  • @prosopo/user-access-policy — 103/103 unit, 46/46 integration (incl. the retry-storm benchmark).
  • @prosopo/provider — 1024/1024 unit across 72 files, 18/18 integration (incl. blacklistRequestInspector.storm).
  • npx turbo run typecheck across the captcha workspace — clean (24/24 tasks).
  • biome check . — clean. lint:refs — exit 0.

@HughParry
HughParry force-pushed the feat/header-restriction-access-rules branch from 1556a61 to eb503d6 Compare July 2, 2026 10:33
HughParry and others added 2 commits July 2, 2026 11:46
… rules

Add header-based matching to the access-policy engine: a Block/Restrict
rule can target a named request header with equals/contains/notEquals/
notContains (the negated operators back an allow-list — block unless the
header matches). Substring contains and per-rule operators can't be a
Redis TAG query, and an allow-list rule must fire even when the request
omits the header, so the condition (headerName/headerValue/headerOperator)
is evaluated in code against the raw request headers; an indexed
headerMatch sentinel makes every header rule a matching candidate. Checked
at both the request-time block middleware and the verify-path hard block,
and worth one point of specificity (mirroring the other scalar dimensions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HughParry
HughParry force-pushed the feat/header-restriction-access-rules branch from eb503d6 to 8b71e74 Compare July 2, 2026 10:46
Conflicts resolved against the access-rule verdict caching that landed on
main (HardBlockVerdictCache + per-request memo):

- getPrioritisedAccessRule now takes main's GetPrioritisedAccessRuleOptions
  (blockOnly / requestMemoHost / skipCache) plus this branch's trailing
  requestHeaders argument.
- The cache/memo now stores the *candidate* list from findRules rather than
  the ranked result. hardBlockCacheKey is derived from the user scope alone,
  which carries no notion of an arbitrary request header, so caching a ranked
  list would serve one request's header-rule verdict to another request with
  the same scope but different headers. Ranking (which is where
  accessRuleHeaderMatches runs) is now done per call, outside the cache.
- blockMiddleware passes both requestMemoHost and the normalised headers.
undici is declared in dev/prosoponator-bot but never imported anywhere
in the package, so `lint:refs` reports it as an unnecessary dependency.
It only surfaces downstream: captcha-private pins a captcha commit that
predates the dependency being added, so its lint:refs never saw it, and
any pin bump past that point fails the check.

Removing the declaration rather than ignoring it in the downstream lint
command, since the dependency is genuinely unused.
…on-access-rules

# Conflicts:
#	dev/prosoponator-bot/package.json
#	package-lock.json
@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-12T10:07:52Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2795, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-13T18:15:40Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2795, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-19T18:03:38Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2795, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-25T06:05:31Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2795, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

This branch has not been deployed

No deployments
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