Skip to content

Security: tenant webhook event_categories boundary + match-ALL update guard (v0.1.25.50)#208

Merged
amavashev merged 2 commits into
mainfrom
fix/tenant-webhook-category-boundary
Jul 11, 2026
Merged

Security: tenant webhook event_categories boundary + match-ALL update guard (v0.1.25.50)#208
amavashev merged 2 commits into
mainfrom
fix/tenant-webhook-category-boundary

Conversation

@amavashev

Copy link
Copy Markdown
Collaborator

Security fix (v0.1.25.50): tenant-plane webhook event_categories were never validated, letting a tenant key subscribe to admin-only event classes.

The gap

WebhookTenantController.validateTenantEventTypes enforces the tenant-accessible boundary (budget.*/reservation.*/tenant.*) on event_types for both create and update — but event_categories was never validated on that plane, and WebhookRepository.matchesEventType treats categories as an additive union with types:

POST /v1/webhooks  (tenant API key)
{"url": "...", "event_types": ["budget.created"], "event_categories": ["api_key"]}

→ 201, and the subscription receives every api_key.* event for the tenant (likewise policy, webhook, system). The same smuggle worked via PATCH /v1/webhooks/{id}.

Spec citation: governance spec revision v0.1.25.38 (pending, prepared in parallel) adds the normative rule — tenant-plane create AND update MUST validate event_categories against the same tenant-accessible boundary, 400 INVALID_REQUEST.

The fix

  • validateTenantEventCategories alongside the existing event_types check, on both create and update; same 400 + message style. The boundary is derived from a new EventCategory.isTenantAccessible() (BUDGET/RESERVATION/TENANT), and EventType.isTenantAccessible() now delegates to it — one source of truth, the two checks cannot drift.
  • Admin-on-behalf-of decision: the check runs unconditionally on the tenant-plane endpoints, admin callers included — exactly how validateTenantEventTypes already behaves on the dual-auth PATCH path (create is tenant-key-only by design, spec v0.1.25.14). The boundary belongs to the endpoint, not the caller: admin categories are minted/managed on /v1/admin/webhooks/*; letting admin PATCH them onto a tenant-plane subscription would recreate the smuggle (the tenant retains the endpoint URL + signing secret).
  • Empty-both edge — verified reachable, guarded: create requires @NotEmpty event_types, but WebhookService.update applied "event_types": [] verbatim; with no categories the result hit matchesEventType's empty-both branch = match-ALL (every event class, admin-only included). Update now returns 400 on both planes when a PATCH would leave both event fields empty — a match-all subscription is not creatable, so it must not be reachable by update. Category-only subscriptions remain legal.

Operator note — audit existing subscriptions

On 0.1.25.49 and earlier, tenant-created subscriptions may already carry admin-only categories. Audit tenant-plane subscriptions and strip/delete offenders, e.g.:

redis-cli --scan --pattern 'webhook:*' | xargs -L1 redis-cli GET | jq -r \
  'select(.event_categories != null)
   | select(.event_categories - ["budget","reservation","tenant"] | length > 0)
   | .subscription_id + " " + .tenant_id + " " + (.event_categories|tostring)'

Admin-provisioned subscriptions legitimately carry admin categories — check provenance via createTenantWebhook audit-log entries (tenant-plane creations) vs createWebhookSubscription (admin plane).

Tests

  • Controller: smuggle on create → 400 (service never invoked); smuggle on update → 400; admin-on-behalf-of PATCH with an admin category → 400 (endpoint-bound boundary pinned); tenant-accessible categories pass on create (all three) and update.
  • Service: clearing event_types with no categories → 400; clearing both in one PATCH → 400; category-only survivor → allowed.
  • Pure admin-plane /v1/admin/webhooks endpoints unaffected (no new validation there; existing suite green).
  • Full mvn -B verify: 1,561 tests (190 model + 561 data + 810 api), 0 failures, coverage gates met.

Version 0.1.25.49 → 0.1.25.50 (pom <revision>, per the v0.1.25.48 feature-PR precedent); CHANGELOG (Security + Fixed sections with the operator recipe) and AUDIT.md updated.

…pdate guard (v0.1.25.50)

Confirmed authorization gap on the tenant webhook plane.
WebhookTenantController.validateTenantEventTypes enforces the
tenant-accessible boundary (budget.*/reservation.*/tenant.*) on
event_types for create and update, but event_categories was never
validated there - and WebhookRepository.matchesEventType treats
categories as an ADDITIVE union with types. A tenant API key could
create (or PATCH) a subscription with one allowed event_type plus
"event_categories": ["api_key"] (or policy / webhook / system) and
receive admin-only event classes for its tenant: API-key lifecycle,
policy changes, webhook lifecycle, system events.

