Skip to content

fix(s3): authorize a configuring CreateBucket per setting, and populate the ACL condition keys - #2566

Merged
vieiralucas merged 8 commits into
mainfrom
worktree-s3-create-iam-actions
Sep 29, 2026
Merged

vieiralucas merged 8 commits into
mainfrom
worktree-s3-create-iam-actions

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

CreateBucket was authorized as s3:CreateBucket alone (plus s3:TagResource for a tag set), even though it applies x-amz-acl / x-amz-grant-*, x-amz-object-ownership and object-lock enablement. On AWS each of those needs its own permission.

This got worse when #2558 made those settings persist: a principal holding only s3:CreateBucket could create a public-read bucket that used to lose the grant on restart and now keeps it — a transient gap became durable over-permission.

Second of the three gaps documented as out of scope in #2558.

What it does

The create returns one action per setting the request configures, following the Permissions section of CreateBucket in the vendored Smithy model:

Request carries Also required
CreateBucketConfiguration tag set s3:TagResource
an ACL granting beyond the owner s3:PutBucketAcl
x-amz-object-ownership s3:PutBucketOwnershipControls
x-amz-bucket-object-lock-enabled: true s3:PutBucketObjectLockConfiguration + s3:PutBucketVersioning

Dispatch already requires every returned action to be allowed, so a principal with only s3:CreateBucket can still create a plain bucket.

The ACL rule follows that documentation precisely rather than keying on header presence: an ACL set to public-read, public-read-write, authenticated-read "or any other custom ACLs" needs s3:PutBucketAcl, while "if you set the ACL to private, or if you don't specify any ACLs, only the s3:CreateBucket permission is required". It's judged by the grants the request resolves to, so a least-privilege caller that always sends --acl private — Terraform's legacy acl attribute, SDK wrappers that always set one — is not refused where real S3 succeeds.

Condition keys

s3:x-amz-acl and the s3:x-amz-grant-* family are populated now. They're how a policy pins down which ACL a write may ask for, and while they were never emitted a guardrail like

{"Effect":"Allow","Action":"s3:CreateBucket","Resource":"*",
 "Condition":{"StringEquals":{"s3:x-amz-acl":"private"}}}

matched nothing and silently authorized exactly what it was written to block. They're emitted from the headers on any request that carries them, rather than gated on an action list that would drift. An absent header emits nothing, so a condition on it safe-fails to "does not apply" — and a blank x-amz-grant-* header emits nothing either, for the same reason has_grant_headers treats it as absent: the request asks for no grant, and a present key would make a Null-based guardrail deny it.

Test plan

  • Unit (481 passing): one action per setting, including private adding none, a false object-lock header adding none, and all four settings at once in a single authorization set; condition keys populated from x-amz-acl and a grant header, and absent headers emitting nothing.
  • E2E (iam_enforcement): a least-privilege principal refused for a public-read create and unblocked by adding s3:PutBucketAcl, with the resulting bucket verified public-read; --acl private accepted with only s3:CreateBucket; the object-lock pair refused then unblocked; and a condition-keyed policy admitting private while denying public-read.
  • Suites: fakecloud-s3 481 passed; e2e iam_enforcement + iam_enforcement_abac 65 passed; e2e s3 + s3_persistence 119 passed; fakecloud-conformance --test s3 51 passed. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.
  • Conformance exposure: none — iam_actions_for / iam_condition_keys_for are only reached when IAM is enabled, and the conformance runner never enables --iam.

