event_id is unique and the duplicate check ran before the signature
check, so an unsigned request claiming the next event's id was stored
under it and the real delivery was then dropped as a duplicate. Rejected
requests are now logged under a fresh id.
The id header was also never read over HTTP: Werkzeug presents it as
X-Webhook-Event-Id, which neither exact spelling matched, so every
request got a random id and deduplication never happened.
Two nets, deliberately complementary, because neither alone would have caught
the advisory:
- `test_route_authz_sweep.py` fires every mutating route as a viewer and
fails on any 2xx outside a reviewed `VIEWER_WRITABLE` list. It proves
*enforcement*, but a route that 400s or 404s on a dummy request before its
gate runs is invisible to it — `POST /workspaces/` 400s on an empty body,
so the net would never have seen the bug it exists for. Hence the targeted
Part A: the fixed routes must answer a viewer and a developer with 403, and
must not block an admin.
- `test_route_authz_static.py` proves *coverage* from the source instead: every
mutating route in `url_map` either carries a `_sk_authz` decorator, reaches
an authorization primitive through its module's call graph, or sits in
SELF_SCOPED / PUBLIC_ALT_AUTH / STATELESS with a written justification.
Staleness checks keep an allowlist slot from outliving the route that
earned it, and a SELF_SCOPED entry must still resolve the caller's identity
— calling `get_current_user()` is necessary but not sufficient, which is
exactly how `create_workspace` looked self-scoped while creating a global
row.
Both nets are blind below the API layer and to public routes, so
`test_authz_round3_guards.py` covers the three fixes whose shape they cannot
express (API-key scope widening, demo-deploy job creation, nginx diff
containment), the webhook cases land beside the rest of the gateway surface,
and the shared-resource tag gate lands with its own blueprint's tests. Each
comes with its non-vacuity case: the same call succeeds for a caller the gate
admits.
`migrations/history` gets tests because it had none — which is why the
NameError in it shipped.
Ratchets: JWT-only census 605 -> 577 (the routes that moved to policy
decorators), the controller-boundary baseline loses the one persistence
crossing `complete_onboarding` no longer makes, and the collected-test floor
rises to 4431.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proactive hardening from an internal audit of the same broken-authorization
class as GHSA-6w78-q5vm-rfmh (authentication checked, app/workspace-level
authorization skipped):
- deployment_jobs: job detail/logs/retry resolve the job and gate on
can_access_app / can_operate_app (app-less jobs: requester or admin);
global job listing is admin-only
- secrets_webhooks: every vault/secret/webhook-endpoint/delivery by-id
route resolves to its workspace and 404-seals non-members; secret
reveal and signing-secret regeneration require workspace owner/admin;
dead local admin_required placeholder removed
- shared_resources: variable-group CRUD and attach/detach gate on the
resolved scope (workspace role / can_edit_app / can_operate_app),
closing deploy-time env injection into foreign apps
- queue_bus: message receive/complete/fail/list on system-owned groups
are admin-only; user-owned groups restricted to their owner
- docker: raw container inspect (exposes env vars) and container logs
are admin-only, matching Decision 7; per-app logs stay app-gated
- databases: raw SQL query endpoints, host-level metadata reads, and
process lists are admin-only; per-app managed database flows unchanged
Adds 4 persona-matrix regression suites (74 tests) and bumps the
test-count ratchet.