fix(copy): authorize a versioned copy-source against its version - #129
Conversation
|
Claude finished @alukach's task in 2m 21s —— View job
✅ No blocking issues — safe to merge. The fix closes the hole cleanly: One non-blocking documentation nit:
Simplify (ponytail) Nothing to cut — the diff is a tight, minimal fix. The 💰 Estimated review cost: $1.01 · 2m21s · 35 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-129.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-129.development-seed.workers.dev
|
|
Both findings are correct. Fixed in f2e1e6f. The main findingVerified before acting: I fixed it differently from the suggested snippet, and it's worth saying why. A hardcoded 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 Consequences, stated rather than buried:
Three tests, mutation-checked (they fail if SimplifyAgreed — #130 has been rebased on this and updated: a plain versioned |
|
Fixed in dbde69c, and thanks — this was more than a stale sentence. That paragraph was supposed to have been rewritten in f2e1e6f alongside the The paragraph now says what ships:
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 |
`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>
dbde69c to
d5f07bd
Compare
) 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
x-amz-copy-sourcemay carry?versionId=, and that version is faithfully copied. But the syntheticGetObjectthe gateway builds to authorize the source carried no version — and a plainGETthrough the proxy drops?versionId=entirely, serving the current object.That combination made
CopyObjectthe 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.rs—S3Operation::GetObjectgainsversion: Option<String>: the version the backend read will actually return,Nonefor current. The doc comment states the invariant that keeps the field meaningful and tells registries to deny aSome(_)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 carriessrc_version, so the read that gets authorized is the read that gets performed.crates/core/src/api/request.rs— a plainGETleavesversion: Noneeven 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 authorizev1while 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_versionis not threaded through (version: Nonein its place):copy_source_version_reaches_the_source_authorization— the registry seesSome("v42")on the source readregistry_can_deny_a_versioned_copy_source— a non-version-aware policy can refuse, and the refusal precedes any backend call, so no versioned bytes moveunversioned_copy_source_authorizes_an_unversioned_read— an ordinary copy is unaffected by a version-denying policyplain_get_with_version_id_authorizes_an_unversioned_read— pins the deliberateNoneon the read pathcargo test(full workspace, all green)cargo clippy --all-targets(clean; one pre-existing warning in an untouched test helper)cargo fmtcargo check -p multistore-cf-workers --target wasm32-unknown-unknownNotes for the reviewer
fix/copyobject-path-mapping), which it shares test scaffolding with. Review or merge that one first; I'll rebase ontomainif you'd rather take this independently.S3Operation::GetObjectbreaks external construction sites. Six in-tree sites updated; downstream custom resolvers (e.g. Source Cooperative) will needversion: Noneadded.GETwith?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