Skip to content

fix(copy): resolve CopyObject sources on path-mapped and OIDC deployments - #128

Merged
alukach merged 3 commits into
mainfrom
fix/copyobject-path-mapping
Jul 27, 2026
Merged

fix(copy): resolve CopyObject sources on path-mapped and OIDC deployments#128
alukach merged 3 commits into
mainfrom
fix/copyobject-path-mapping

Conversation

@alukach

@alukach alukach commented Jul 27, 2026

Copy link
Copy Markdown
Member

What I'm changing

Server-side CopyObject is unusable on a proxy that maps client paths onto internal bucket names — Source Cooperative maps /{account}/{product}/{key} to the bucket account:product. Reproduced against production (data.source.coop) with a plain boto3 copy_object:

NoSuchBucket: bucket not found: alukach

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:

  1. The copy-source header is never namespace-mapped. CopyObject names its source in x-amz-copy-source, not the URL, so PathMapping::rewrite_request never sees it. The gateway parses the raw client-facing value and resolves alukach — an account, not a bucket — giving 404 NoSuchBucket. The client signs that header, so a proxy cannot fix this by rewriting the request in flight.

  2. same_backing_store compares credentials that only one side ever has. A credential-injecting middleware (AwsBackendAuth) swaps auth_type=oidc for 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.

501 NotImplemented: cross-store copy (source and destination on different backends) is not supported

How I did it

  • crates/core/src/route_handler.rsRequestInfo::copy_source + with_copy_source(), carrying the copy-source rewritten into the gateway's bucket namespace. Mirrors the existing signing_path idiom: the signed header stays exactly as the client sent it for SigV4 verification, while source resolution uses the mapped value. router.rs propagates the field.
  • crates/core/src/api/request.rsparse_s3_request takes copy_source_override and prefers it over the header when the request is a copy. A PUT without the header stays a PutObject regardless of the override.
  • crates/path-mapping/src/lib.rsPathMapping::rewrite_copy_source folds leading segments into the bucket name (/a/b/key/a:b/key), preserving the key's percent-encoding and any ?versionId=. Returns None for 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.rssame_backing_storesame_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 with AccessDenied, 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.md gains the path-mapping requirement (with a call-site snippet) and corrects "same store, same credentials" to "same endpoint"; architecture/crate-layout.md notes 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_ignored

  • rewrite_copy_source_folds_leading_segments_into_the_bucket / ..._preserves_encoding_and_version / ..._declines_unmappable_values

  • unmapped_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.md

  • middleware_resolved_destination_still_matches_unresolved_source — the asymmetric-credential case

  • copy_succeeds_when_only_the_destination_has_minted_credentials — end-to-end, was 501

  • cargo test (full workspace, all green)

  • cargo clippy --fix --allow-dirty --allow-staged (clean)

  • cargo fmt

  • cargo check -p multistore-cf-workers --target wasm32-unknown-unknown

Not yet verified end-to-end against live data.source.coop — that needs a corresponding change in the consumer to call with_copy_source, which is not in this PR.

🤖 Generated with Claude Code

…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

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @alukach's task in 2m 11s —— View job


Claude finished reviewing

Todo list

  • Gather context (PR body, comments, changed files)
  • Fetch diff against origin/main
  • Read changed files in full
  • Review for correctness/security/best-practice bugs
  • Apply ponytail-review pass for over-engineering
  • Post final review

✅ No blocking issues — safe to merge.

