Skip to content

fix(copy): authorize a versioned copy-source against its version - #129

Merged
alukach merged 3 commits into
mainfrom
fix/copy-source-version-authz
Jul 27, 2026
Merged

fix(copy): authorize a versioned copy-source against its version#129
alukach merged 3 commits into
mainfrom
fix/copy-source-version-authz

Conversation

@alukach

@alukach alukach commented Jul 27, 2026

Copy link
Copy Markdown
Member

What I'm changing

x-amz-copy-source may carry ?versionId=, and that version is faithfully copied. But the synthetic GetObject the gateway builds to authorize the source carried no version — and a plain GET through the proxy drops ?versionId= entirely, serving the current object.

That combination made CopyObject the only operation able to address a non-current object version, and it did so without the registry ever being told which version. A caller with read access on a product could copy an older version into a location they control and read it back. For a deployment that treats "overwritten" as "no longer readable", there was no way to refuse: the version never reached the policy.

Found while reviewing the authorization path for #128. It arrived with the CopyObject feature (#121), not with that PR, so it is split out here rather than folded into a bugfix.

How I did it

  • crates/core/src/types.rsS3Operation::GetObject gains version: Option<String>: the version the backend read will actually return, None for current. The doc comment states the invariant that keeps the field meaningful and tells registries to deny a Some(_) they do not recognize rather than ignore it — it names bytes that are otherwise unreachable through the proxy.
  • crates/core/src/proxy.rs — the copy's source authorization carries src_version, so the read that gets authorized is the read that gets performed.
  • crates/core/src/api/request.rs — a plain GET leaves version: None even when the client sends ?versionId=. This is deliberate: 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. The field describes the read, not the request shape.
  • docs/reference/operations.md — documents both halves.

Alternative considered

Rejecting ?versionId= on a copy-source outright (501) would also close the hole and is a smaller diff, but it removes a working capability and still leaves version-scoped policy inexpressible. Threading it through keeps the capability and puts the decision where it belongs — with the registry.

Test plan

Four tests. Verified the two that pin the hole fail when src_version is not threaded through (version: None in its place):

  • copy_source_version_reaches_the_source_authorization — the registry sees Some("v42") on the source read

  • registry_can_deny_a_versioned_copy_source — a non-version-aware policy can refuse, and the refusal precedes any backend call, so no versioned bytes move

  • unversioned_copy_source_authorizes_an_unversioned_read — an ordinary copy is unaffected by a version-denying policy

  • plain_get_with_version_id_authorizes_an_unversioned_read — pins the deliberate None on the read path

  • cargo test (full workspace, all green)

  • cargo clippy --all-targets (clean; one pre-existing warning in an untouched test helper)

  • cargo fmt

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

Notes for the reviewer

  • Stacked on fix(copy): resolve CopyObject sources on path-mapped and OIDC deployments #128 (fix/copyobject-path-mapping), which it shares test scaffolding with. Review or merge that one first; I'll rebase onto main if you'd rather take this independently.
  • Breaking: adding a field to S3Operation::GetObject breaks external construction sites. Six in-tree sites updated; downstream custom resolvers (e.g. Source Cooperative) will need version: None added.
  • Not addressed here: a plain GET with ?versionId= still silently returns the current object instead of erroring. That is a read-path correctness question rather than an authorization one, and changing it would alter behavior for existing clients — happy to take it separately if you want it.

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

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


  • Gather context (diff, changed files)
  • Review diff for correctness/security/best-practice issues
  • Apply ponytail-review for over-engineering
  • Post final review

✅ No blocking issues — safe to merge.

The fix closes the hole cleanly: src_version now flows from CopyObject into the synthetic GetObject used to authorize the source (proxy.rs:1084-1087), S3Operation::action() maps version: Some(_) to a distinct Action::GetObjectVersion (types.rs:472-475), and auth::authorize's anonymous-access branch only ever grants GetObject | HeadObject | ListBucket (authorize.rs:35-40) — so a versioned read is deny-by-default for both anonymous and scoped callers unless get_object_version is explicitly listed. Traced all four call sites of S3Operation::GetObject { .. } construction (types.rs, proxy.rs, api/request.rs, authorize.rs) — all updated, compiler-enforced by the field addition. The plain-GET-ignores-?versionId= behavior is unchanged and correctly left out of scope.

One non-blocking documentation nit:

  • docs/configuration/roles.md:95 — the new row describes get_object_version as covering "GET/copy of a specific object version," but today only the copy-source read exercises this action; a plain GET with ?versionId= still silently serves the current object and never reaches this action (as the PR body itself notes under "Not addressed here"). Worth a parenthetical ("currently only exercised by copy-source reads; plain GET does not yet honor ?versionId=") so a reader configuring roles today doesn't infer that granting this action enables version-scoped GET.

