From 85e19420df2fb39a7ab5e7f4073cbd3790bab85b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 27 Jul 2026 11:59:53 -0700 Subject: [PATCH 1/3] fix(copy): authorize a versioned copy-source against its version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `x-amz-copy-source` may carry `?versionId=`, and that version rides through to the backend copy — but the synthetic `GetObject` used to authorize the source carried no version, and a plain `GET` through the proxy drops `?versionId=` entirely and serves the current object. `CopyObject` was therefore the only operation able to address a non-current version, and it did so without the registry ever being told: a caller with read access could copy an older version into a location they control and read it back. Deployments that treat "overwritten" as "no longer readable" had no way to refuse, because the version never reached their policy. - `types.rs`: `S3Operation::GetObject` gains `version: Option` — the version the backend read will actually return, `None` for current. The doc states the invariant it must hold to stay meaningful, and tells registries to deny an unrecognized `Some(_)` rather than ignore it. - `proxy.rs`: the copy's source authorization carries `src_version`, so the read that gets authorized is the read that gets performed. - `api/request.rs`: a plain `GET` leaves `version: None` even when the client sends `?versionId=`. This is deliberate, not an oversight — the read path does not address versions, so marking it versioned would describe a read that never happens and would let a policy authorize v1 while the backend serves current. Tests: the version reaches the source authorization (fails with `version: None` threaded through instead); a registry can refuse a versioned source and the refusal precedes any backend call; an unversioned copy is unaffected; a plain `GET` with `?versionId=` still authorizes an unversioned read. Not addressed here: a `GET` carrying `?versionId=` still silently returns the current object rather than erroring. That is a read-path correctness question, not an authorization one, and changing it would alter behavior for existing clients. Co-Authored-By: Claude Opus 5 (1M context) --- crates/core/src/api/request.rs | 10 +- crates/core/src/auth/authorize.rs | 1 + crates/core/src/proxy.rs | 180 ++++++++++++++++++++++++++++++ crates/core/src/types.rs | 18 +++ docs/reference/operations.md | 2 + 5 files changed, 210 insertions(+), 1 deletion(-) diff --git a/crates/core/src/api/request.rs b/crates/core/src/api/request.rs index a8133b2..a6ce3f9 100644 --- a/crates/core/src/api/request.rs +++ b/crates/core/src/api/request.rs @@ -160,7 +160,15 @@ pub fn build_s3_operation( raw_query: query.map(|q| q.to_string()), }) } else { - Ok(S3Operation::GetObject { bucket, key }) + // `?versionId=` is deliberately not carried here: the read + // path does not address versions, so the backend returns the + // current object and authorizing a version would describe a + // read that never happens. See `S3Operation::GetObject::version`. + Ok(S3Operation::GetObject { + bucket, + key, + version: None, + }) } } Method::HEAD => Ok(S3Operation::HeadObject { bucket, key }), diff --git a/crates/core/src/auth/authorize.rs b/crates/core/src/auth/authorize.rs index 5767dc9..0709bd8 100644 --- a/crates/core/src/auth/authorize.rs +++ b/crates/core/src/auth/authorize.rs @@ -305,6 +305,7 @@ mod tests { let read = |key: &str| S3Operation::GetObject { bucket: "b".into(), key: key.into(), + version: None, }; assert!(authorize(&id, &read("public/src.txt"), &bucket("b", false)).is_ok()); assert!(authorize(&id, &read("private/src.txt"), &bucket("b", false)).is_err()); diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index 93eaff3..b5ca82f 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -1084,6 +1084,7 @@ where let src_op = S3Operation::GetObject { bucket: src_bucket.clone(), key: src_key.clone(), + version: src_version.clone(), }; let src = self .bucket_registry @@ -3405,6 +3406,9 @@ mod tests { struct RecordingRegistry { seen: Arc>>, deny: Option, + /// Deny any read that names an object version, standing in for a + /// registry whose policy is not version-aware. + deny_versioned_reads: bool, } impl BucketRegistry for RecordingRegistry { @@ -3422,6 +3426,17 @@ mod tests { if self.deny == Some(operation.action()) { return Err(ProxyError::AccessDenied); } + if self.deny_versioned_reads + && matches!( + operation, + S3Operation::GetObject { + version: Some(_), + .. + } + ) + { + return Err(ProxyError::AccessDenied); + } Ok(ResolvedBucket { config: test_bucket_config(name), list_rewrite: None, @@ -3453,6 +3468,7 @@ mod tests { RecordingRegistry { seen: seen.clone(), deny: None, + deny_versioned_reads: false, }, MockCreds, None, @@ -3507,6 +3523,7 @@ mod tests { RecordingRegistry { seen: Arc::new(std::sync::Mutex::new(Vec::new())), deny: Some(Action::GetObject), + deny_versioned_reads: false, }, MockCreds, None, @@ -3543,6 +3560,7 @@ mod tests { RecordingRegistry { seen: Arc::new(std::sync::Mutex::new(Vec::new())), deny: Some(Action::PutObject), + deny_versioned_reads: false, }, MockCreds, None, @@ -3562,4 +3580,166 @@ mod tests { assert!(captured.lock().unwrap().is_none()); }); } + + /// A versioned copy-source reads bytes that no other operation can reach — + /// the read path ignores `?versionId=` and serves the current object. The + /// version must therefore reach the registry on the source authorization, + /// or a policy that would reject it never sees it. + #[test] + fn copy_source_version_reaches_the_source_authorization() { + run(async { + let versions = Arc::new(std::sync::Mutex::new(Vec::new())); + + #[derive(Clone)] + struct VersionRecordingRegistry { + versions: Arc>>>, + } + + impl BucketRegistry for VersionRecordingRegistry { + async fn get_bucket( + &self, + name: &str, + _identity: &ResolvedIdentity, + operation: &S3Operation, + ) -> Result { + if let S3Operation::GetObject { version, .. } = operation { + self.versions.lock().unwrap().push(version.clone()); + } + Ok(ResolvedBucket { + config: test_bucket_config(name), + list_rewrite: None, + display_name: None, + }) + } + + async fn list_buckets( + &self, + _identity: &ResolvedIdentity, + ) -> Result, ProxyError> { + Ok(vec![]) + } + } + + let gw = ProxyGateway::new( + CaptureHeadersBackend { + captured: Arc::new(std::sync::Mutex::new(None)), + }, + VersionRecordingRegistry { + versions: versions.clone(), + }, + MockCreds, + None, + ); + let mut headers = HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + "/src-bucket/obj.txt?versionId=v42".parse().unwrap(), + ); + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/obj.txt", None, &headers, None) + .await; + assert!(matches!(action, HandlerAction::Response(_))); + assert_eq!( + versions.lock().unwrap().clone(), + vec![Some("v42".to_string())], + "the source read must be authorized against the version it copies" + ); + }); + } + + /// An unversioned copy-source authorizes an unversioned read — the version + /// field describes the read that actually happens, not the request shape. + #[test] + fn unversioned_copy_source_authorizes_an_unversioned_read() { + run(async { + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let gw = ProxyGateway::new( + CaptureHeadersBackend { + captured: Arc::new(std::sync::Mutex::new(None)), + }, + RecordingRegistry { + seen: seen.clone(), + deny: None, + // Would reject a versioned read; this copy names no version. + deny_versioned_reads: true, + }, + MockCreds, + None, + ); + let mut headers = HeaderMap::new(); + headers.insert("x-amz-copy-source", "/src-bucket/obj.txt".parse().unwrap()); + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/obj.txt", None, &headers, None) + .await; + match action { + HandlerAction::Response(resp) => assert_eq!(resp.status, 200), + other => panic!( + "expected 200 Response, got {:?}", + std::mem::discriminant(&other) + ), + } + }); + } + + /// A registry whose policy is not version-aware can now refuse the read, + /// and the refusal lands before the backend is contacted — no versioned + /// bytes move. + #[test] + fn registry_can_deny_a_versioned_copy_source() { + run(async { + let captured = Arc::new(std::sync::Mutex::new(None)); + let gw = ProxyGateway::new( + CaptureHeadersBackend { + captured: captured.clone(), + }, + RecordingRegistry { + seen: Arc::new(std::sync::Mutex::new(Vec::new())), + deny: None, + deny_versioned_reads: true, + }, + MockCreds, + None, + ); + let mut headers = HeaderMap::new(); + headers.insert( + "x-amz-copy-source", + "/src-bucket/obj.txt?versionId=v42".parse().unwrap(), + ); + let action = gw + .resolve_request(Method::PUT, "/dst-bucket/obj.txt", None, &headers, None) + .await; + match action { + HandlerAction::Response(resp) => assert_eq!(resp.status, 403), + other => panic!( + "expected 403 Response, got {:?}", + std::mem::discriminant(&other) + ), + } + assert!( + captured.lock().unwrap().is_none(), + "a denied versioned read must never reach the backend" + ); + }); + } + + /// A plain `GET` carrying `?versionId=` stays unversioned: the proxy does + /// not address versions on the read path, so authorizing it as versioned + /// would describe a read that never happens. + #[test] + fn plain_get_with_version_id_authorizes_an_unversioned_read() { + let headers = HeaderMap::new(); + let op = crate::api::request::parse_s3_request( + &Method::GET, + "/b/obj.txt", + Some("versionId=v42"), + &headers, + crate::api::request::HostStyle::Path, + None, + ) + .unwrap(); + assert!( + matches!(op, S3Operation::GetObject { version: None, .. }), + "expected an unversioned GetObject, got {op:?}" + ); + } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 90d36c7..21789d4 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -347,6 +347,21 @@ pub enum S3Operation { GetObject { bucket: String, key: String, + /// The object version this read will actually return, or `None` for the + /// current version. + /// + /// Set only where the backend read is genuinely version-scoped — today + /// that is the source half of a [`CopyObject`](Self::CopyObject), whose + /// `x-amz-copy-source` may carry `?versionId=`. A plain `GET` leaves + /// this `None` even when the client sends `?versionId=`, because the + /// proxy does not address versions on the read path and serves the + /// current object regardless; authorizing such a request as versioned + /// would describe a read that never happens. + /// + /// Registries that scope permissions by version should deny on a + /// `Some(_)` they do not recognize rather than ignore it: it names + /// bytes that are otherwise unreachable through the proxy. + version: Option, }, HeadObject { bucket: String, @@ -503,6 +518,7 @@ mod tests { let op = S3Operation::GetObject { bucket: "b".into(), key: "k".into(), + version: None, }; assert_eq!(op.action(), Action::GetObject); @@ -532,6 +548,7 @@ mod tests { let op = S3Operation::GetObject { bucket: "my-bucket".into(), key: "k".into(), + version: None, }; assert_eq!(op.bucket(), Some("my-bucket")); @@ -543,6 +560,7 @@ mod tests { let op = S3Operation::GetObject { bucket: "b".into(), key: "my/key.txt".into(), + version: None, }; assert_eq!(op.key(), "my/key.txt"); diff --git a/docs/reference/operations.md b/docs/reference/operations.md index 8aad60e..f54a2d9 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -35,6 +35,8 @@ Credentials are *not* compared between the two ends. The copy is signed with the destination's credentials and the source contributes only its bucket name and prefix, so the source's own credentials are never used; whether the destination's credentials may read the source is an IAM question S3 answers with `AccessDenied`. (Comparing them would also be meaningless: a credential-injecting middleware such as `AwsBackendAuth` runs only against the destination config, so a source resolved from the same `auth_type=oidc` connection could never match.) The caller's own authorization is unaffected — the source is separately authorized as a read. +**Versioned sources are authorized as versioned reads.** An `x-amz-copy-source` may carry `?versionId=`, and that version is copied — bytes the read path cannot otherwise reach, since a plain `GET` ignores `?versionId=` and serves the current object. The version is therefore carried on the synthetic `GetObject` used to authorize the source (`S3Operation::GetObject::version`), so a registry can scope permissions by version; one that cannot should deny a `Some(_)` it does not recognize. A plain `GET` leaves the field `None` even when the client sends `?versionId=` — authorizing it as versioned would describe a read that never happens. + **Path-mapped deployments must map the copy-source.** `CopyObject` names its source in a header, so a proxy that rewrites client paths onto internal bucket names must rewrite the copy-source too — otherwise the source resolves as a client-facing name the registry has never heard of and every copy fails with `404 NoSuchBucket`. The client signed that header, so it must not be mutated; pass the mapped value alongside it via `RequestInfo::with_copy_source`, and signature verification keeps using the header as sent. `PathMapping::rewrite_copy_source` produces the mapped value: ```rust From 5b7ab9c5dcb5b159e572716890bdb36ddec23b5a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 27 Jul 2026 14:11:42 -0700 Subject: [PATCH 2/3] fix(authz): make a version-scoped read its own action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the previous commit closed the hole only for registries with a custom policy: `auth::authorize` — the shared engine that `multistore-static-config`, the bundled registry, calls from `get_bucket` — matches on action + bucket + prefix and never looked at the version. So the doc telling registries to "deny a `Some(_)` they do not recognize" described behavior the reference implementation did not have, and on a static-config deployment a caller with `get_object` on `product/` could still copy an old version of `product/old.txt` somewhere readable and fetch it back. Rather than special-case a denial inside `authorize`, a version-scoped read now resolves to its own action, `Action::GetObjectVersion`, mirroring S3's split between `s3:GetObject` and `s3:GetObjectVersion`. The existing action match then does the work: a scope granting `get_object` simply does not match, so the default is deny — and unlike a hardcoded rejection, a deployment can *grant* `get_object_version` and use the feature. That matters for the follow-up that makes plain versioned `GET` work; a blanket denial would have made it unusable on the bundled registry. Consequences worth naming: anonymous callers can never read a previous version (the anonymous grant covers current-object reads only), and existing configs keep working unchanged — they simply do not carry the new action, which is the safe direction. Also merges `VersionRecordingRegistry` into `RecordingRegistry` (review: Simplify) — it duplicated the whole trait impl to capture one field. Tests: the bundled policy denies a versioned read to a `get_object` holder (fails if `action()` maps versioned reads to `GetObject`), a `get_object_version` holder is allowed within their prefix and denied outside it, and anonymous is denied on an anonymous-readable bucket. Co-Authored-By: Claude Opus 5 (1M context) --- crates/core/src/auth/authorize.rs | 54 +++++++++++++++++++++++++++++++ crates/core/src/proxy.rs | 46 ++++++++------------------ crates/core/src/types.rs | 26 ++++++++++++--- docs/configuration/roles.md | 1 + 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/crates/core/src/auth/authorize.rs b/crates/core/src/auth/authorize.rs index 0709bd8..48d860b 100644 --- a/crates/core/src/auth/authorize.rs +++ b/crates/core/src/auth/authorize.rs @@ -291,6 +291,60 @@ mod tests { .is_err()); } + /// The hole this exists to close: on the bundled policy, a caller granted + /// `get_object` over a prefix must not be able to read — or copy out — an + /// older version of an object in it. A version-scoped read is its own + /// action, so the `get_object` grant simply does not match. + #[test] + fn get_object_grant_does_not_permit_a_versioned_read() { + let id = identity_with(AccessScope { + bucket: "b".into(), + prefixes: vec!["public/".into()], + actions: vec![Action::GetObject, Action::PutObject], + }); + let versioned = S3Operation::GetObject { + bucket: "b".into(), + key: "public/src.txt".into(), + version: Some("v1".into()), + }; + assert!( + authorize(&id, &versioned, &bucket("b", false)).is_err(), + "get_object must not imply reading previous versions" + ); + } + + /// ...and a caller who *is* granted the version action can, within their + /// prefix. Denying by default must stay expressible, not absolute. + #[test] + fn get_object_version_grant_permits_a_versioned_read_in_prefix() { + let id = identity_with(AccessScope { + bucket: "b".into(), + prefixes: vec!["public/".into()], + actions: vec![Action::GetObjectVersion], + }); + let versioned = |key: &str| S3Operation::GetObject { + bucket: "b".into(), + key: key.into(), + version: Some("v1".into()), + }; + assert!(authorize(&id, &versioned("public/src.txt"), &bucket("b", false)).is_ok()); + // The prefix still binds. + assert!(authorize(&id, &versioned("private/src.txt"), &bucket("b", false)).is_err()); + } + + /// An anonymous caller never reads a previous version, even on a bucket + /// that allows anonymous reads: the anonymous grant covers current-object + /// reads only. + #[test] + fn versioned_read_denied_for_anonymous() { + let versioned = S3Operation::GetObject { + bucket: "b".into(), + key: "x.txt".into(), + version: Some("v1".into()), + }; + assert!(authorize(&ResolvedIdentity::Anonymous, &versioned, &bucket("b", true)).is_err()); + } + /// The other half of a copy's authorization: the gateway resolves the copy /// source through a synthetic `GetObject`, so the source key is prefix- /// checked as a read. A caller scoped to `public/` cannot name a source diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index b5ca82f..0ffb529 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -3405,6 +3405,8 @@ mod tests { #[derive(Clone)] struct RecordingRegistry { seen: Arc>>, + /// Versions seen on read authorizations, in order. + versions: Arc>>>, deny: Option, /// Deny any read that names an object version, standing in for a /// registry whose policy is not version-aware. @@ -3423,6 +3425,9 @@ mod tests { operation.action(), operation.key().to_string(), )); + if let S3Operation::GetObject { version, .. } = operation { + self.versions.lock().unwrap().push(version.clone()); + } if self.deny == Some(operation.action()) { return Err(ProxyError::AccessDenied); } @@ -3467,6 +3472,7 @@ mod tests { }, RecordingRegistry { seen: seen.clone(), + versions: Arc::new(std::sync::Mutex::new(Vec::new())), deny: None, deny_versioned_reads: false, }, @@ -3522,6 +3528,7 @@ mod tests { }, RecordingRegistry { seen: Arc::new(std::sync::Mutex::new(Vec::new())), + versions: Arc::new(std::sync::Mutex::new(Vec::new())), deny: Some(Action::GetObject), deny_versioned_reads: false, }, @@ -3559,6 +3566,7 @@ mod tests { }, RecordingRegistry { seen: Arc::new(std::sync::Mutex::new(Vec::new())), + versions: Arc::new(std::sync::Mutex::new(Vec::new())), deny: Some(Action::PutObject), deny_versioned_reads: false, }, @@ -3589,43 +3597,15 @@ mod tests { fn copy_source_version_reaches_the_source_authorization() { run(async { let versions = Arc::new(std::sync::Mutex::new(Vec::new())); - - #[derive(Clone)] - struct VersionRecordingRegistry { - versions: Arc>>>, - } - - impl BucketRegistry for VersionRecordingRegistry { - async fn get_bucket( - &self, - name: &str, - _identity: &ResolvedIdentity, - operation: &S3Operation, - ) -> Result { - if let S3Operation::GetObject { version, .. } = operation { - self.versions.lock().unwrap().push(version.clone()); - } - Ok(ResolvedBucket { - config: test_bucket_config(name), - list_rewrite: None, - display_name: None, - }) - } - - async fn list_buckets( - &self, - _identity: &ResolvedIdentity, - ) -> Result, ProxyError> { - Ok(vec![]) - } - } - let gw = ProxyGateway::new( CaptureHeadersBackend { captured: Arc::new(std::sync::Mutex::new(None)), }, - VersionRecordingRegistry { + RecordingRegistry { + seen: Arc::new(std::sync::Mutex::new(Vec::new())), versions: versions.clone(), + deny: None, + deny_versioned_reads: false, }, MockCreds, None, @@ -3659,6 +3639,7 @@ mod tests { }, RecordingRegistry { seen: seen.clone(), + versions: Arc::new(std::sync::Mutex::new(Vec::new())), deny: None, // Would reject a versioned read; this copy names no version. deny_versioned_reads: true, @@ -3694,6 +3675,7 @@ mod tests { }, RecordingRegistry { seen: Arc::new(std::sync::Mutex::new(Vec::new())), + versions: Arc::new(std::sync::Mutex::new(Vec::new())), deny: None, deny_versioned_reads: true, }, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 21789d4..1c82624 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -189,6 +189,15 @@ pub struct AccessScope { #[serde(rename_all = "snake_case")] pub enum Action { GetObject, + /// Reading a *specific* object version, as opposed to the current one. + /// + /// A distinct action, mirroring S3's own split between `s3:GetObject` and + /// `s3:GetObjectVersion`: a scope that grants reads of an object does not + /// thereby grant reads of everything that object used to be. Prefix-scoped + /// policy cannot express "may read version X", so a caller must hold this + /// action explicitly — a grant of `GetObject` alone denies version-scoped + /// reads rather than silently permitting them. + GetObjectVersion, HeadObject, PutObject, ListBucket, @@ -358,9 +367,12 @@ pub enum S3Operation { /// current object regardless; authorizing such a request as versioned /// would describe a read that never happens. /// - /// Registries that scope permissions by version should deny on a - /// `Some(_)` they do not recognize rather than ignore it: it names - /// bytes that are otherwise unreachable through the proxy. + /// A `Some(_)` here changes the authorization action to + /// [`Action::GetObjectVersion`], so the shared policy + /// ([`authorize`](crate::auth::authorize)) denies it unless the caller + /// holds that action explicitly. Registries with their own policy + /// should make the same distinction: this names bytes that are + /// otherwise unreachable through the proxy. version: Option, }, HeadObject { @@ -454,7 +466,13 @@ impl S3Operation { /// The authorization action for this operation. pub fn action(&self) -> Action { match self { - S3Operation::GetObject { .. } => Action::GetObject, + // A version-scoped read is its own action: it reaches bytes that + // are otherwise unreachable, so holding `GetObject` must not imply + // it. See `Action::GetObjectVersion`. + S3Operation::GetObject { version: None, .. } => Action::GetObject, + S3Operation::GetObject { + version: Some(_), .. + } => Action::GetObjectVersion, S3Operation::HeadObject { .. } => Action::HeadObject, // A copy's destination is a write; the source read is authorized // separately at dispatch (see `execute_copy`). diff --git a/docs/configuration/roles.md b/docs/configuration/roles.md index 135fb7b..4e652d9 100644 --- a/docs/configuration/roles.md +++ b/docs/configuration/roles.md @@ -92,6 +92,7 @@ actions = ["get_object", "head_object", "put_object"] | Action | S3 Operation | |--------|-------------| | `get_object` | GET (download) | +| `get_object_version` | GET/copy of a *specific* object version (`?versionId=`) | | `head_object` | HEAD (metadata) | | `put_object` | PUT (upload) | | `delete_object` | DELETE | From d5f07bdad007e38f9d4e6426fbe8f4e1c59b0789 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 27 Jul 2026 14:28:10 -0700 Subject: [PATCH 3/3] docs(copy): say that the bundled policy enforces the version distinction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy section still described version scoping as something a registry *may* implement, with advice to deny an unrecognized `Some(_)`. That was accurate for the first commit and stale after `Action::GetObjectVersion`: the bundled policy now enforces it, so the paragraph understated what ships and left the mechanism documented only in the roles table. The earlier commit intended this edit and silently missed — the replacement was written against different wording than the file held, so it applied nowhere. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/operations.md b/docs/reference/operations.md index f54a2d9..f1c6aef 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -35,7 +35,7 @@ Credentials are *not* compared between the two ends. The copy is signed with the destination's credentials and the source contributes only its bucket name and prefix, so the source's own credentials are never used; whether the destination's credentials may read the source is an IAM question S3 answers with `AccessDenied`. (Comparing them would also be meaningless: a credential-injecting middleware such as `AwsBackendAuth` runs only against the destination config, so a source resolved from the same `auth_type=oidc` connection could never match.) The caller's own authorization is unaffected — the source is separately authorized as a read. -**Versioned sources are authorized as versioned reads.** An `x-amz-copy-source` may carry `?versionId=`, and that version is copied — bytes the read path cannot otherwise reach, since a plain `GET` ignores `?versionId=` and serves the current object. The version is therefore carried on the synthetic `GetObject` used to authorize the source (`S3Operation::GetObject::version`), so a registry can scope permissions by version; one that cannot should deny a `Some(_)` it does not recognize. A plain `GET` leaves the field `None` even when the client sends `?versionId=` — authorizing it as versioned would describe a read that never happens. +**Versioned sources are authorized as versioned reads.** An `x-amz-copy-source` may carry `?versionId=`, and that version is copied — bytes the read path cannot otherwise reach, since a plain `GET` ignores `?versionId=` and serves the current object. The version is therefore carried on the synthetic `GetObject` used to authorize the source (`S3Operation::GetObject::version`), which makes the authorization action `get_object_version` rather than `get_object` — mirroring S3's own split between `s3:GetObject` and `s3:GetObjectVersion`. This is not merely advice to registry implementors: the bundled policy (`auth::authorize`, which `multistore-static-config` calls) enforces it, because a version-scoped read resolves to its own [action](../configuration/roles.md). A caller granted `get_object` over a prefix therefore cannot read — or copy out — an older version of an object in it, and an anonymous caller never can; the scope must list `get_object_version` explicitly. Prefix-scoped policy cannot express "may read version X", so the default is to deny rather than silently permit. A registry with its own policy should draw the same distinction. A plain `GET` leaves the field `None` even when the client sends `?versionId=` — authorizing it as versioned would describe a read that never happens. **Path-mapped deployments must map the copy-source.** `CopyObject` names its source in a header, so a proxy that rewrites client paths onto internal bucket names must rewrite the copy-source too — otherwise the source resolves as a client-facing name the registry has never heard of and every copy fails with `404 NoSuchBucket`. The client signed that header, so it must not be mutated; pass the mapped value alongside it via `RequestInfo::with_copy_source`, and signature verification keeps using the header as sent. `PathMapping::rewrite_copy_source` produces the mapped value: