Skip to content

Commit 2c929c3

Browse files
alukachclaude
andauthored
test(authz): extract decide_backend_auth + CI ordering test (#142) (#170)
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_federates` — `None` 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) - [x] 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). - [x] Public (no-auth) connections still use the anonymous path → `tests/backend_auth.rs` (`unsigned_sets_skip_signature`) + `authorized_read_unsigned_populates_options`. - [x] **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). - [x] 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-unknown` — `resolve_product` and the lib still compile for the worker target. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fa463c7 commit 2c929c3

3 files changed

Lines changed: 277 additions & 51 deletions

File tree

src/authz.rs

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
//! Write-action classification. Kept wasm-free so it can be unit-tested
2-
//! natively (see `tests/authz.rs`), despite the crate's `[lib] test = false`.
3-
//! The rest of the write gate (read-only / signable / permission checks) is
4-
//! trivial enough to live inline in the registry.
1+
//! Authorization for product backends: write-action classification and the
2+
//! authorization → federation decision ([`decide_backend_auth`]). Kept wasm-free
3+
//! so both can be unit-tested natively (see `tests/authz.rs`), despite the
4+
//! crate's `[lib] test = false`.
55
6+
use std::collections::HashMap;
7+
8+
use multistore::error::ProxyError;
69
use multistore::types::Action;
710

11+
use crate::backend_auth::{apply_backend_auth, BackendAuth};
12+
813
/// Whether an S3 action mutates the backend. Reads (GET/HEAD/LIST) are served
914
/// without a write check; everything else is a write and must be authorized.
1015
///
@@ -18,3 +23,56 @@ pub(crate) fn is_write_action(action: Action) -> bool {
1823
Action::GetObject | Action::HeadObject | Action::ListBucket
1924
)
2025
}
26+
27+
/// Authorize a resolved product's request and, only on success, translate the
28+
/// connection's backend authentication into multistore `backend_options`. This
29+
/// is the single authorization → federation seam: `resolve_product` performs the
30+
/// I/O (subject-scoped Source API fetches) and hands the outcome here as plain
31+
/// values, so the security-critical ordering can be unit-tested off-wasm.
32+
///
33+
/// `authentication` is `Some` only when the caller's subject-scoped connection
34+
/// fetch succeeded. `None` models that upstream lookup having *denied* the
35+
/// caller, so we deny here without federating — the confused-deputy guard.
36+
///
37+
/// A write additionally requires an authenticated caller (`subject_present`) who
38+
/// holds the product's `write` permission, a connection that is not `read_only`,
39+
/// and a connection the proxy can actually sign as (an S3 web-identity role).
40+
///
41+
/// On every denial, `options` is left untouched: an unauthorized request must
42+
/// never have backend credentials or `skip_signature` emitted on its behalf.
43+
/// Only on success does [`apply_backend_auth`] populate `options`.
44+
#[allow(clippy::too_many_arguments)]
45+
pub(crate) fn decide_backend_auth(
46+
authentication: Option<&BackendAuth>,
47+
read_only: bool,
48+
is_write: bool,
49+
subject_present: bool,
50+
permissions: &[String],
51+
connection_id: &str,
52+
backend_type: &str,
53+
options: &mut HashMap<String, String>,
54+
) -> Result<(), ProxyError> {
55+
// Confused-deputy guard: no authorized connection ⇒ never federate.
56+
let auth = authentication.ok_or(ProxyError::AccessDenied)?;
57+
58+
if is_write {
59+
// Anonymous callers can never write (and there is no subject to query
60+
// permissions with).
61+
if !subject_present {
62+
return Err(ProxyError::AccessDenied);
63+
}
64+
// A connection can sign writes only via an S3 web-identity role; an
65+
// unsigned/unsupported connection (or one flagged read-only) cannot
66+
// accept them regardless of the caller's permissions.
67+
let signable = matches!(auth, BackendAuth::S3WebIdentityRole { .. });
68+
if read_only || !signable {
69+
return Err(ProxyError::AccessDenied);
70+
}
71+
// The caller must hold the product's `write` permission.
72+
if !permissions.iter().any(|p| p.eq_ignore_ascii_case("write")) {
73+
return Err(ProxyError::AccessDenied);
74+
}
75+
}
76+
77+
apply_backend_auth(auth, connection_id, backend_type, options)
78+
}

