Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ and `sprue` (the upload service; mirror its patterns where relevant).
own bucket (`companionPermission`: GetObjectAttributes needs `s3:GetObject`
too) is checked for both, and so is a header that adds one
(`classification.readHeaders`: lock and tagging headers on a write,
`x-amz-bypass-governance-retention` on a delete or retention write,
`x-amz-bucket-object-lock-enabled` on CreateBucket). Every header a
decision is taken from must be a signed header, which `RequirementsFor`
refuses too for the gateway's local check. A body is read only for the
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/aws/smithy-go v1.27.2
github.com/docker/docker v28.5.2+incompatible
github.com/exaring/otelpgx v0.12.0
github.com/fil-forge/libforge v0.0.0-20261022083349-59d8bde5f889
github.com/fil-forge/libforge v0.0.0-20261022121207-bcdd5eacc465
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fil-forge/libforge v0.0.0-20261022083349-59d8bde5f889 h1:lmjXiMsVFHsln4hmSjtuJxsHhLP49WOp48Ot5KPctMQ=
github.com/fil-forge/libforge v0.0.0-20261022083349-59d8bde5f889/go.mod h1:ZtPwUgXm7gqQSmjmIcfMy5AwWpFIR02WMZvbgNOwi/4=
github.com/fil-forge/libforge v0.0.0-20261022121207-bcdd5eacc465 h1:y7EPy1Fbn/LWQMy7iYKoZ1G0Pi88a4Z8tVw7uFOBjLA=
github.com/fil-forge/libforge v0.0.0-20261022121207-bcdd5eacc465/go.mod h1:ZtPwUgXm7gqQSmjmIcfMy5AwWpFIR02WMZvbgNOwi/4=
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c h1:AEUEUEs/mDNksJIXdMgNMGJ03SHk6NlKjtDH58d1T04=
github.com/fil-forge/smelt v0.0.0-20260914083257-f594cf02655c/go.mod h1:OUO2GxgUihmYfywNIfvWTQocg6jqaNiTd04cG5BbdII=
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c h1:uzLK4I8rD2SXBTeb/qOMbnfD/oAr2RoFVP730abzzyo=
Expand Down
1 change: 1 addition & 0 deletions pkg/bucketpolicy/bucketpolicy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ func TestActionResources(t *testing.T) {
"s3:GetObject", "s3:GetObjectLegalHold", "s3:GetObjectRetention", "s3:GetObjectVersion",
"s3:ListMultipartUploadParts", "s3:PutObject", "s3:PutObjectLegalHold", "s3:PutObjectRetention",
"s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:GetObjectAttributes",
"s3:BypassGovernanceRetention",
}
require.ElementsMatch(t, s3bkt.PolicyActions(), append(slices.Clone(bucketActions), objectActions...))

Expand Down
31 changes: 31 additions & 0 deletions pkg/rpc/service/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,23 @@ func TestAuthorize(t *testing.T) {
require.ErrorIs(t, err, auth.ErrUnsignedHeader)
})

t.Run("bypassing governance retention needs its permission", func(t *testing.T) {
const objectURL = "https://s3.fil.one/bucket/object-key?versionId=v1"
bypass := map[string]string{"x-amz-bypass-governance-retention": "true"}

az, _, _ := setup(t, accessKey, &setupConfig{accessKeyPermissions: []string{"s3:DeleteObjectVersion"}})
_, err := az.Authorize(ctx, providerID, signedRequestWith(t, accessKey, "DELETE", objectURL, bypass, region, "x-amz-bypass-governance-retention"))
require.ErrorIs(t, err, auth.ErrOperationNotPermitted)

az, _, _ = setup(t, accessKey, &setupConfig{accessKeyPermissions: []string{"s3:DeleteObjectVersion", "s3:BypassGovernanceRetention"}})
authz, err := az.Authorize(ctx, providerID, signedRequestWith(t, accessKey, "DELETE", objectURL, bypass, region, "x-amz-bypass-governance-retention"))
require.NoError(t, err)
require.Equal(t, auth.OpDeleteObjectVersion, authz.Operation)

_, err = az.Authorize(ctx, providerID, signedRequestWith(t, accessKey, "DELETE", objectURL, bypass, region))
require.ErrorIs(t, err, auth.ErrUnsignedHeader)
})

t.Run("a request's requirements refuse an unsigned header as Authorize does", func(t *testing.T) {
headers := map[string]string{"x-amz-object-lock-legal-hold": "ON", "x-amz-tagging": "k=v"}
_, _, err := auth.RequirementsFor(signedRequestWith(t, accessKey, "PUT", "https://s3.fil.one/bucket/object-key", headers, region, "x-amz-tagging"))
Expand Down Expand Up @@ -755,6 +772,20 @@ func TestAuthorize(t *testing.T) {
require.Equal(t, bucketID, authz.SourceBucket.ID)
})

t.Run("a bypass header needs the bypass action from the policy", func(t *testing.T) {
bypass := map[string]string{"x-amz-bypass-governance-retention": "true"}
az, _, tenantID, bucketID, policies := setupWithPolicies(t, accessKey, &setupConfig{principalBound: true})
grant(t, policies, bucketID, tenantID, allow("s3:DeleteObjectVersion"))
_, err := az.Authorize(ctx, providerID, signedRequestWith(t, accessKey, "DELETE", "https://s3.fil.one/bucket/object-key?versionId=v1", bypass, region, "x-amz-bypass-governance-retention"))
require.ErrorIs(t, err, auth.ErrOperationNotPermitted)

az, _, tenantID, bucketID, policies = setupWithPolicies(t, accessKey, &setupConfig{principalBound: true})
grant(t, policies, bucketID, tenantID, allow("s3:DeleteObjectVersion", "s3:BypassGovernanceRetention"))
authz, err := az.Authorize(ctx, providerID, signedRequestWith(t, accessKey, "DELETE", "https://s3.fil.one/bucket/object-key?versionId=v1", bypass, region, "x-amz-bypass-governance-retention"))
require.NoError(t, err)
require.Equal(t, bucketID, authz.Bucket.ID)
})

t.Run("a lock header needs the lock action from the destination's policy", func(t *testing.T) {
hold := map[string]string{"x-amz-copy-source": "/bucket/object-key", "x-amz-object-lock-legal-hold": "ON"}
az, _, tenantID, bucketID, policies := setupWithPolicies(t, accessKey, &setupConfig{principalBound: true})
Expand Down
16 changes: 13 additions & 3 deletions pkg/rpc/service/auth/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,10 @@ const copySourceHeader = "x-amz-copy-source"
// The request headers that put a permission on the operation they ride on,
// as S3 requires. A write that sets a retention period or a legal hold on
// the version it creates needs the lock permission beside the write, one
// that tags it needs the tagging permission, and a bucket created with
// Object Lock enabled is born versioned and locked, which needs both
// configuration permissions beside s3:CreateBucket. The gateway acts on
// that tags it needs the tagging permission, a delete or retention write
// asking to bypass governance retention needs the bypass permission, and a
// bucket created with Object Lock enabled is born versioned and locked,
// which needs both configuration permissions beside s3:CreateBucket. The gateway acts on
// these headers exactly as read here: it stamps the lock state and the tags
// at commit (a copy's tags only under a REPLACE directive) and creates the
// bucket versioned and locked, so a header that adds no permission here is
Expand All @@ -319,6 +320,11 @@ const (
taggingHeader = "x-amz-tagging"
taggingDirectiveHeader = "x-amz-tagging-directive"
bucketObjectLockEnabledHeader = "x-amz-bucket-object-lock-enabled"
// bypassGovernanceHeader asks a delete or a retention write to act on a
// governance-locked version; S3 needs s3:BypassGovernanceRetention for
// it. The gateway decides the bypass itself against the bucket's lock
// state, and refuses it for now whatever the key holds.
bypassGovernanceHeader = "x-amz-bypass-governance-retention"
)

// classification is what classifyRequest derives from a request: the operation,
Expand Down Expand Up @@ -389,6 +395,10 @@ func (c *classification) readHeaders(headers map[string]string) {
} else if _, ok := HeaderValue(headers, taggingHeader); ok {
need(taggingHeader, "s3:PutObjectTagging")
}
case OpDeleteObject, OpDeleteObjectVersion, OpDeleteObjects, OpPutObjectRetention:
if v, ok := HeaderValue(headers, bypassGovernanceHeader); ok && strings.EqualFold(first(v), "true") {
need(bypassGovernanceHeader, "s3:BypassGovernanceRetention")
}
case OpCreateBucket:
if v, ok := HeaderValue(headers, bucketObjectLockEnabledHeader); ok && strings.EqualFold(first(v), "true") {
need(bucketObjectLockEnabledHeader, "s3:PutBucketVersioning", "s3:PutBucketObjectLockConfiguration")
Expand Down
18 changes: 18 additions & 0 deletions pkg/rpc/service/auth/operation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,24 @@ func TestReadHeaders(t *testing.T) {
headers: map[string]string{"x-amz-bucket-object-lock-enabled": "false, true"}},
{name: "put object ignores the bucket flag", method: "PUT", url: "https://s3.example.com/bkt/k",
headers: map[string]string{"x-amz-bucket-object-lock-enabled": "true"}},
// Bypassing governance retention is priced on the deletes and the
// retention write, on the header's first value, case-insensitively.
{name: "delete with bypass", method: "DELETE", url: "https://s3.example.com/bkt/k",
headers: map[string]string{"x-amz-bypass-governance-retention": "true"},
wantPerms: []string{"s3:BypassGovernanceRetention"}, wantHeaders: []string{"x-amz-bypass-governance-retention"}},
{name: "delete of a version with bypass", method: "DELETE", url: "https://s3.example.com/bkt/k?versionId=v",
headers: map[string]string{"X-Amz-Bypass-Governance-Retention": "TRUE"},
wantPerms: []string{"s3:BypassGovernanceRetention"}, wantHeaders: []string{"x-amz-bypass-governance-retention"}},
{name: "delete objects with bypass", method: "POST", url: "https://s3.example.com/bkt?delete",
headers: map[string]string{"x-amz-bypass-governance-retention": "true,false"},
wantPerms: []string{"s3:BypassGovernanceRetention"}, wantHeaders: []string{"x-amz-bypass-governance-retention"}},
{name: "retention write with bypass", method: "PUT", url: "https://s3.example.com/bkt/k?retention",
headers: map[string]string{"x-amz-bypass-governance-retention": "true"},
wantPerms: []string{"s3:BypassGovernanceRetention"}, wantHeaders: []string{"x-amz-bypass-governance-retention"}},
{name: "delete without bypass", method: "DELETE", url: "https://s3.example.com/bkt/k",
headers: map[string]string{"x-amz-bypass-governance-retention": "false"}},
{name: "a put ignores the bypass header", method: "PUT", url: "https://s3.example.com/bkt/k",
headers: map[string]string{"x-amz-bypass-governance-retention": "true"}},
} {
t.Run(tt.name, func(t *testing.T) {
c, err := classifyRequest(s3.Request{Method: tt.method, URL: tt.url, Headers: tt.headers})
Expand Down
5 changes: 5 additions & 0 deletions pkg/s3perm/s3perm.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ var permissionCommands = map[string][]ucan.Command{
"s3:GetBucketObjectLockConfiguration": nil,
"s3:PutBucketObjectLockConfiguration": nil,

// Bypassing governance retention rides on a delete or a retention write,
// which carries the commands; the bypass itself moves nothing. The
// gateway decides the bypass against the bucket's lock state.
"s3:BypassGovernanceRetention": nil,

// Object tags and attributes. Both reads go through the object's catalog
// entry, which may have to come back over the network.
"s3:GetObjectTagging": cmdsRetrieve,
Expand Down
2 changes: 1 addition & 1 deletion pkg/s3perm/s3perm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ func TestPolicyAction(t *testing.T) {
func TestPolicyActions(t *testing.T) {
actions := s3bkt.PolicyActions()
require.True(t, slices.IsSorted(actions))
require.Len(t, actions, 24)
require.Len(t, actions, 25)
for _, p := range actions {
require.True(t, s3bkt.PolicyAction(p), p)
}
Expand Down
Loading