fix(copy): resolve CopyObject sources on path-mapped and OIDC deployments - #128
Conversation
…ents
Server-side CopyObject was unusable on a proxy that maps client paths onto
internal bucket names (Source Cooperative's `/{account}/{product}/{key}` →
`account:product`), for two independent reasons. Every SDK client hit the
first one, so the feature was 100% broken there:
1. `x-amz-copy-source` names the source in a *header*, which no URL rewrite
touches. The gateway parsed the raw client-facing value, so the source
resolved as `alukach` — an account, not a bucket — and the copy died with
`404 NoSuchBucket`. The client signs that header, so a proxy cannot fix it
by mutating the request.
2. Even given a resolvable source, `same_backing_store` compared credentials
between the two ends. A credential-injecting middleware (`AwsBackendAuth`)
only runs against the *destination* config in the dispatch context, so a
source resolved from the same `auth_type=oidc` connection still carried its
unresolved form and never matched — every copy, including a copy within one
product, was rejected as a `501` cross-store copy.
- `route_handler.rs`: `RequestInfo::copy_source` + `with_copy_source`, carrying
the copy-source rewritten into the gateway's bucket namespace. Mirrors
`signing_path`: the signed header stays as sent for SigV4 verification while
source resolution uses the mapped value.
- `api/request.rs`: `parse_s3_request` takes `copy_source_override`, preferring
it over the header when the request is a copy. A PUT without the header stays
a `PutObject` regardless of the override.
- `path-mapping`: `PathMapping::rewrite_copy_source` folds leading segments into
the bucket name, preserving the key's percent-encoding and any `versionId`.
Returns `None` for values too short to map, so the caller falls through to the
header and the gateway reports the real error.
- `proxy.rs`: `same_backing_store` → `same_s3_endpoint`, comparing S3-ness,
endpoint, and region only. Credentials are dropped from the comparison
deliberately: 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 may read the source is an
IAM question S3 answers with `AccessDenied`. Caller authorization is
unaffected — the source is still separately authorized as a read.
- Cargo.lock: incidental catch-up to the 0.7.0 version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @alukach's task in 2m 11s —— View job Claude finished reviewingTodo list
✅ No blocking issues — safe to merge. I traced both bugs end to end against the actual call graph, not just the PR description:
No correctness, security, or best-practice issues found in the diff. Simplify (ponytail): nothing to cut — 💰 Estimated review cost: $1.03 · 2m10s · 30 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-128.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-128.development-seed.workers.dev
|
A copy is two permissions, not one, but nothing pinned that: the existing CopyObject tests run against a registry that authorizes everything, so a regression that dropped either check — or passed the wrong key to it — would have gone unnoticed. `RecordingRegistry` captures every (bucket, action, key) the gateway asks the registry to authorize, and can deny a chosen action: - `copy_authorizes_destination_write_and_source_read` — asserts exactly two authorizations, the destination as `PutObject` on the destination key and the source as `GetObject` on the source key. The keys matter: a registry that scopes by prefix can only enforce that if the key that end touches reaches it. - `copy_denied_when_the_caller_cannot_read_the_source` — write access to the destination does not let a caller launder an unreadable source into a bucket they control. 403, backend never contacted. - `copy_denied_when_the_caller_cannot_write_the_destination` — the mirror case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authorization reviewQuestion raised on review: does dropping the credential comparison weaken the permission model — is the signer actually checked for read on the source and write on the destination? Both ends are checked, and the change does not touch that path. Traced through
Both land before the backend is contacted, and The credentials that were being compared were never an authorization control: the copy is signed with the destination's credentials, and the source config contributes only Three tests added in aa3414b to pin this, since nothing covered it before (the existing copy tests use a registry that authorizes everything): One pre-existing caveat, unrelated to this PR
|
`authorize` was only exercised for `DeleteObjects`, so the policy half of a copy's authorization was untested — the gateway tests in aa3414b prove the registry is *asked* about both ends, but nothing proved the default policy answers correctly. - `copy_object_enforces_prefix_on_the_destination_key` — the operation's key must read as the destination key. Mutating `authorize` to key off `src_key` instead makes this fail; without it a prefix-scoped caller could write anywhere in the bucket. - `copy_object_requires_the_put_action` — `CopyObject` maps to `PutObject`, so read access is not copy access. - `copy_object_denied_for_anonymous` — a copy writes, so it is denied even on an anonymous-readable bucket. - `copy_source_read_enforces_prefix_via_get_object` — the source half: the synthetic `GetObject` the gateway builds is prefix-checked as a read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CopyObject` names its source in a header rather than the URL, in client coordinates (`/account/product/key`). This deployment is path-mapped, so the registry only knows mapped bucket names — an unmapped source resolves to a bucket it has never heard of and every copy fails with 404 NoSuchBucket. multistore 0.7.1 adds `RequestInfo::with_copy_source` for exactly this (developmentseed/multistore#128), but it is opt-in: the host app has to supply the mapped value. Wire it up via `PathMapping::rewrite_copy_source`. The client signed the original header, so it is left untouched and signature verification still uses it as sent; only the out-of-band mapped value differs. Adds two integration tests: an anonymous copy is denied at the authz gate, and a credentialed copy must reach the federation seam rather than dying as NoSuchBucket — which is what distinguishes a mapped source from an unmapped one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Bumps multistore to 0.7.1 and adapts this proxy to the copy-source handling it introduces. ## Why `CopyObject` names its source in the `x-amz-copy-source` header rather than the URL, in client coordinates (`/account/product/key`). This deployment is path-mapped, so the registry only knows mapped bucket names (`account:product`) — an unmapped source resolves to a bucket it has never heard of and the copy fails with 404 NoSuchBucket. That affects the server-side CopyObject support landed in 97d5121. multistore 0.7.1 provides the mechanism for this ([#128](developmentseed/multistore#128)), but it is opt-in: `RequestInfo::with_copy_source` takes a mapped value that the host app must supply. This wires it up. The bump also picks up [#129](developmentseed/multistore#129), which authorizes a versioned copy-source against its version. ## Changes - `Cargo.toml` / `Cargo.lock` — multistore crates to 0.7.1, from crates.io with checksums. - `object_path::mapped_copy_source` — maps the header through `PathMapping::rewrite_copy_source`, returning `None` for a non-copy request or an unmappable value. Lives in `object_path` because that module is deliberately wasm-free and so can be unit-tested natively, the lib being `cdylib` with `test = false`. - `src/lib.rs` — passes the mapped value via `with_copy_source`. The client signed the original header, so it is left untouched and signature verification still uses it as sent; only the value passed alongside differs. ## Tests The mapping is pinned natively in `tests/object_path.rs`: the account/product fold, leading-slash normalization, nested keys, `versionId` and percent-encoding preservation, and both `None` cases. Natively rather than through an integration test, because an integration test cannot detect this regression: federation is attempted against the destination before the copy source is resolved, so in CI — where the throwaway signing key means federation never succeeds — a copy fails identically whether or not the mapping is wired up. This was verified, not assumed. Copy-source authorization — a caller holding write on the destination must not be able to read a product they aren't entitled to via `CopyObject` — is asserted in `tests/test_federation.py`, the deployed-environment suite. Same reason: in CI the destination write never succeeds, and `AccessDenied` is itself a federation error code, so the assertion would pass without proving anything. It is dormant and skips until both `FEDERATION_WRITE_PRODUCT` and a caller token are supplied, and skips cleanly otherwise; `staging.yml` gains boto3, the `FEDERATION_WRITE_PRODUCT` passthrough, and a step minting the caller as a GitHub Actions OIDC token — short-lived and per-run, with nothing stored. This anticipates registering GitHub as a valid IdP with Source so products can accept writes from GitHub Actions. It stays dormant until two things land: the deployment's `AUTH_ISSUER` must accept GitHub's issuer, which is a **code change and not only config** — `src/config.rs:80` reads `AUTH_ISSUER` as a single `String`, unlike the comma-separated `AUTH_AUDIENCE` at `src/config.rs:87` — and `FEDERATION_TEST_AUDIENCE` must name the audience Source expects. Until both, no token is minted and the test skips. Two integration tests in `tests/test_writes.py` cover what that layer can genuinely show, CopyObject having had no coverage there at all: `test_anonymous_copy_is_denied` (a copy is a write, so it dies at the authz gate) and `test_copy_reaches_the_federation_seam` (a copy is parsed and dispatched as a copy, and fails closed). ## Verification - `cargo test` — 73 pass across 8 suites (was 69) - `cargo check --lib --target wasm32-unknown-unknown` — clean - Local run against stub API + `wrangler dev`; credentialed tests need a GitHub OIDC token and run in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What I'm changing
Server-side
CopyObjectis unusable on a proxy that maps client paths onto internal bucket names — Source Cooperative maps/{account}/{product}/{key}to the bucketaccount:product. Reproduced against production (data.source.coop) with a plain boto3copy_object:Two independent bugs, both hit before the request ever reaches the backend. Every SDK client trips the first, so the feature is 100% broken on such a deployment:
The copy-source header is never namespace-mapped.
CopyObjectnames its source inx-amz-copy-source, not the URL, soPathMapping::rewrite_requestnever sees it. The gateway parses the raw client-facing value and resolvesalukach— an account, not a bucket — giving404 NoSuchBucket. The client signs that header, so a proxy cannot fix this by rewriting the request in flight.same_backing_storecompares credentials that only one side ever has. A credential-injecting middleware (AwsBackendAuth) swapsauth_type=oidcfor minted STS keys, but it only runs against the destination config in the dispatch context. The copy source is resolved afterwards, inside the handler, and still carries the unresolved form — so the two configs could never match. Hand-signing past bug 1 confirms it: source and destination in the same product were rejected as a cross-store copy.How I did it
crates/core/src/route_handler.rs—RequestInfo::copy_source+with_copy_source(), carrying the copy-source rewritten into the gateway's bucket namespace. Mirrors the existingsigning_pathidiom: the signed header stays exactly as the client sent it for SigV4 verification, while source resolution uses the mapped value.router.rspropagates the field.crates/core/src/api/request.rs—parse_s3_requesttakescopy_source_overrideand prefers it over the header when the request is a copy. A PUT without the header stays aPutObjectregardless of the override.crates/path-mapping/src/lib.rs—PathMapping::rewrite_copy_sourcefolds leading segments into the bucket name (/a/b/key→/a:b/key), preserving the key's percent-encoding and any?versionId=. ReturnsNonefor values too short to map, so the caller falls through to the raw header and the gateway reports the real error rather than a synthesized one.crates/core/src/proxy.rs—same_backing_store→same_s3_endpoint, comparing S3-ness, endpoint, and region only. Credentials are dropped from the comparison deliberately, not incidentally: the copy is signed with the destination's credentials, and the source config contributes only its bucket name and prefix (build_copy_source_header), so the source's own credentials are never used for anything. Whether the destination's credentials may read the source is an IAM question, and S3 answers it withAccessDenied, which passes through. Caller authorization is unchanged — the source is still separately authorized as a read (GetObject) against the caller's identity.docs/—reference/operations.mdgains the path-mapping requirement (with a call-site snippet) and corrects "same store, same credentials" to "same endpoint";architecture/crate-layout.mdnotes the new path-mapping responsibility.Cargo.lock— incidental catch-up to the 0.7.0 version bump, unrelated to this change.Test plan
Nine tests added, following the repo's failing-test-first convention. Verified the four that pin the bugs fail against the pre-fix logic (credential comparison restored, override ignored) and pass after:
copy_source_override_replaces_the_signed_header/..._without_the_header_is_ignoredrewrite_copy_source_folds_leading_segments_into_the_bucket/..._preserves_encoding_and_version/..._declines_unmappable_valuesunmapped_copy_source_fails_on_a_path_mapping_registry— pins the production symptom (404, backend never contacted)mapped_copy_source_override_resolves_the_source— same request succeeds with the override, backend receives/backend-bucket/README.mdmiddleware_resolved_destination_still_matches_unresolved_source— the asymmetric-credential casecopy_succeeds_when_only_the_destination_has_minted_credentials— end-to-end, was501cargo test(full workspace, all green)cargo clippy --fix --allow-dirty --allow-staged(clean)cargo fmtcargo check -p multistore-cf-workers --target wasm32-unknown-unknownNot yet verified end-to-end against live
data.source.coop— that needs a corresponding change in the consumer to callwith_copy_source, which is not in this PR.🤖 Generated with Claude Code