src/source_api/registry.rs

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ use multistore::registry::{BucketRegistry, ResolvedBucket};
66
use multistore::types::{Action, BucketConfig, ResolvedIdentity, S3Operation};
77
use std::collections::HashMap;
88

9-
use crate::authz::is_write_action;
10-
use crate::backend_auth::{apply_backend_auth, BackendAuth};
9+
use crate::authz::{decide_backend_auth, is_write_action};
1110

1211
use super::types::DataConnectionDetails;
1312

@@ -165,60 +164,56 @@ async fn resolve_product(
165164
)
166165
.await?;
167166

168-
// Authorize writes. The subject-scoped fetches above already cleared the
169-
// caller to *see* this product; a write additionally requires an
170-
// authenticated caller who holds the product's `write` permission, a
171-
// connection that is not read-only, and a connection the proxy can sign as.
172-
if is_write {
173-
// Anonymous callers can never write (and there is no subject to query
174-
// permissions with).
175-
let subject = subject.ok_or(ProxyError::AccessDenied)?;
176-
// Connection-level denials need no caller lookup — check them first so a
177-
// write the connection can't accept skips the permissions API call. A
178-
// connection can sign writes only via an S3 web-identity role.
179-
let signable = matches!(
180-
connection.authentication,
181-
BackendAuth::S3WebIdentityRole { .. }
182-
);
183-
if connection.read_only || !signable {
184-
return Err(ProxyError::AccessDenied);
185-
}
186-
// The caller must hold the product's `write` permission.
187-
let permissions = super::cache::get_or_fetch_permissions(
188-
api_base_url,
189-
account,
190-
product,
191-
api_auth,
192-
request_id,
193-
subject,
194-
)
195-
.await?;
196-
if !permissions.iter().any(|p| p.eq_ignore_ascii_case("write")) {
197-
return Err(ProxyError::AccessDenied);
198-
}
199-
}
200-
201167
// 4. Build BucketConfig
202168
let (backend_type, mut backend_options) = build_backend_options(&connection.details)?;
203169

