Skip to content

fix(s3): honor the ACL headers on CopyObject, and share one resolver - #2565

Merged
vieiralucas merged 2 commits into
mainfrom
worktree-s3-copyobject-acl
Sep 28, 2026
Merged

vieiralucas merged 2 commits into
mainfrom
worktree-s3-copyobject-acl

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

CopyObject read neither x-amz-acl nor the x-amz-grant-* headers and hardcoded the destination's ACL to the owner's FULL_CONTROL. So copy-object --acl public-read returned 200 and produced a private object — the caller believed it had published the copy. It was also the one object-write path that accepted an ACL for a bucket whose ObjectOwnership disables them, and the only one where --acl pubic-read (a typo) was silently ignored rather than refused.

The destination now takes its ACL from the request. A copy is a new object, so an unspecified ACL stays the default owner grant rather than inheriting whatever the source carried — that case has its own test, being the one most easily broken by "just copy the source's grants".

This was the first of the three gaps documented as out of scope in #2558.

One resolver instead of three copies

The three object-write paths each kept their own copy of the same four checks, and the order is load-bearing: the canned value is validated before the grant headers are resolved, and both before the "named two ways" rejection, so a request carrying an unresolvable grant answers InvalidArgument rather than InvalidRequest. The conformance probe populates the canned member and the grant members together, and only the first of those codes is in the S3 error allowlist — so a path that reordered its own copy would have silently dropped probe variants.

S3Service::resolve_write_acl_headers now holds that order once, and PutObject, CreateMultipartUpload and CopyObject all call it. Having one copy let two divergences be fixed in one place rather than three:

  • AWS accepts an ACL granting nothing beyond the bucket owner against a BucketOwnerEnforced bucket — spelled x-amz-acl: bucket-owner-full-control or, in the AWS wording, "an equivalent form of this ACL", so an explicit full-control grant to the owner counts too. All three paths refused both.
  • BlockPublicAcls was enforced only by PutObjectAcl/PutBucketAcl, so put-object --acl public-read stored an AllUsers grant on a bucket that blocks exactly that, while put-object-acl --acl public-read was refused. AWS refuses both.

One normalization worth flagging: CreateMultipartUpload checked ownership before validating the canned value, so an invalid value on a BucketOwnerEnforced bucket answered AccessControlListNotSupported. It now answers InvalidArgument, matching what PutObject already did — the value is wrong whatever the bucket allows. Both orders are pinned by tests.

Test plan

  • Unit (486 passing): CopyObject honoring the canned header and the grant headers; a copy with no ACL headers staying private even when the source is public-read; CopyObject rejecting an invalid canned value, canned-plus-grants, and an ACL on a BucketOwnerEnforced destination; the owner-only exception accepted both as the canned value and as the equivalent explicit grant, with anything reaching past the owner still refused; BlockPublicAcls refusing a public ACL at write time while private passes; and the multipart error-code ordering pinned in both directions.
  • Verified by reverting: with the destination ACL back to the hardcoded owner grant, both copy_object_honors_* tests fail.
  • Suites: fakecloud-s3 486 passed; e2e s3, s3_anonymous_access, s3_copy_source_if_match, s3_persistence all green (127); fakecloud-conformance --test s3 51 passed; the s3 probe reports 4040/4068 variants, unchanged. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