I traced both bugs end to end against the actual call graph, not just the PR description:

  • Copy-source rewriting (crates/path-mapping/src/lib.rs:286 rewrite_copy_source, crates/core/src/api/request.rs:47 override plumbing): PathMapping::parse never percent-decodes, so rewrite_copy_source preserves encoding exactly as the existing parse_copy_source (request.rs:104) expects when it later decodes the key — the two halves compose correctly. ?versionId= is split on the literal, unencoded delimiter, matching the real header grammar (a literal ? can't appear in a properly-encoded key). None-on-too-few-segments falls through to the raw header, so the error the client sees on a genuinely bad source is the real NoSuchBucket/InvalidRequest, not a synthesized one. Both real call sites of parse_s3_request (proxy.rs:342, proxy.rs:693) were updated; no orphaned caller left on the old signature.
  • same_backing_storesame_s3_endpoint (crates/core/src/proxy.rs:1670): confirmed by reading execute_copy (proxy.rs:1585) directly — sign_s3_request signs with dest_config only; src_config is used exclusively to build the x-amz-copy-source value (bucket name + prefix) via build_copy_source_header. So the removed credential comparison never gated an actual credential use; it only rejected asymmetric-credential same-endpoint copies that should have succeeded. Caller-side authorization is untouched — dispatch_operation's CopyObject arm (proxy.rs:1073) still authorizes the destination write via the main pipeline and the source read via a synthetic GetObject through get_bucket, both before execute_copy runs. The three new authorization tests (copy_authorizes_destination_write_and_source_read and the two denial tests) pin exactly that shape.
  • Docs (docs/reference/operations.md) accurately reflect the new "same-endpoint" semantics and add the path-mapping caveat with a working code snippet using real symbol names (RequestInfo::with_copy_source, PathMapping::rewrite_copy_source). Cargo.lock diff is purely the 0.6.4→0.7.0 version-bump catch-up, no dependency changes.
  • Verified all real (non-test) call sites of parse_s3_request were updated, and same_backing_store has no remaining references anywhere. CI (full cargo test, clippy, fmt, wasm32 check) is green on the latest commit.

No correctness, security, or best-practice issues found in the diff.

Simplify (ponytail): nothing to cut — rewrite_copy_source reuses the existing parse rather than reimplementing segment-splitting, the credential-comparison removal is a deletion (net simpler), and the three new mock registries in proxy.rs tests each pin a genuinely distinct scenario (path-mapped 404, asymmetric OIDC credentials, two-sided authorization) rather than being redundant scaffolding.


💰 Estimated review cost: $1.03 · 2m10s · 30 turns

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-128.development-seed.workers.dev

  • Date: 2026-07-27T18:55:00Z
  • Commit: 839ecbe

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-128.development-seed.workers.dev

  • Date: 2026-07-27T18:55:00Z
  • Commit: 839ecbe

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>
@alukach

alukach commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Authorization review

Question 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 dispatch_operation's CopyObject arm:

  • Destination — authorized by the main pipeline. S3Operation::CopyObject.action() is Action::PutObject (types.rs) and .key() is the destination key, so get_bucket(dst, identity, op) sees a write against the key being written.
  • Source — authorized inside the copy handler by a synthetic GetObject { src_bucket, src_key } passed to get_bucket, i.e. the exact authorization a real GET of that source object would get. execute_copy then reuses the same src_key, so the key that was authorized is the key that gets copied.

Both land before the backend is contacted, and validate_key rejects ./.. segments on the source key, so the source's backend_prefix cannot be escaped. Anonymous callers are rejected at the destination write.

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 bucket_name + prefix (build_copy_source_header) — its credentials are never used for anything. Dropping the comparison therefore grants a caller nothing the registry did not already authorize. What does change: for two buckets on one endpoint backed by different credentials, the proxy now attempts the copy instead of returning 501, so the backend-side boundary is enforced by IAM (AccessDenied, passed through) rather than by a proxy-side guess. A caller still needs registry authorization on both ends to get that far.

Three tests added in aa3414b to pin this, since nothing covered it before (the existing copy tests use a registry that authorizes everything): copy_authorizes_destination_write_and_source_read asserts exactly two authorizations with the right action and key on each end, plus a denial test for each direction.

One pre-existing caveat, unrelated to this PR

x-amz-copy-source accepts ?versionId=, and that version rides through to the backend copy — but the synthetic GetObject used to authorize the source carries no version, and plain GetObject through the proxy drops ?versionId= entirely. So CopyObject is currently the only operation that can address a non-current object version: a caller with read access can copy an older version into a location they control and then read it. Whether that matters depends on whether any deployment treats "overwritten" as "no longer readable". It arrived with the CopyObject feature (#121), not with this change — flagging it rather than folding a behavior change into a bugfix PR. Happy to open a follow-up that either rejects versionId or threads it into the source authorization.

`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>
@alukach
alukach marked this pull request as ready for review July 27, 2026 19:40
@alukach
alukach merged commit d2f56e6 into main Jul 27, 2026
17 checks passed
@alukach
alukach deleted the fix/copyobject-path-mapping branch July 27, 2026 21:14
alukach added a commit to source-cooperative/data.source.coop that referenced this pull request Jul 27, 2026
`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>
alukach added a commit to source-cooperative/data.source.coop that referenced this pull request Jul 28, 2026
)

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>
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.

1 participant