170+
// A write needs the caller's product permissions; fetch them only for an
171+
// authenticated write, since reads never consult them and an anonymous write
172+
// is denied before they're read. (Earlier revisions also skipped this fetch
173+
// for writes the connection itself could not accept — read-only / unsignable —
174+
// as a micro-opt. Consolidating the whole gate into `decide_backend_auth`
175+
// trades that for a single source of truth, at the cost of one extra API call
176+
// on that specific deny path.)
177+
let permissions = match subject {
178+
Some(subject) if is_write => {
179+
super::cache::get_or_fetch_permissions(
180+
api_base_url,
181+
account,
182+
product,
183+
api_auth,
184+
request_id,
185+
subject,
186+
)
187+
.await?
188+
}
189+
_ => Vec::new(),
190+
};
191+
204192
// Backend authentication: unsigned (public) by default, or federate the
205-
// proxy's OIDC identity into the connection's role.
193+
// proxy's OIDC identity into the connection's role — but only after the write
194+
// gate passes. Both decisions live in `decide_backend_auth`.
206195
//
207196
// The confused-deputy guard is upstream: the subject-scoped Source API
208197
// fetches above (get_or_fetch_product / get_or_fetch_data_connection, keyed
209-
// on the caller's principal) only return the product/connection this caller
210-
// is authorized for — so reaching here means the caller is already cleared
211-
// for this connection's backend. Federation does not re-authorize.
198+
// on the caller's principal) only return the product/connection this caller is
199+
// authorized for — so reaching here means the caller is already cleared for
200+
// this connection's backend, hence we pass `Some(..)`. Federation does not
201+
// re-authorize.
212202
//
213203
// This ordering is enforced by data dependency, not just statement order:
214-
// apply_backend_auth needs `connection`, which only exists once the
215-
// subject-scoped fetch succeeds. A 403/404 from that fetch propagates via `?`
216-
// before we ever get here, so an unauthorized caller can never reach
217-
// federation. Guarded end-to-end by tests/test_federation.py's
218-
// test_restricted_product_denied_to_anonymous.
204+
// `connection` only exists once the subject-scoped fetch succeeds, and a
205+
// 403/404 from that fetch propagates via `?` before we ever get here — so an
206+
// unauthorized caller can never reach federation. Guarded in CI by the
207+
// `tests/authz.rs` `decide_backend_auth` unit tests (an unauthorized outcome
208+
// ⇒ AccessDenied with no options emitted) and end-to-end by
209+
// tests/test_federation.py's test_restricted_product_denied_to_anonymous.
219210
span.record("auth_type", connection.authentication.kind());
220-
apply_backend_auth(
221-
&connection.authentication,
211+
decide_backend_auth(
212+
Some(&connection.authentication),
213+
connection.read_only,
214+
is_write,
215+
subject.is_some(),
216+
&permissions,
222217
&connection.data_connection_id,
223218
&backend_type,
224219
&mut backend_options,

tests/authz.rs

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
//! Native unit tests for the wasm-free `authz` module, included via `#[path]`
22
//! (the lib itself is `cdylib` with `test = false`). Mirrors the pattern in
33
//! `tests/backend_auth.rs`.
4+
//!
5+
//! `authz` references `crate::backend_auth`, so that module is pulled in here too
6+
//! (under the test crate root) so the `crate::` path resolves the same way it
7+
//! does in the lib build.
48
59
#[path = "../src/authz.rs"]
610
mod authz;
11+
#[path = "../src/backend_auth.rs"]
12+
mod backend_auth;
713

8-
use authz::is_write_action;
14+
use authz::{decide_backend_auth, is_write_action};
15+
use backend_auth::BackendAuth;
16+
use multistore::error::ProxyError;
917
use multistore::types::Action;
18+
use std::collections::HashMap;
1019

1120
#[test]
1221
fn reads_are_not_writes() {
@@ -28,3 +37,167 @@ fn mutations_are_writes() {
2837
assert!(is_write_action(action), "{action:?} should be a write");
2938
}
3039
}
40+
41+
// ── decide_backend_auth: authorization → federation ordering (#142) ─────────
42+
//
43+
// The invariant under test: an unauthorized request must be denied *before* any
44+
// backend authentication is applied — so a denial returns `AccessDenied` and
45+
// leaves `options` empty (no `oidc_role_arn` / `skip_signature` leaked). If
46+
// someone reordered the gate so federation ran before the checks, these tests
47+
// would catch populated options on a denial.
48+
49+
fn role() -> BackendAuth {
50+
BackendAuth::S3WebIdentityRole {
51+
role_arn: "arn:aws:iam::1:role/r".into(),
52+
}
53+
}
54+
55+
/// `None` authentication = the upstream subject-scoped fetch denied the caller.
56+
/// Federation must never happen and `options` must stay empty, for reads or
57+
/// writes alike.
58+
#[test]
59+
fn unauthorized_outcome_never_federates() {
60+
for is_write in [false, true] {
61+
let mut o = HashMap::new();
62+
let result = decide_backend_auth(
63+
None,
64+
false,
65+
is_write,
66+
true,
67+
&["write".to_string()],
68+
"conn-1",
69+
"s3",
70+
&mut o,
71+
);
72+
assert!(matches!(result, Err(ProxyError::AccessDenied)));
73+
assert!(o.is_empty(), "denied request must not emit backend options");
74+
}
75+
}
76+
77+
#[test]
78+
fn authorized_read_unsigned_populates_options() {
79+
let mut o = HashMap::new();
80+
decide_backend_auth(
81+
Some(&BackendAuth::Unsigned),
82+
false,
83+
false,
84+
false,
85+
&[],
86+
"conn-1",
87+
"s3",
88+
&mut o,
89+
)
90+
.unwrap();
91+
assert_eq!(o.get("skip_signature").map(String::as_str), Some("true"));
92+
}
93+
94+
#[test]
95+
fn authorized_read_federated_populates_options() {
96+
let mut o = HashMap::new();
97+
decide_backend_auth(
98+
Some(&role()),
99+
false,
100+
false,
101+
false,
102+
&[],
103+
"conn-1",
104+
"s3",
105+
&mut o,
106+
)
107+
.unwrap();
108+
assert_eq!(
109+
o.get("oidc_role_arn").map(String::as_str),
110+
Some("arn:aws:iam::1:role/r")
111+
);
112+
assert!(!o.contains_key("skip_signature"));
113+
}
114+
115+
#[test]
116+
fn write_by_anonymous_denied() {
117+
let mut o = HashMap::new();
118+
let result = decide_backend_auth(
119+
Some(&role()),
120+
false,
121+
true,
122+
false, // no subject
123+
&["write".to_string()],
124+
"conn-1",
125+
"s3",
126+
&mut o,
127+
);
128+
assert!(matches!(result, Err(ProxyError::AccessDenied)));
129+
assert!(o.is_empty());
130+
}
131+
132+
#[test]
133+
fn write_to_read_only_denied() {
134+
let mut o = HashMap::new();
135+
let result = decide_backend_auth(
136+
Some(&role()),
137+
true, // read_only
138+
true,
139+
true,
140+
&["write".to_string()],
141+
"conn-1",
142+
"s3",
143+
&mut o,
144+
);
145+
assert!(matches!(result, Err(ProxyError::AccessDenied)));
146+
assert!(o.is_empty());
147+
}
148+
149+
#[test]
150+
fn write_to_non_signable_denied() {
151+
// Unsigned (public) connections can't sign writes, even with the permission.
152+
let mut o = HashMap::new();
153+
let result = decide_backend_auth(
154+
Some(&BackendAuth::Unsigned),
155+
false,
156+
true,
157+
true,
158+
&["write".to_string()],
159+
"conn-1",
160+
"s3",
161+
&mut o,
162+
);
163+
assert!(matches!(result, Err(ProxyError::AccessDenied)));
164+
assert!(o.is_empty());
165+
}
166+
167+
#[test]
168+
fn write_without_write_permission_denied() {
169+
let mut o = HashMap::new();
170+
let result = decide_backend_auth(
171+
Some(&role()),
172+
false,
173+
true,
174+
true,
175+
&["read".to_string()], // no "write"
176+
"conn-1",
177+
"s3",
178+
&mut o,
179+
);
180+
assert!(matches!(result, Err(ProxyError::AccessDenied)));
181+
assert!(o.is_empty());
182+
}
183+
184+
#[test]
185+
fn authorized_write_populates_options() {
186+
let mut o = HashMap::new();
187+
decide_backend_auth(
188+
Some(&role()),
189+
false,
190+
true,
191+
true,
192+
&["read".to_string(), "WRITE".to_string()], // case-insensitive match
193+
"conn-1",
194+
"s3",
195+
&mut o,
196+
)
197+
.unwrap();
198+
assert_eq!(
199+
o.get("oidc_role_arn").map(String::as_str),
200+
Some("arn:aws:iam::1:role/r")
201+
);
202+
assert!(!o.contains_key("skip_signature"));
203+
}

0 commit comments

Comments
 (0)