test(authz): extract decide_backend_auth + CI ordering test (#142)#170
test(authz): extract decide_backend_auth + CI ordering test (#142)#170alukach wants to merge 1 commit into
Conversation
Close the one real gap in #142: there was no CI-runnable proof of the confused-deputy ordering invariant ("an unauthorized caller never reaches federation"). The only guard was tests/test_federation.py, which is env-gated and skips in CI without a live deployed proxy. Consolidate resolve_product's scattered write-gate (anon/read-only/non-signable/ write-permission) plus the apply_backend_auth call into one pure, wasm-free function, authz::decide_backend_auth, and route production through it. The lib is cdylib with `[lib] test = false`, so this is the seam that lets the invariant be unit-tested natively in CI via tests/authz.rs (mirroring tests/backend_auth.rs). The new tests assert that any denial returns AccessDenied AND leaves the option map empty (no oidc_role_arn / skip_signature leaked), and that authorized read/write paths populate it. Verified non-vacuous: breaking the None guard makes unauthorized_outcome_never_federates fail. Behavior change: the gate is now one source of truth, so a write to a connection that can't accept it (read-only / non-signable) now performs the permissions API call before denying, rather than short-circuiting before it. Same 403 result, one extra cached API call on that deny path. (And build_backend_options now runs before the write gate, so a write to a connection with an unknown provider surfaces 500 instead of 403 — same as reads to such a broken connection already did.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @alukach's task in 1m 28s —— View job ✅ No blocking issues — safe to merge.
|
|
🚀 Latest commit deployed to https://source-data-proxy-pr-170.source-coop.workers.dev
|
Closes #142.
What
The federation wiring for #142 already landed on
main. The one remaining gap was a CI-runnable proof of the security-critical ordering invariant: an unauthorized caller must never reach federation /apply_backend_auth(the confused-deputy guard). The only existing guard,tests/test_federation.py::test_restricted_product_denied_to_anonymous, is env-gated and skips in CI without a live deployed proxy, so CI never exercised the invariant.This PR closes that gap by extracting the authorization → federation decision out of
resolve_productinto a pure, wasm-free function and unit-testing it in CI.How
authz::decide_backend_auth— a pure function that takes the authorization outcome as plain values, gates the request, and only on success callsapply_backend_auth. The confused-deputy guard is the first line:authentication: Option<&BackendAuth>isNonewhen the upstream subject-scoped fetch denied the caller ⇒Err(AccessDenied)with no federation.resolve_product(anon denied / read-only / non-signable /write-permission) plus the trailingapply_backend_authcall are now one tested function.resolve_productkeeps the I/O (subject-scoped Source API fetches) and routes its result through the pure fn. Net: registry logic shrinks, the gate has a single source of truth.tests/authz.rs(the crate iscdylibwith[lib] test = false; native tests only compile wasm-free modules —authzis one, and already had a test binary). The module is pulled in via#[path], mirroringtests/backend_auth.rs;backend_authis included alongside it so thecrate::backend_authpath resolves the same way it does in the lib build.New tests (
tests/authz.rs):unauthorized_outcome_never_federates—Noneauthentication (read and write) ⇒AccessDeniedandoptionsstays empty (nooidc_role_arn/skip_signature).authorized_read_unsigned_populates_options,authorized_read_federated_populates_options— authorized reads populate options.write_by_anonymous_denied,write_to_read_only_denied,write_to_non_signable_denied,write_without_write_permission_denied— each write denial ⇒Errand emptyoptions.authorized_write_populates_options— authorized write (case-insensitivewritepermission) populates options.Not test theater: every denial asserts
optionsis empty, and the guard was verified non-vacuous — reverting theNoneguard to a default makesunauthorized_outcome_never_federatesfail.Acceptance criteria (#142)
tests/test_federation.py::test_federated_object_is_served(live-infra integration test).tests/backend_auth.rs(unsigned_sets_skip_signature) +authorized_read_unsigned_populates_options.federate_*→ NEW CI unit testunauthorized_outcome_never_federates+ the structural data-dependency guarantee inresolve_product(the?on the subject-scoped fetch short-circuits before federation).tests/test_federation.py::test_restricted_product_denied_to_anonymous.Behavior change (called out, not silent)
Consolidating the gate means the permissions check no longer short-circuits before the permissions API call. A write to a connection that can't accept it (read-only / non-signable) now performs the (cached) permissions fetch before denying, rather than skipping it. Same
403result, one extra cached API call on that specific deny path — a rare write-to-misconfigured-connection corner, not the hot read path.Relatedly,
build_backend_optionsnow runs before the write gate, so a write to a connection with an unknown provider surfaces500instead of403. Such a connection is already broken for everyone (reads500too), so this is consistent rather than a regression.Verification
cargo test— 45 native tests pass (10 authz incl. 8 new, 10 backend_auth, 13 pagination, 12 routing).cargo fmt --all -- --check— clean.cargo clippy --target wasm32-unknown-unknown -- -D warnings— clean.cargo check --target wasm32-unknown-unknown—resolve_productand the lib still compile for the worker target.🤖 Generated with Claude Code