Skip to content

fix(s3): judge the BucketOwnerEnforced exception against the bucket owner - #2581

Merged
vieiralucas merged 4 commits into
mainfrom
worktree-s3-boe-owner-id
Sep 29, 2026
Merged

vieiralucas merged 4 commits into
mainfrom
worktree-s3-boe-owner-id

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #2565, found by reviewing that PR's own follow-up commit after it had merged. Two defects, one user-visible.

The BucketOwnerEnforced exception judged the wrong owner

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 expressed in the XML format" included, per CopyObjectRequest$ACL / PutObjectRequest$ACL in the vendored model. #2565 correctly narrowed the check to the ACL the request names, but compared the grantee against the caller rather than the bucket owner.

Those coincide for a bucket the caller created (create_bucket sets acl_owner_id = req.account_id), so the common path was right. They diverge for a bucket persisted by another account: the loader hydrates every persisted bucket into the configured default account (main.rs) while s3_bucket_from_snapshot keeps each bucket's own stored acl_owner_id. On such a bucket, under BucketOwnerEnforced:

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

The resolver now asks the bucket for its acl_owner_id, falling back to the caller when the bucket is not in memory -- that is the ordering where ACL headers are validated before the operation reports the missing bucket, and it preserves today's behavior there.

The test asserted the bug. It granted full control to 123456789012 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.

Reverting the blank x-amz-acl leniency

The same #2565 follow-up made a present-but-empty x-amz-acl count as absent on the object-write paths. That was wrong:

  • "" is not a member of com.amazonaws.s3#ObjectCannedACL, and nothing in the model supports treating it as "no ACL asked for";
  • main answered 400 InvalidArgument for it on PutObject and CreateMultipartUpload before fix(s3): honor the ACL headers on CopyObject, and share one resolver #2565 -- only CopyObject changed, because it had ignored ACL headers entirely;
  • PutObjectAcl, PutBucketAcl and CreateBucket all still answer 400.

So the leniency made one operation disagree with four rather than fixing a regression. The x-amz-grant-* precedent cited for it is a different case: an empty value there names no grantee, whereas an empty x-amz-acl names an invalid ACL. Its test now pins the agreement across all the paths instead of the exception. (The copy of this leniency that had been added to #2566 was reverted there for the same reason.)

Also

  • 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 that read as though it were protecting the predicate.
  • Docs: the "ACL ownership modes" bullet still said BucketOwnerEnforced "disables ACLs entirely (all ACL writes rejected)", which the exception falsified.

Surface sync

  • Reference docs: website/content/docs/services/s3.md, the bullet above.
  • SDKs / README / website copy / LLM-facing content / machine metadata: no change. No new or changed API surface, and no service, operation or variant count movement (probe re-run below), so the repo description needs no edit either.

Test plan

  • cargo clippy --workspace --all-targets -- -D warnings -- clean
  • cargo nextest run -p fakecloud-s3 -- 488 pass
  • cargo nextest run -p fakecloud-e2e --test s3 --test s3_persistence --test s3_anonymous_access -- 126 pass
  • cargo nextest run -p fakecloud-conformance --test s3 -- 51 pass
  • conformance -- run --services s3 -- 4040/4068 variants, unchanged

Both fixes verified failing without them:

  • restoring the account_id comparison fails bucket_owner_full_control_is_accepted_when_ownership_disables_acls
  • restoring the blank filter fails a_blank_acl_header_is_rejected_on_every_acl_setting_path

Summary by cubic

Fixes the BucketOwnerEnforced object-write exception being judged against the caller instead of the bucket owner, and reverts an inconsistent leniency for blank x-amz-acl headers.

  • On buckets persisted by another account, grants to the caller were accepted and grants to the actual owner refused; the check now resolves the bucket's stored acl_owner_id (looked up only when the request names an ACL), falling back to the caller when the bucket isn't in memory. The same owner id now also builds the resolved grants, which previously used the caller's.
  • The test now pins both directions against the owner, and a grant to anyone else is AccessControlListNotSupported.
  • Reverts treating a blank x-amz-acl as absent on the object-write paths, restoring the 400 InvalidArgument and keeping those paths consistent with PutObjectAcl, PutBucketAcl, and CreateBucket.
  • Drops a dead !g.is_empty() guard and corrects the docs bullet that claimed BucketOwnerEnforced rejects all ACL writes and that reads return owner-only.

Written for commit 22e73e6. Summary will update on new commits.

Review in cubic

…wner

Follow-up to #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 #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
Review of the commit before this one:

- The new doc block landed INSIDE `bucket_owner_enforced`'s rustdoc, so
  `bucket_acl_owner_id` was documented as "Returns true when the bucket has
  ObjectOwnership=BucketOwnerEnforced" -- for a function returning
  `Option<String>` -- and `bucket_owner_enforced` was left undocumented. Both now
  carry their own docs, and `bucket_acl_owner_id`'s says why the `None` fallback
  is inert rather than merely tolerable: every consumer pairs it with
  `bucket_owner_enforced`, which returns false on exactly the same two misses, so
  the fallback value is never what an answer turns on.

- The owner was looked up on EVERY object write and discarded whenever the request
  carried no ACL, which is the overwhelming majority -- an extra acquisition of
  the global read lock on the hot path. It is now looked up only when the request
  actually names an ACL.

- That lookup also now feeds the resolved grants, not just the exception check.
  `requested` was still built with the CALLER's id while the write paths store
  grants under the bucket's owner. Nothing turned on it (`grants_are_public` reads
  only `grantee_uri`), so it was a latent trap rather than a bug -- but deriving
  the owner two ways in one function is exactly how the original defect happened.