Surface sync

  • Reference docs — website/content/docs/reference/security.md adds s3:x-amz-acl and the s3:x-amz-grant-* family to the supported service-condition-key table; website/content/docs/services/s3.md documents the per-setting permissions, including the private exemption.
  • SDKs / introspection — no change: AWS authorization behavior, not a /_fakecloud/* surface.
  • Counts / metadata — unchanged: no new operation, service or conformance variant.

Summary by cubic

CreateBucket was authorized by s3:CreateBucket alone, so a principal holding only that permission could configure ACLs, object ownership, or object lock at create time. Since those settings persist, that transient gap became durable over-permission. The create now requires one action per setting it configures, and the associated s3:x-amz-* condition keys are populated from headers.

  • Required actions follow the model's CreateBucket permissions: s3:PutBucketAcl, s3:PutBucketOwnershipControls, and s3:PutBucketObjectLockConfiguration plus s3:PutBucketVersioning for object lock; --acl private and no-ACL creates still need only s3:CreateBucket.
  • The ACL check and the BucketOwnerEnforced conflict check share one acl_reaches_past_owner helper, so aws-exec-read no longer escapes the s3:PutBucketAcl requirement; on its own operations the implied actions carry none of the create's tags.
  • The create's requested tags populate aws:RequestTag for every implied action, so a tag-scoped guardrail that permits the create doesn't then deny the ACL or object-lock action it implies.
  • Condition keys emit from any header that carries them (s3:x-amz-acl, the s3:x-amz-grant-* family, s3:x-amz-object-ownership); absent or blank values emit nothing, so a Null-based guardrail can't deny a request that asks for nothing. The object-lock header emits no key, since AWS defines none for it; that setting is gated by permissions only.
  • Reference docs and the S3 service page document the per-setting permissions and the new condition keys.

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

Review in cubic

…te the ACL condition keys

CreateBucket was authorized as s3:CreateBucket alone (plus s3:TagResource for a
tag set), even though it applies x-amz-acl / x-amz-grant-*,
x-amz-object-ownership and object-lock enablement. On AWS each of those needs
its own permission. This got worse when those settings started persisting: a
principal holding only s3:CreateBucket could create a `public-read` bucket that
used to lose the grant on restart and now keeps it, so a transient gap became
durable over-permission.

The create now returns one action per setting the request configures, following
the permissions section of CreateBucket in the vendored model --
s3:PutBucketAcl, s3:PutBucketOwnershipControls, and
s3:PutBucketObjectLockConfiguration plus s3:PutBucketVersioning (object lock
turns versioning on, so AWS wants both). Dispatch already requires every
returned action to be allowed, so a principal with only s3:CreateBucket can
still create a plain bucket.

The s3:x-amz-acl and s3:x-amz-grant-* condition keys are also populated now.
They are how a policy pins down WHICH ACL a write may ask for, and while they
were never emitted a guardrail like
`Deny s3:CreateBucket when s3:x-amz-acl != private` matched nothing and silently
authorized exactly what it was written to block. They are emitted from the
headers on any request that carries them rather than gated on an action list
that would drift, and an absent header emits nothing so a condition on it
safe-fails to "does not apply".

E2E covers the least-privilege principal being refused and then unblocked for
both the ACL and the object-lock cases, and a condition-keyed policy admitting
`private` while denying `public-read`.

The ACL rule follows that documentation precisely: an ACL set to public-read,
public-read-write, authenticated-read "or any other custom ACLs" needs
s3:PutBucketAcl, while "if you set the ACL to private, or if you don't specify
any ACLs, only the s3:CreateBucket permission is required". It is judged by the
grants the request resolves to, so a least-privilege caller that always sends
`--acl private` -- Terraform's legacy `acl` attribute, SDK wrappers that always
set one -- is not refused where real S3 succeeds.

A blank `x-amz-grant-*` header does not emit its condition key, for the same
reason has_grant_headers treats it as absent: the request asks for no grant, and
a present key would make a Null-based guardrail deny it.
…ization

- `aws-exec-read` escaped the new `s3:PutBucketAcl` requirement. Its READ grant
  to the EC2 service's canonical user is not modeled, so it resolves to
  owner-only grants -- while the BucketOwnerEnforced conflict check has always
  treated it as an ACL reaching outside the owner, and CloudFormation sends it
  for `AccessControl: AwsExecRead`. Both sites now share one
  `acl_reaches_past_owner` helper instead of each deriving the answer, which is
  how they came to disagree.

- The four newly required authorizations were evaluated against an EMPTY
  `aws:RequestTag` context. Dispatch rebuilds the context per action, so a
  tag-scoped guardrail that permitted the create then denied the
  `s3:PutBucketAcl` it implies: a 403 on a request AWS allows, since AWS builds
  one request context for the whole request.

- `s3:x-amz-object-ownership` and `s3:x-amz-bucket-object-lock-enabled` are
  populated too. Gating a setting on a permission while its condition key stays
  absent is the same hazard this PR was written to fix: a guardrail that matches
  nothing authorizes exactly what it was written to block.

- A doc comment carrying the `#2553` provenance had been displaced onto the
  wrong test, leaving `s3_create_bucket_with_tags_needs_tag_resource`
  undocumented, and two comments this change falsified still described the old
  behavior.

Tests, each verified failing without its fix: `aws-exec-read` requires
PutBucketAcl; every implied action sees the create's tags (and carries none on
its own operation); the two new condition keys are emitted, and skipped when
blank. Plus an e2e proving a tag-scoped `Deny` that permits the create does not
then deny its ACL.
…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.
`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.
…/fakecloud into worktree-s3-create-iam-actions
@vieiralucas
vieiralucas merged commit ce906a9 into main Sep 29, 2026
157 checks passed
@vieiralucas
vieiralucas deleted the worktree-s3-create-iam-actions branch September 29, 2026 18:36
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