Governance spec revision v0.1.25.38 (pending, prepared in parallel)
adds the normative rule: tenant-plane create AND update MUST validate
event_categories against the same boundary, 400 INVALID_REQUEST.

Fix:

- New validateTenantEventCategories alongside the event_types check,
  applied on BOTH create and update. Boundary derived from a new
  EventCategory.isTenantAccessible() (BUDGET/RESERVATION/TENANT);
  EventType.isTenantAccessible() now DELEGATES to it - single source
  of truth, the type- and category-level checks cannot drift.
- Admin-on-behalf-of decision: the check runs unconditionally on the
  tenant-plane endpoints, admin callers included - exactly how
  validateTenantEventTypes already behaves on the dual-auth PATCH path
  (create is tenant-key-only by design, spec v0.1.25.14). The boundary
  belongs to the ENDPOINT, not the caller: admin categories are minted
  and managed on /v1/admin/webhooks/*; letting admin PATCH them onto a
  tenant-plane subscription would recreate the smuggle (the tenant
  retains the endpoint URL + signing secret).
- Empty-both edge (verified REACHABLE, guarded): create requires
  @notempty event_types, but WebhookService.update applied
  "event_types": [] verbatim; with no categories the result hit
  matchesEventType's empty-both branch = match-ALL (every event class,
  admin-only included). Update now 400s on BOTH planes when a PATCH
  would leave both event fields empty ("Subscription must retain at
  least one event_type or event_category") - a match-all subscription
  is not creatable, so it must not be reachable by update. Category-
  only subscriptions (clear types, keep categories) remain legal.

Operators (also in CHANGELOG security note): on 0.1.25.49 and earlier,
tenant-created subscriptions may already carry admin-only categories -
audit tenant-plane subscriptions and strip/delete offenders; recipe in
the CHANGELOG.

Tests: smuggle on create -> 400 (service never invoked); smuggle on
update -> 400; admin-on-behalf-of PATCH with admin category -> 400
(endpoint-bound boundary pinned); tenant-accessible categories pass on
create and update; service-level empty-both guard (clear types with no
categories -> 400, clear both in one PATCH -> 400, category-only
survivor -> allowed); pure admin-plane /v1/admin/webhooks endpoints
unaffected (existing suite green, no new validation there).

Build: mvn -B verify green - 1,561 tests (190 model + 561 data +
810 api), coverage gates met. Version/revision 0.1.25.49 -> 0.1.25.50;
CHANGELOG.md + AUDIT.md updated.
Codex security review of PR #208 confirmed the core fix sound (no
bypass routes, boundary derivation correct, unknown/mixed-case enum
values 400 at Jackson binding, delivery-time enforcement correctly
rejected). REVISE-MINOR - the operator recipe (a security-release
artifact) and small test gaps.

1. CHANGELOG operator audit recipe rewritten - the original was
   unreliable and incomplete:
   - `redis-cli --scan --pattern 'webhook:*'` also matched encrypted
     `webhook:secret:*` values, so the jq pipeline choked on non-JSON.
     Now scans only subscription keys `webhook:whsub_*` (verified the
     prefix in WebhookRepository.save: subscription = "webhook:" + id,
     id = "whsub_" + hex; secrets = "webhook:secret:" + id; indexes =
     "webhooks:*" plural - none match the narrowed glob) AND tolerates
     any stray non-JSON value with `-R 'fromjson? // empty'`.
   - It audited admin-only categories but NOT legacy empty-both
     match-all rows (deliverable via matchesEventType's empty-both
     branch until repaired). Single pass now flags both:
     ADMIN_CATEGORIES (event_categories minus the tenant set) and
     MATCH_ALL (both event fields empty/absent). Logic re-verified
     against the stored JSON shape (event_categories NON_NULL: absent
     when null, [] when empty) before writing.

2. Tests added (all green):
   - WebhookAdminControllerTest: admin-plane create WITH admin-only
     event_categories -> 201; admin-plane update -> 200. Proves the
     boundary is tenant-plane-only, not a global tightening.
   - EventModelTest: EventCategory.isTenantAccessible() exhaustive over
     all enum values; every EventType delegates to its category's
     accessibility. Future-drift guard for new types/categories.
   - WebhookServiceTest: clearing event_categories to [] with
     event_types omitted -> event_types survives, update proceeds (the
     realistic repair path for a smuggled ADMIN_CATEGORIES row).

Build: mvn -B verify green - 1,566 tests (192 model + 561 data +
813 api), coverage gates met. AUDIT.md updated (recipe correction +
recorded out-of-scope decision on PATCH-of-legacy-admin-category rows,
see PR comment).
@amavashev

Copy link
Copy Markdown
Collaborator Author

Codex security round 2 — applied in bdfcfdd

Core fix confirmed sound (no bypass routes, boundary derivation correct, unknown/mixed-case enum values 400 at Jackson binding, delivery-time enforcement correctly rejected). Two REVISE-MINOR items applied + one recorded decision.

1. Operator audit recipe — corrected and completed

The original recipe was unreliable (webhook:* also matched encrypted webhook:secret:* values → jq choked on non-JSON) and incomplete (audited admin-only categories but not the legacy empty-both match-ALL rows). Verified the key layout in WebhookRepository.save — subscription = webhook:<whsub_…>, secret = webhook:secret:<id>, indexes = webhooks:* (plural) — so webhook:whsub_* cleanly selects only subscription keys. Re-verified the predicate against the stored JSON shape (event_categories is NON_NULL: absent when null, [] when empty; event_types always serialized). Corrected recipe (verbatim, copy-pasteable, flags both classes):

redis-cli --scan --pattern 'webhook:whsub_*' \
  | while read -r k; do redis-cli GET "$k"; done \
  | jq -rR 'fromjson? // empty
      | (.event_categories // []) as $cats
      | (.event_types // []) as $types
      | ($cats - ["budget","reservation","tenant"]) as $admin
      | if ($admin | length) > 0 then
          "ADMIN_CATEGORIES \(.subscription_id) \(.tenant_id) \($admin)"
        elif ($types | length) == 0 and ($cats | length) == 0 then
          "MATCH_ALL \(.subscription_id) \(.tenant_id)"
        else empty end'

ADMIN_CATEGORIES = admin-only categories; MATCH_ALL = empty-both wildcard. Logic validated against all four JSON shapes + a non-JSON line: only the two offender classes are flagged, the valid row and the garbage line are silently dropped.

2. Tests added (all green)

  • Admin-plane boundary scope (WebhookAdminControllerTest): create WITH admin-only event_categories → 201; update → 200. Proves the boundary is tenant-plane-only, not a global tightening.
  • Exhaustive enum coverage (EventModelTest): EventCategory.isTenantAccessible() over every enum value; every EventType delegates to its category's accessibility. Guards future drift when a type/category is added.
  • Repair path (WebhookServiceTest): clearing event_categories to [] with event_types omitted → event_types survives, update proceeds (the realistic fix for a smuggled ADMIN_CATEGORIES row).

Build: mvn -B verify green — 1,566 tests (192 model + 561 data + 813 api), coverage gates met.

3. Recorded decision (no code change)

Codex's open question: a tenant-plane PATCH on a legacy/admin-created row that already holds admin-only categories validates only the provided arrays, so a tenant could retain pre-existing admin categories by PATCHing an unrelated field. This is intended and matches the spec (validate the request, not the stored state). A tenant taking over an admin-created tenant-scoped subscription is a separate provenance/ownership threat model, out of scope for this patch — I did not expand scope. If you consider the takeover risk worth tracking, I'd suggest a separate issue (e.g. "tenant-plane mutations on admin-provisioned subscriptions") rather than folding it in here; happy to file if you want it.

@amavashev

Copy link
Copy Markdown
Collaborator Author

Filed the provenance/ownership follow-up as #209 (tenant takeover of admin-created tenant-scoped subscriptions) — the residual threat model codex flagged, deliberately out of scope for this fix. This PR closes the injection path (create/update category validation) and the empty-both match-ALL door, and ships the operator audit recipe for pre-existing offenders.

@amavashev

Copy link
Copy Markdown
Collaborator Author

External review (codex) security pass: SHIP after round 2. Round 1 confirmed the core fix clean — no bypass routes (bulk endpoints only status/delete, replay/test don't mutate filters), boundary derivation exact (EventCategory.isTenantAccessible = budget/reservation/tenant, every EventType delegates), unknown/mixed-case enum values 400 at Jackson binding, delivery-time enforcement correctly rejected (would break legit admin-created rows). Round 2 fixed the operator audit recipe (webhook:whsub_* only, jq fromjson? tolerance, flags both ADMIN_CATEGORIES and MATCH_ALL rows) and added the 5 boundary/drift/repair tests. Residual provenance risk tracked as #209. 1,566 tests green, coverage gates met.

@amavashev amavashev merged commit 10f22c3 into main Jul 11, 2026
8 checks passed
@amavashev amavashev deleted the fix/tenant-webhook-category-boundary branch July 11, 2026 00:03
amavashev added a commit that referenced this pull request Jul 11, 2026
Release prep for v0.1.25.50: prod + full-stack compose self-pin bumped 0.1.25.49 → 0.1.25.50. The pom <revision> (0.1.25.50) and CHANGELOG [0.1.25.50] entry already landed in the security fix (PR #208); the bundled cycles-server pin is already 0.1.25.47 and cycles-server-events 0.1.25.22. Compose files parse clean.
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