Skip to content

feat(s3/bucket): allow tagging, attribute and bucket configuration reads in policies - #86

Merged
alanshaw merged 1 commit into
mainfrom
ash/feat/config-and-tagging-policy-actions
Oct 20, 2026
Merged

alanshaw merged 1 commit into
mainfrom
ash/feat/config-and-tagging-policy-actions

Conversation

@alanshaw

Copy link
Copy Markdown
Member

A bucket policy can now grant these by name: object tagging (s3:GetObjectTagging, s3:PutObjectTagging, s3:DeleteObjectTagging), s3:GetObjectAttributes, and reads of the bucket's configuration (s3:GetBucketAcl, s3:GetBucketCORS, s3:GetBucketLocation, s3:GetBucketObjectLockConfiguration, s3:GetBucketTagging, s3:GetBucketVersioning). The configuration reads are bucket actions, so they take the bucket ARN; the rest take the objects ARN.

Hilt is changing to refuse any request with a subresource it doesn't classify, and to give each operation the gateway serves its own permission. Before that change, a principal reached these operations through s3:ListBucket, s3:GetObject and the plain object writes, so policies need to be able to grant them or principals lose access.

The configuration writes (s3:PutBucketVersioning, s3:PutBucketObjectLockConfiguration, s3:PutBucketTagging) stay out of the allowlist, as the encryption configuration does: each changes the bucket for every principal. Service keys can hold them.

Policies using s3:* pick up the new actions automatically. Policies that list actions need the new ones added.

🤖 Generated with Claude Code

…ads in policies

Hilt is giving each S3 operation the gateway serves its own permission
instead of letting unrecognized subresources fall through to the bare
bucket or object operation. A principal could reach these through
s3:ListBucket, s3:GetObject and the plain object writes, so a policy has
to be able to grant them by name: object tagging, GetObjectAttributes, and
reads of the bucket's ACL, CORS, location, object lock, tagging and
versioning configuration. The configuration writes stay out, since they
change the bucket for every principal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 20, 2026
…ries naming a version

Hilt now authorizes DeleteObjects on s3:DeleteObject, as S3 does, where it
used to classify the request as CreateBucket. S3 also requires
s3:DeleteObjectVersion for an entry that names a version, and those entries
are in the body, where Hilt cannot see them. The IAM service now carries
the key's action sets on the request (reqscope.ActionsKey, the same
per-key cache the fast path reads), and DeleteObjects fails such an entry
with AccessDenied unless the key holds the permission on the bucket.

Bumps hilt to the classification that mirrors the gateway's router
(fil-forge/hilt#102) and libforge to the matching policy actions
(fil-forge/libforge#86). The itest key carries the new permissions behind
INGOT_ITEST_ROUTE_PERMS until the published hilt image knows them, and
TestForgeRoutePermissions covers the refusals through the real hilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alanshaw
alanshaw requested review from pyropy and a balanced review from Copilot October 20, 2026 10:07

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.

🟢 Approval recommended

The focused permission additions and validation tests match the intended scope, with no blocking issues identified.

No open issues 🎉

What changed in this PR

Expands bucket-policy permissions to support Hilt’s operation-specific authorization while keeping bucket configuration writes excluded.

Changes:

  • Allows object tagging, object attributes, and six bucket configuration reads.
  • Classifies configuration reads as bucket-ARN actions.
  • Adds tests for ARN validation and rejected configuration writes.
File Description
commands/​s3/​bucket/​policy_test.go Tests resource restrictions and excluded configuration writes.
commands/​s3/​bucket/​policy_json.go Extends the action allowlist and bucket-action classification.

Review effort: Balanced

@pyropy pyropy 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.

:shipit:

alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 20, 2026
classifyRequest refuses a query naming two subresources or an unsupported
one, and classifies the supported subresources with no operation of their
own as the plain bucket or object operation. Those include writes:
PUT /b?versioning and ?object-lock ran on a CreateBucket authorization,
which skips the bucket scope check, so a key holding s3:CreateBucket could
change any bucket's versioning or object lock.

Each supported subresource now selects its operation per method, as the
gateway's router does (bucketRoutes, objectRoutes): bucket location, ACL,
CORS, policy status, tagging, versioning and object lock configuration, and
object tagging and attributes, each with its own S3 permission. A route the
gateway has that Hilt does not authorize (PUT ?cors, PUT ?acl, object ACLs,
uploadId alone on a PUT) is refused, as is a POST naming no route. A
subresource the router does not route on for the method is the plain
operation there, as in the router, and a HEAD is HeadBucket or HeadObject.

The bucket configuration writes and the policy status are service-key
only, like the policy and encryption. The new reads and the object tagging
writes are policy actions (libforge pinned to fil-forge/libforge#86).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alanshaw
alanshaw merged commit b6aeeaf into main Oct 20, 2026
9 checks passed
@alanshaw
alanshaw deleted the ash/feat/config-and-tagging-policy-actions branch October 20, 2026 10:39
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 20, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 20, 2026
Hilt moves to the hilt#102 head that pins the same libforge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 20, 2026
…ries naming a version

Hilt now authorizes DeleteObjects on s3:DeleteObject, as S3 does, where it
used to classify the request as CreateBucket. S3 also requires
s3:DeleteObjectVersion for an entry that names a version, and those entries
are in the body, where Hilt cannot see them. The IAM service now carries
the key's action sets on the request (reqscope.ActionsKey, the same
per-key cache the fast path reads), and DeleteObjects fails such an entry
with AccessDenied unless the key holds the permission on the bucket.

Bumps hilt to the classification that mirrors the gateway's router
(fil-forge/hilt#102) and libforge to the matching policy actions
(fil-forge/libforge#86). The itest key carries the new permissions behind
INGOT_ITEST_ROUTE_PERMS until the published hilt image knows them, and
TestForgeRoutePermissions covers the refusals through the real hilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 20, 2026
Hilt moves to the hilt#102 head that pins the same libforge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 20, 2026
classifyRequest refuses a query naming two subresources or an unsupported
one, and classifies the supported subresources with no operation of their
own as the plain bucket or object operation. Those include writes:
PUT /b?versioning and ?object-lock ran on a CreateBucket authorization,
which skips the bucket scope check, so a key holding s3:CreateBucket could
change any bucket's versioning or object lock.

Each supported subresource now selects its operation per method, as the
gateway's router does (bucketRoutes, objectRoutes): bucket location, ACL,
CORS, policy status, tagging, versioning and object lock configuration, and
object tagging and attributes, each with its own S3 permission. A route the
gateway has that Hilt does not authorize (PUT ?cors, PUT ?acl, object ACLs,
uploadId alone on a PUT) is refused, as is a POST naming no route. A
subresource the router does not route on for the method is the plain
operation there, as in the router, and a HEAD is HeadBucket or HeadObject.

The bucket configuration writes and the policy status are service-key
only, like the policy and encryption. The new reads and the object tagging
writes are policy actions (libforge pinned to fil-forge/libforge#86).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 20, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/hilt that referenced this pull request Oct 20, 2026
…102)

Stacked on #101. Pins libforge to its merge of fil-forge/libforge#86.

#101 refuses a query naming two subresources (`ConflictingQuery`) or an
unsupported one, and classifies DeleteObjects. It classifies the
remaining supported subresources as the plain bucket or object
operation. Those include writes: `PUT /b?versioning` and `?object-lock`
run on a **CreateBucket** authorization, which skips the bucket scope
check, so a key holding `s3:CreateBucket` can change any bucket's
versioning or object lock. This PR gives each of them its own operation
and permission.

### What changes

Each supported subresource now selects its operation per method, as the
gateway's router does (`bucketRoutes`, `objectRoutes`, alongside #101's
`subresources` vocabulary):

| Operation | Permission |
|---|---|
| GetBucketLocation / Acl / Cors | `s3:GetBucketLocation` /
`s3:GetBucketAcl` / `s3:GetBucketCORS` |
| GetBucketPolicyStatus | `s3:GetBucketPolicyStatus` |
| Get / Put / DeleteBucketTagging | `s3:GetBucketTagging` /
`s3:PutBucketTagging` (both writes) |
| Get / PutBucketVersioning | `s3:GetBucketVersioning` /
`s3:PutBucketVersioning` |
| Get / PutObjectLockConfiguration |
`s3:GetBucketObjectLockConfiguration` /
`s3:PutBucketObjectLockConfiguration` |
| Get / Put / DeleteObjectTagging | `s3:GetObjectTagging` /
`s3:PutObjectTagging` / `s3:DeleteObjectTagging` |
| GetObjectAttributes | `s3:GetObjectAttributes` |

The bucket configuration writes and the policy status are service-key
only, like the policy and encryption. The other new permissions are
policy actions (fil-forge/libforge#86, merged). Object tagging writes
are catalog writes; the bucket configuration lives in Ingot's registry
and needs no Forge commands.

Routing follows the router per method:

- A route the gateway has that Hilt does not authorize is refused with
`UnsupportedOperation`: `PUT`/`DELETE ?cors`, `PUT ?acl`, object ACLs,
`?uploads`/`?versions` on an object, and `uploadId` on a PUT without a
`partNumber`.
- A POST naming no route is refused. On a bucket it is a browser form
upload, authenticated by a signed policy in the body that Hilt doesn't
verify; #101 classifies it as CreateBucket.
- A supported subresource the router doesn't route on for the method is
the plain operation there, as in the router (`PUT /b?uploads` creates
the bucket). A HEAD is HeadBucket or HeadObject whatever its
subresource.
- Other query parameters are ignored, as #101 has it.

### Behaviour changes to know about

- Existing service keys don't hold the new permissions, so they lose
these operations until they're re-issued with them. FilOne's console
sets explicit permission lists on the keys it issues and will need the
new names.
- Principal policies that list actions (instead of `s3:*`) need the new
reads added for principals to keep reading bucket configuration, tags
and attributes.
- `POST /b/k?partNumber&uploadId` is now CompleteMultipartUpload, as the
router routes it (same permission as the PutObject it was before).

### Tests

`go test ./...` and `go vet -tags itest ./itest` pass.
`TestClassifyRequest` covers each new operation, the router's
fall-through cases, and the refused routes.
`TestRoutesAddressExistingBuckets` checks that every routed operation is
scope-checked against its bucket and routes only on a supported
subresource. I haven't run the IAM itest scenarios against the change.
fil-forge/ingot#287 pins this branch and runs its route-permission
scenario against it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alanshaw added a commit to fil-forge/ingot that referenced this pull request Oct 20, 2026
…ries naming a version (#287)

Stacked on #286, which maps hilt#101's `ConflictingQuery` refusal to
S3's 400. Pins hilt to the merge of fil-forge/hilt#102 (`cd26388`, which
includes hilt#101), replacing #286's pin, and libforge to its merge of
fil-forge/libforge#86.

Hilt used to classify `POST /b?delete` (DeleteObjects) as CreateBucket.
CreateBucket skips the bucket scope check, so a key holding
`s3:CreateBucket` could batch-delete in any bucket of its tenant,
permanently deleting named versions included. Ingot did no permission
check per entry. hilt#101 authorizes DeleteObjects on `s3:DeleteObject`,
as S3 does.

S3 also requires `s3:DeleteObjectVersion` for each entry that names a
version. Those entries are in the body, where Hilt can't see them, so
Ingot now checks them: the IAM service puts the key's action sets on the
request (`reqscope.ActionsKey`, the same per-key cache the local fast
path reads), and `DeleteObjects` fails each such entry with
AccessDenied, leaving the version in place, unless the key holds the
permission on the bucket. Entries without a version are unaffected. A
request with no action set refuses version entries.

The pin bump also brings in hilt#101's and hilt#102's classification on
the fast path (`RequirementsFor`), so bucket configuration, object
tagging and attribute requests are checked against their own
permissions.

### Tests

- `TestVersioning_DeleteObjectsNeedsVersionPermission`: refused without
the permission and with no action set; a mixed batch refuses the version
entry and deletes the rest.
- `TestForgeRoutePermissions` (itest), through the real hilt:
- a key holding only `s3:CreateBucket` is refused PutBucketVersioning
and DeleteObjects on an existing bucket
- a key without `s3:DeleteObjectVersion` gets AccessDenied for the
version entry while the other entry is deleted, and a key with it
deletes the version

  Each denial is asserted twice, so the fast path is covered.
- The itest key (`hiltAllPermissions`) carries the encryption and route
permissions; the published hilt image knows them, so the
`INGOT_ITEST_ENCRYPTION_PERMS` and `INGOT_ITEST_ROUTE_PERMS` guards are
gone and both permission scenarios run in CI.

`make test` passes. Against the published hilt `:main` (`cd26388`), with
no override, as CI runs, these pass: `TestForgeVersity` (the S3
conformance suite), `TestForgeScenarios`, `TestForgeRoutePermissions`
and `TestForgeBucketEncryptionPermissions`.

Merge #286 first; this PR's pin replaces its pin.

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

---------

Co-authored-by: Claude Opus 5.5 <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.

3 participants