Simplify (ponytail)

Nothing to cut — the diff is a tight, minimal fix. The RecordingRegistry test-helper additions (versions field, deny_versioned_reads bool) are proportionate to the four new tests they support, Action::GetObjectVersion isn't a speculative abstraction (it mirrors a real S3 action and is the mechanism that makes the fix enforceable rather than advisory), and no new dependencies or reinvented stdlib are present.


💰 Estimated review cost: $1.01 · 2m21s · 35 turns

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

  • Date: 2026-07-27T21:47:56Z
  • Commit: c884d03

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

  • Date: 2026-07-27T21:47:56Z
  • Commit: c884d03

@alukach

alukach commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Both findings are correct. Fixed in f2e1e6f.

The main finding

Verified before acting: static-config/src/lib.rs:215 does call auth::authorize, and that function matches on action + bucket + prefix with no reference to version. So the doc I added told registries to deny a Some(_) they don't recognize while the reference implementation shipped in this repo did exactly the ignoring it warned against. The scenario in the description was still open on the bundled registry. Good catch.

I fixed it differently from the suggested snippet, and it's worth saying why. A hardcoded return Err(AccessDenied) for any versioned read closes the hole, but it also makes version-scoped reads unreachable on the bundled registry — no configuration could enable them. That is fine while CopyObject is the only producer of a versioned read, but #130 makes plain GET/HEAD honor ?versionId=, and a blanket denial would land that feature dead on arrival for exactly the deployments the crate is meant to serve.

Instead a version-scoped read now resolves to its own action:

S3Operation::GetObject { version: None, .. } => Action::GetObject,
S3Operation::GetObject { version: Some(_), .. } => Action::GetObjectVersion,

The existing action match in authorize then does the work — a scope granting get_object doesn't match, so the default is deny, and no new branch is needed. This mirrors S3's own split between s3:GetObject and s3:GetObjectVersion, which exists for this reason: reading what an object used to be is a distinct privilege from reading what it is. And unlike a hardcoded rejection, a deployment that wants versioned reads can grant get_object_version.

Consequences, stated rather than buried:

  • Anonymous callers can never read a previous version. The anonymous grant in authorize covers GetObject/HeadObject/ListBucket only, and GetObjectVersion is deliberately not added. A public bucket serving previous versions anonymously needs a custom registry.
  • Existing configs keep working. They simply don't carry the new action, which is the safe direction — no config silently gains a capability.

Three tests, mutation-checked (they fail if action() maps versioned reads back to GetObject): a get_object holder is denied a versioned read, a get_object_version holder is allowed inside their prefix and denied outside it, and anonymous is denied on an anonymous-readable bucket. Also updated the action table in docs/configuration/roles.md and the copy section in docs/reference/operations.md.

Simplify

Agreed — VersionRecordingRegistry duplicated the whole trait impl to capture one field. Merged into RecordingRegistry as a versions: Arc<Mutex<Vec<Option<String>>>>, and the second type is gone.

#130 has been rebased on this and updated: a plain versioned GET now authorizes as get_object_version too, which is documented there.

Base automatically changed from fix/copyobject-path-mapping to main July 27, 2026 21:14
@alukach

alukach commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Fixed in dbde69c, and thanks — this was more than a stale sentence.

That paragraph was supposed to have been rewritten in f2e1e6f alongside the Action::GetObjectVersion change. The edit silently applied nowhere: I wrote the replacement against wording the file didn't hold, so it matched nothing and passed without complaint. roles.md got its table row, operations.md kept the original text, and the result read exactly as you describe — advice to registry implementors for a rule the bundled policy now enforces itself. Same miss on #130, which had inherited the paragraph; both are corrected, and I checked for other copies of the old framing (none).

The paragraph now says what ships:

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

It links the roles action table, so the two halves you noted were documented separately now point at each other.

#130 rebased on top; its copy paragraph carries the same text, and its "Version-scoped reads" section already stated the get_object_version requirement for plain reads. Full suite green on both (285 / 290).

alukach and others added 3 commits July 27, 2026 14:47
`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<String>` —
  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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@alukach
alukach force-pushed the fix/copy-source-version-authz branch from dbde69c to d5f07bd Compare July 27, 2026 21:47
@alukach
alukach marked this pull request as ready for review July 27, 2026 21:57
@alukach
alukach merged commit 8261ebe into main Jul 27, 2026
17 checks passed
@alukach
alukach deleted the fix/copy-source-version-authz branch July 27, 2026 21:57
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