Surface sync

  • Reference docs — website/content/docs/services/s3.md: the ACL bullet now covers CopyObject, and states the owner-only exception and that it applies to the object-write paths rather than to PutObjectAcl/PutBucketAcl.
  • SDKs / introspection — no change: AWS API behavior, not a /_fakecloud/* surface.
  • Counts / metadata — unchanged: no new operation, service or conformance variant, so conformance-baseline.json, the ops index, the README headline and the repo description all stay as they are.

Summary by cubic

CopyObject now honors the x-amz-acl and x-amz-grant-* headers instead of hardcoding the destination's ACL to the owner's FULL_CONTROL, so copy-object --acl public-read no longer returns 200 while producing a private object. A copy is a new object, so an unspecified ACL stays the default owner grant rather than inheriting the source's.

The three object-write paths (PutObject, CreateMultipartUpload, CopyObject) each kept their own copy of the same four ACL checks; they now share one resolver, resolve_write_acl_headers, which fixed three divergences. On BucketOwnerEnforced buckets the resolver accepts only the one ACL AWS names — the canned bucket-owner-full-control or the equivalent explicit grants giving the owner full control and nobody anything — rather than any ACL that resolves to owner-only grants; private, bucket-owner-read and aws-exec-read are still refused. BlockPublicAcls now refuses a public ACL at write time on all three paths rather than only through PutObjectAcl/PutBucketAcl. CreateMultipartUpload error order is normalized too: an invalid canned value on a BucketOwnerEnforced bucket reports InvalidArgument instead of AccessControlListNotSupported, and a present-but-blank x-amz-acl header is treated as absent, like the x-amz-grant-* family.

Written for commit cfb010f. Summary will update on new commits.

Review in cubic

CopyObject read neither x-amz-acl nor the x-amz-grant-* headers and hardcoded
the destination's ACL to the owner's FULL_CONTROL, so
`copy-object --acl public-read` returned 200 and produced a private object --
the caller believed it had published the copy. It was also the one object-write
path that accepted an ACL for a bucket whose ObjectOwnership disables them, and
the only one where `--acl pubic-read` was silently ignored rather than refused.

The destination now takes its ACL from the request, and a copy is a new object,
so an unspecified ACL stays the default owner grant rather than inheriting
whatever the source carried. That case has its own test, being the one most
easily broken by "just copy the source's grants".

The three object-write paths each kept their own copy of the same four checks,
and the ORDER is load-bearing: the canned value is validated before the grant
headers are resolved, and both before the "named two ways" rejection, so a
request carrying an unresolvable grant answers InvalidArgument rather than
InvalidRequest. The conformance probe populates the canned member and the grant
members together, and only the first of those codes is in the S3 error
allowlist -- so a path that reordered its own copy would have silently dropped
probe variants. resolve_write_acl_headers now holds the order once, and
PutObject, CreateMultipartUpload and CopyObject all call it.

Sharing one resolver also let two divergences be fixed in one place rather than
three:

- AWS accepts an ACL that grants nothing beyond the bucket owner against a
  BucketOwnerEnforced bucket, spelled either `x-amz-acl:
  bucket-owner-full-control` or, in the AWS wording, "an equivalent form of this
  ACL" -- so an explicit full-control grant to the owner is accepted too. All
  three paths refused both.
- BlockPublicAcls was enforced only by PutObjectAcl and PutBucketAcl, so
  `put-object --acl public-read` stored an AllUsers grant on a bucket that blocks
  exactly that while `put-object-acl --acl public-read` was refused. AWS refuses
  both.

One normalization worth noting: CreateMultipartUpload checked ownership before
validating the canned value, so an invalid value on a BucketOwnerEnforced bucket
answered AccessControlListNotSupported. It now answers InvalidArgument, which is
what PutObject already did -- the value is wrong whatever the bucket allows.
Both orders are pinned by tests now.
Review caught a loosening relative to main. The exception judged the grants a
request RESOLVES to, but `canned_acl_grants` collapses several distinct canned
values onto the same owner-only grant, so `private`, `bucket-owner-read` and
`aws-exec-read` all passed a gate that previously refused them -- the last two
only because their real grantees are not modeled.

The model is precise about the one exception: such a bucket "only accept[s] PUT
requests that don't specify an ACL or PUT requests that specify bucket owner
full control ACLs, such as the bucket-owner-full-control canned ACL or an
equivalent form of this ACL expressed in the XML format". So the test is the ACL
the request NAMES: the canned `bucket-owner-full-control`, or explicit grants
giving the owner full control and nobody anything. `private` is neither "no ACL"
nor "bucket owner full control", and AWS refuses it there.

Also: a present-but-blank `x-amz-acl` is now treated as absent, the rule
`has_grant_headers` already applies to the `x-amz-grant-*` family and for the
same reason -- it is what a client sends for an unset config field, and testing
presence alone turned it into a hard 400 on every ACL-accepting write. Extending
that 400 to CopyObject was this PR's doing; the fix removes it from all three.

- the refusal loop covers all four values, so the resolved-shape predicate
  cannot come back unnoticed
- a handler-level test, since the helper-level ones prove nothing about whether
  a write path calls the resolver before storing the object
- the vacuous assertion that echoed back the header the function was handed is
  replaced by one on the grants it resolves to
- docs: the narrowed exception, and the BlockPublicAcls enforcement this PR
  added at write time
@vieiralucas
vieiralucas merged commit 0220c73 into main Sep 28, 2026
157 checks passed
@vieiralucas
vieiralucas deleted the worktree-s3-copyobject-acl branch September 28, 2026 01:39
vieiralucas added a commit that referenced this pull request Sep 28, 2026
…where

The e2e added last commit passed against the bug it was written for. Its Deny
used `StringNotEquals`, and an unpopulated condition key safe-fails EVERY
non-`IfExists`, non-`ForAllValues` operator to false, so the Deny did not apply
on the broken code either and both assertions held with or without the fix. The
claim "verified failing without its fix" was true of the unit test and false of
this one.

`Null` is the one operator that reads an unpopulated key AS null, so it is the
one that can see a per-action context that carries no tags. With the Deny
rewritten that way, the test fails on the unfixed code and passes on the fixed
one -- verified both directions.

Blank values, which the same commit handled in two places out of three:

- `s3:x-amz-acl` was emitted for a present-but-empty header while both
  neighbouring families skipped blanks, and while the write paths treat such a
  header as absent. A `Null`- or `StringEquals`-gated policy therefore denied a
  request that asks for no ACL at all.
- `x-amz-object-ownership` demanded `s3:PutBucketOwnershipControls` on a blank
  value, whose condition key is deliberately not emitted -- so a
  `StringEquals`-gated Allow answered 403 where the handler answers 400.

Also: `security.md` claimed `s3:x-amz-object-ownership` covers
`PutBucketOwnershipControls`, which carries that value in its XML body rather
than a header, so the key is never populated there -- the guardrail the row
promises would match nothing, which is the hazard this PR exists to remove. The
row now says `CreateBucket` and why.

And two comments: the `BUCKET_CANNED_ACLS` doc still said `create_bucket`
special-cases `aws-exec-read` (it is `acl_reaches_past_owner` now, driving both
callers), and `acl_reaches_past_owner` now says explicitly that it is NOT the
same question as the object-write check added by #2565, so the two are not
"unified" later: a BucketOwnerEnforced bucket refuses an ACL granting another
account anything, while a BucketOwnerEnforced bucket accepts an object PUT only
with no ACL or bucket-owner-full-control, which is not even a legal bucket ACL.
vieiralucas added a commit to Sorttech/fakecloud that referenced this pull request Sep 29, 2026
…wner

Follow-up to faiscadev#2565, found by reviewing that PR's own follow-up commit after it
had merged.

A BucketOwnerEnforced bucket accepts an object write that specifies no ACL, or
one that specifies bucket owner full control -- "an equivalent form of this ACL"
included, per the model. The check compared the grantee against the CALLER
instead of the bucket owner. Those coincide for a bucket the caller created, so
the common path was right, and they diverge for a bucket persisted by another
account: the loader hydrates every persisted bucket into the configured default
account while each keeps its own stored `acl_owner_id`. There:

- `x-amz-grant-full-control: id=<caller>` was accepted, storing an object ACL
  that gives the bucket owner NOTHING on a bucket whose whole purpose is that
  the owner controls every object;
- `x-amz-grant-full-control: id=<owner>`, which AWS accepts, was refused.

`S3Store`-side nothing changes; the resolver now asks the bucket for its
`acl_owner_id` and falls back to the caller when the bucket is not in memory,
which is the case where the ACL headers are validated before the operation
reports the missing bucket.

The test asserted the bug. It granted full control to the caller while
`seed_bucket` owns the bucket as "owner", so it passed only because of the wrong
comparison -- fixing the code would have failed it. It now asserts both
directions against the real owner, and a grant to anyone else (the caller
included) is `AccessControlListNotSupported`.

Also reverts the blank `x-amz-acl` leniency from that same commit. `""` is not a
member of `ObjectCannedACL`; `main` answered 400 for it on PutObject and
CreateMultipartUpload before faiscadev#2565, and PutObjectAcl, PutBucketAcl and
CreateBucket all still do. Treating it as "no ACL asked for" on the object-write
paths alone made one operation disagree with four rather than fixing anything,
and the `x-amz-grant-*` precedent cited for it is a different case: an empty
value there names no grantee, not an invalid ACL. Its test now pins the
agreement instead of the exception.

- drops a `!g.is_empty()` guard that could never be false
  (`resolved_grant_headers` errors on an empty clause set rather than returning
  one) and read as though it were load-bearing
- docs: the "ACL ownership modes" bullet still said BucketOwnerEnforced disables
  ACLs entirely, which the exception falsified
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant