fix(s3): judge the BucketOwnerEnforced exception against the bucket owner - #2581
Merged
Merged
Conversation
…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.
…loud into worktree-s3-boe-owner-id
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BucketOwnerEnforcedbucket 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, perCopyObjectRequest$ACL/PutObjectRequest$ACLin 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_bucketsetsacl_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) whiles3_bucket_from_snapshotkeeps each bucket's own storedacl_owner_id. On such a bucket, underBucketOwnerEnforced: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
123456789012whileseed_bucketowns 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) isAccessControlListNotSupported.Reverting the blank
x-amz-aclleniencyThe same #2565 follow-up made a present-but-empty
x-amz-aclcount as absent on the object-write paths. That was wrong:""is not a member ofcom.amazonaws.s3#ObjectCannedACL, and nothing in the model supports treating it as "no ACL asked for";mainanswered400 InvalidArgumentfor it onPutObjectandCreateMultipartUploadbefore fix(s3): honor the ACL headers on CopyObject, and share one resolver #2565 -- onlyCopyObjectchanged, because it had ignored ACL headers entirely;PutObjectAcl,PutBucketAclandCreateBucketall 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 emptyx-amz-aclnames 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
!g.is_empty()guard that could never be false --resolved_grant_headerserrors on an empty clause set rather than returning one -- and that read as though it were protecting the predicate.BucketOwnerEnforced"disables ACLs entirely (all ACL writes rejected)", which the exception falsified.Surface sync
website/content/docs/services/s3.md, the bullet above.Test plan
cargo clippy --workspace --all-targets -- -D warnings-- cleancargo nextest run -p fakecloud-s3-- 488 passcargo nextest run -p fakecloud-e2e --test s3 --test s3_persistence --test s3_anonymous_access-- 126 passcargo nextest run -p fakecloud-conformance --test s3-- 51 passconformance -- run --services s3-- 4040/4068 variants, unchangedBoth fixes verified failing without them:
account_idcomparison failsbucket_owner_full_control_is_accepted_when_ownership_disables_aclsa_blank_acl_header_is_rejected_on_every_acl_setting_pathSummary by cubic
Fixes the
BucketOwnerEnforcedobject-write exception being judged against the caller instead of the bucket owner, and reverts an inconsistent leniency for blankx-amz-aclheaders.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.AccessControlListNotSupported.x-amz-aclas absent on the object-write paths, restoring the 400InvalidArgumentand keeping those paths consistent withPutObjectAcl,PutBucketAcl, andCreateBucket.!g.is_empty()guard and corrects the docs bullet that claimedBucketOwnerEnforcedrejects all ACL writes and that reads return owner-only.Written for commit 22e73e6. Summary will update on new commits.