Security: tenant webhook event_categories boundary + match-ALL update guard (v0.1.25.50)#208
Conversation
…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).
Codex security round 2 — applied in bdfcfddCore 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 completedThe original recipe was unreliable ( 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'
2. Tests added (all green)
Build: 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. |
|
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. |
|
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. |
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.
Security fix (v0.1.25.50): tenant-plane webhook
event_categorieswere never validated, letting a tenant key subscribe to admin-only event classes.The gap
WebhookTenantController.validateTenantEventTypesenforces the tenant-accessible boundary (budget.*/reservation.*/tenant.*) onevent_typesfor both create and update — butevent_categorieswas never validated on that plane, andWebhookRepository.matchesEventTypetreats categories as an additive union with types:→ 201, and the subscription receives every
api_key.*event for the tenant (likewisepolicy,webhook,system). The same smuggle worked viaPATCH /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_categoriesagainst the same tenant-accessible boundary, 400INVALID_REQUEST.The fix
validateTenantEventCategoriesalongside the existingevent_typescheck, on both create and update; same 400 + message style. The boundary is derived from a newEventCategory.isTenantAccessible()(BUDGET/RESERVATION/TENANT), andEventType.isTenantAccessible()now delegates to it — one source of truth, the two checks cannot drift.validateTenantEventTypesalready 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).@NotEmpty event_types, butWebhookService.updateapplied"event_types": []verbatim; with no categories the result hitmatchesEventType'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.:
Admin-provisioned subscriptions legitimately carry admin categories — check provenance via
createTenantWebhookaudit-log entries (tenant-plane creations) vscreateWebhookSubscription(admin plane).Tests
event_typeswith no categories → 400; clearing both in one PATCH → 400; category-only survivor → allowed./v1/admin/webhooksendpoints unaffected (no new validation there; existing suite green).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.