Skip to content

test(authz): extract decide_backend_auth + CI ordering test (#142)#170

Open
alukach wants to merge 1 commit into
mainfrom
test/142-authz-ordering
Open

test(authz): extract decide_backend_auth + CI ordering test (#142)#170
alukach wants to merge 1 commit into
mainfrom
test/142-authz-ordering

Conversation

@alukach

@alukach alukach commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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_product into a pure, wasm-free function and unit-testing it in CI.

How

  • New authz::decide_backend_auth — a pure function that takes the authorization outcome as plain values, gates the request, and only on success calls apply_backend_auth. The confused-deputy guard is the first line: authentication: Option<&BackendAuth> is None when the upstream subject-scoped fetch denied the caller ⇒ Err(AccessDenied) with no federation.
  • Consolidation, not addition — the scattered write-gate previously inlined in resolve_product (anon denied / read-only / non-signable / write-permission) plus the trailing apply_backend_auth call are now one tested function. resolve_product keeps 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.
  • CI unit tests in tests/authz.rs (the crate is cdylib with [lib] test = false; native tests only compile wasm-free modules — authz is one, and already had a test binary). The module is pulled in via #[path], mirroring tests/backend_auth.rs; backend_auth is included alongside it so the crate::backend_auth path resolves the same way it does in the lib build.

New tests (tests/authz.rs):

  • unauthorized_outcome_never_federatesNone authentication (read and write) ⇒ AccessDenied and options stays empty (no oidc_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 ⇒ Err and empty options.
  • authorized_write_populates_options — authorized write (case-insensitive write permission) populates options.

Not test theater: every denial asserts options is empty, and the guard was verified non-vacuous — reverting the None guard to a default makes unauthorized_outcome_never_federates fail.

Acceptance criteria (#142)

  • Private S3 product served end-to-end via federated creds → existing gated tests/test_federation.py::test_federated_object_is_served (live-infra integration test).
  • Public (no-auth) connections still use the anonymous path → tests/backend_auth.rs (unsigned_sets_skip_signature) + authorized_read_unsigned_populates_options.
  • Test: an unauthorized caller never triggers federate_*NEW CI unit test unauthorized_outcome_never_federates + the structural data-dependency guarantee in resolve_product (the ? on the subject-scoped fetch short-circuits before federation).
  • Integration test against a private bucket → existing gated 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 403 result, 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_options now runs before the write gate, so a write to a connection with an unknown provider surfaces 500 instead of 403. Such a connection is already broken for everyone (reads 500 too), 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-unknownresolve_product and the lib still compile for the worker target.

🤖 Generated with Claude Code

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

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 1m 28s —— View job


✅ No blocking issues — safe to merge.

  • src/authz.rs:56 — Confused-deputy guard is correctly the first line; every denial path reaches Err before apply_backend_auth touches options. All eight test cases assert o.is_empty() after denial, which is the right non-vacuous check.

  • src/authz.rs:45authentication: Option<&BackendAuth> is always Some(..) at the real call site (registry.rs:212); the None arm exists exclusively for the unit-test harness. This is accurately documented, but it means the live confused-deputy guard is the data dependency (? propagation on the subject-scoped fetch), not this ok_or. No bug — the PR description calls this out explicitly and the structural guarantee holds.

  • src/source_api/registry.rs:177-190 — The anonymous-write case (subject = None, is_write = true) leaves permissions = Vec::new() and then passes subject_present = false into decide_backend_auth, which denies before the permissions slice is consulted. Correct; consistent with the write_by_anonymous_denied test.

  • src/source_api/registry.rs:168build_backend_options now runs before the write gate, so an unknown-provider write surfaces 500 instead of 403. Noted in the PR as intentional; a broken connection already returns 500 on reads, so this is consistent rather than a regression.

  • tests/authz.rs#[path] double-include of backend_auth cleanly resolves the crate::backend_auth path without introducing test-only shims into the library. The non-vacuousness comment ("reverting the None guard makes unauthorized_outcome_never_federates fail") is good hygiene.

@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-170.source-coop.workers.dev

  • Date: 2026-06-29T05:56:54Z
  • Commit: 3b67c0e

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(registry): wire signed backend_options into resolve_product + authz-ordering test

1 participant