- The docs line rewritten last commit kept a claim nothing implements: "reads
  return owner-only". No read path consults the ownership setting --
  `GetBucketAcl` and `GetObjectAcl` return the stored grants verbatim -- so an ACL
  set before the mode was turned on is still reported. The bullet now says writes
  are rejected, reads are not reinterpreted, and that AWS differs there.

Re-verified after the restructure: forcing the comparison back to the caller fails
`bucket_owner_full_control_is_accepted_when_ownership_disables_acls`; with it in
place, 488 unit / 126 e2e / 51 conformance pass and the probe holds at 4040/4068.
vieiralucas added a commit that referenced this pull request Sep 29, 2026
`s3:x-amz-bucket-object-lock-enabled` is not an AWS condition key. S3's
object-lock keys are `s3:object-lock-mode`, `s3:object-lock-legal-hold`,
`s3:object-lock-remaining-retention-days` and `s3:object-lock-retain-until-date`,
all about an object's retention rather than a bucket's creation; there is no key
for the `x-amz-bucket-object-lock-enabled` header. The vendored model agrees as
far as it can: it carries that string only as the HTTP header, and the one
`s3:x-amz-*` condition key it names anywhere is `s3:x-amz-metadata-directive`.

Emitting it inverted the point of this PR. A policy author writing
`Deny CreateBucket unless s3:x-amz-bucket-object-lock-enabled == true` would see it
work against fakecloud and silently do nothing against AWS -- the same
"guardrail that matches nothing" this PR exists to remove, pointed the other way.
The key, its `security.md` row and its assertion are gone; the test now asserts it
is NOT emitted. `s3:x-amz-object-ownership` stays: AWS defines that one, for
CreateBucket. The object-lock PERMISSIONS requirement is untouched -- that comes
from the model's own Permissions text, not from a condition key.

Three comments that were false, one of them load-bearing:

- The blank-`x-amz-acl` skip justified itself with "the write paths treat a
  present-but-empty canned ACL as absent". Only one of the four canned-ACL paths
  did, and #2581 reverts even that, so the justification would have aged into a
  lie. It now rests on what is actually invariant -- consistency with the other
  key families -- and says plainly that three paths answer 400 for a blank value,
  so nobody reads the skip as "blank is accepted".
- `iam_actions_for`'s blank comment claimed the `x-amz-acl` branch skipped blanks.
  It did not; a blank merely resolved to owner-only because `canned_acl_grants`
  falls through to the owner for anything unrecognized. Now filtered explicitly,
  so an arm that ever resolved an unknown value past the owner cannot silently
  start demanding `s3:PutBucketAcl` for an empty header.
- The `BUCKET_CANNED_ACLS` doc said `bucket-owner-full-control` is "not even a
  legal bucket ACL" while the constant it sits beside accepts it. The accurate
  statement is that the model's `BucketCannedACL` omits it, so the bucket-side
  question never has that answer -- the header is still accepted and resolves to
  the owner's FULL_CONTROL.

Also: the `security.md` ownership row claimed a `CreateBucket` gate the emitter
does not implement (it is populated from the header on any request, like the rows
above it). It now says so, and why AWS scopes the key to CreateBucket anyway.

One correction to the previous commit message: it claimed an unpopulated condition
key safe-fails every non-`IfExists`, non-`ForAllValues` operator. The
`ForAllValues` carve-out applies only to a key the service POPULATED with an empty
value list; a key that was never populated safe-fails regardless of qualifier.
@vieiralucas
vieiralucas merged commit 2bdd4e6 into main Sep 29, 2026
157 checks passed
@vieiralucas
vieiralucas deleted the worktree-s3-boe-owner-id branch September 29, 2026 15:01
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