Skip to content

feat(s3): carry an optional body on the forwarded request - #87

Merged
alanshaw merged 2 commits into
mainfrom
ash/feat/s3-request-body
Oct 22, 2026
Merged

alanshaw merged 2 commits into
mainfrom
ash/feat/s3-request-body

Conversation

@alanshaw

@alanshaw alanshaw commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Hilt decides some requests on their body. The bucket policy PUT already travels to Hilt with its document on the policy command. DeleteObjects is the other one: an entry naming a version needs s3:DeleteObjectVersion, and only the body says so. This gives the shared forwarded request an optional body for that.

What changes

s3.Request gains a Body field, omitted from both encodings when unset, so a request without one encodes exactly as it did before. The rule for when it is set lives in Hilt, which names the operations whose body decides a permission; Ingot attaches the body for those and no others, so the bulk streams of PutObject and UploadPart never reach it. The field carries its own 8 MiB limit, which holds the largest valid DeleteObjects document: a thousand entries of a kilobyte key expanded up to sixfold by XML escaping, a version id as long, and the conditional fields, about 7.3 MB. Everything else keeps the codecs' 2 MiB.

The follow-ups in Hilt and Ingot pin this.

🤖 Generated with Claude Code

Hilt decides some requests on their body: the bucket policy PUT already
travels with its document, and DeleteObjects needs the version
permission when an entry names a version. The shared request type gains
an optional body for the operations Hilt names, omitted from the
encoding otherwise, so a request without one encodes as before. The
codec caps it at 2 MiB; a DeleteObjects body is at most about 1.2 MiB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 21, 2026
…ath on it

Hilt now reads a DeleteObjects body and needs s3:DeleteObjectVersion
when an entry names a version. The body travels only for the operations
Hilt's classifier names, so a streaming write's body is never read
here; the fast path checks the version permission from the same
classification and leaves a body the signature does not bind to Hilt,
which refuses it as UnboundBody. Pins libforge to fil-forge/libforge#87
and hilt to fil-forge/hilt#106.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alanshaw
alanshaw requested a balanced review from Copilot October 21, 2026 17:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The size limit rejects valid XML-escaped requests, and both decoders lose explicitly empty bodies.

3 open issues
2 files not reviewed
  • commands/s3/cbor_gen.go: Generated file
  • commands/s3/json_gen.go: Generated file
What changed in this PR

Adds an optional body to forwarded S3 requests so Hilt can authorize operations whose permissions depend on request content.

Changes:

  • Adds Request.Body with omission when unset.
  • Updates CBOR and DAG-JSON codecs with a 2 MiB body limit.
  • Tests body round trips and omission.
File Description
commands/​s3/​types.go Defines the optional body field.
commands/​s3/​json_gen.go Adds DAG-JSON body encoding and decoding.
commands/​s3/​codec_test.go Tests body serialization and omission.
commands/​s3/​cbor_gen.go Adds CBOR body encoding and decoding.

Review effort: Balanced

Comment thread commands/s3/cbor_gen.go
Comment thread commands/s3/json_gen.go
Comment thread commands/s3/types.go Outdated
XML escaping expands a key up to sixfold, and an entry carries a version
id and conditional fields beside it, so a thousand entries can run to
about 7.3 MB. The field gets its own 8 MiB limit; everything else keeps
the codecs' 2 MiB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The previously reported empty-body round-trip issue remains unresolved in both decoders.

2 open issues
1 resolved since last review
2 files not reviewed
  • commands/s3/cbor_gen.go: Generated file
  • commands/s3/json_gen.go: Generated file

Review effort: Balanced

@alanshaw
alanshaw merged commit 59d8bde into main Oct 22, 2026
9 checks passed
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 22, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alanshaw
alanshaw deleted the ash/feat/s3-request-body branch October 22, 2026 08:47
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 22, 2026
A DeleteObjects entry naming a version needs `s3:DeleteObjectVersion` in
S3, and only the request body says so. Hilt classified the request as
`s3:DeleteObject` on the bucket, and Ingot removed the named versions
with no further check, so a key or principal holding the delete
permission alone could permanently remove any version it could name, a
thousand per request. The single-object DELETE with a versionId was
already classified correctly.

### What changes

The body now travels to Hilt for the operations whose body decides a
permission, and for no other. `Operation.ReadsBody` names them, and
today that is DeleteObjects alone; the bucket policy PUT keeps its
document on its own command. Ingot attaches the body for those
operations only, so PutObject and UploadPart never have theirs
forwarded, and Hilt refuses a body on any other operation. The field is
capped at 8 MiB by the codec and here, which holds the largest valid
DeleteObjects document once XML escaping and the per-entry fields are
counted, about 7.3 MB.

Hilt binds the body to the signature before reading it, the same rule as
for headers. A signed payload hash covers it directly. Under an unsigned
payload, a signed `x-amz-checksum-*` or `Content-MD5` header covers it,
which S3 requires on a DeleteObjects and the SDKs send signed. A body
that is missing, unparseable, oversized or bound by neither is refused
as `InvalidBody`.

An entry naming a version, the null version included, adds
`s3:DeleteObjectVersion` to the request's permissions on the bucket,
checked for a service key and for a principal's effective set, and
reported by `RequirementsFor` for Ingot's fast path.

### Behaviour changes to know about

- A key or principal holding `s3:DeleteObject` without
`s3:DeleteObjectVersion` can no longer delete named versions through
DeleteObjects. Plain entries are unaffected.
- Hilt decides the whole request: a batch mixing plain and versioned
entries is refused outright when the version permission is missing,
where S3 would delete the plain entries and refuse the rest inside one
response. This fails closed and keeps the decision in one place; a
client wanting the S3 shape splits its batch.
- Ingot must forward the body for DeleteObjects; until it does, Hilt
refuses every DeleteObjects as `InvalidBody`. The Ingot change lands
with this.

Pins libforge main, which carries fil-forge/libforge#87. The IAM itest
scenario `DeleteObjectsVersionNeedsTheVersionAction` is added but was
not run; neither were the checksum-header fallbacks exercised against a
real SDK, only the payload-hash path that the CLI and SDKs use for this
operation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 22, 2026
…ath on it (#299)

Companion to fil-forge/hilt#106 and fil-forge/libforge#87, both merged;
pins Hilt and libforge main.

Hilt now decides a DeleteObjects on its body: an entry naming a version
needs `s3:DeleteObjectVersion` beside `s3:DeleteObject`. Until now Ingot
removed the named versions with no such check, so a key holding the
delete permission alone could remove any version it could name.

### What changes

The request forwarded to Hilt carries its body only for the operations
Hilt's classifier names as deciding on one, which today is
DeleteObjects. Every other request goes without, so PutObject and
UploadPart never have their body read here. Hilt binds the body to the
signature before reading it, and the fast path decides on the same
classification: a version entry is refused locally when the key's cached
set lacks the version permission, and a body the signature does not bind
is left to Hilt, which refuses it as `InvalidBody`. That name joins
`UnsignedHeader` in the error mapping as AccessDenied.

### Behaviour changes to know about

- A DeleteObjects naming a version by a key or principal without
`s3:DeleteObjectVersion` is refused as a whole, where S3 would refuse
only those entries. Plain batches are unaffected.
- A DeleteObjects whose body is not covered by the signature, as the
payload hash or a signed checksum header, is refused. The SDKs and CLI
sign the payload for this operation.

The IAM itest scenario for this lives in Hilt and was not run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

2 participants