diff --git a/AGENTS.md b/AGENTS.md index ffccb2d..1efb36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/go.mod b/go.mod index 3a5d73b..e0a829f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5da6b1c..a58b5d9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/bucketpolicy/bucketpolicy_test.go b/pkg/bucketpolicy/bucketpolicy_test.go index a8c103f..a56008b 100644 --- a/pkg/bucketpolicy/bucketpolicy_test.go +++ b/pkg/bucketpolicy/bucketpolicy_test.go @@ -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...)) diff --git a/pkg/rpc/service/auth/auth_test.go b/pkg/rpc/service/auth/auth_test.go index fc11e05..529af40 100644 --- a/pkg/rpc/service/auth/auth_test.go +++ b/pkg/rpc/service/auth/auth_test.go @@ -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")) @@ -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}) diff --git a/pkg/rpc/service/auth/operation.go b/pkg/rpc/service/auth/operation.go index bce1b03..6dd441d 100644 --- a/pkg/rpc/service/auth/operation.go +++ b/pkg/rpc/service/auth/operation.go @@ -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 @@ -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, @@ -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") diff --git a/pkg/rpc/service/auth/operation_test.go b/pkg/rpc/service/auth/operation_test.go index abe618c..74914b2 100644 --- a/pkg/rpc/service/auth/operation_test.go +++ b/pkg/rpc/service/auth/operation_test.go @@ -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}) diff --git a/pkg/s3perm/s3perm.go b/pkg/s3perm/s3perm.go index 8e1dbf2..f75f31b 100644 --- a/pkg/s3perm/s3perm.go +++ b/pkg/s3perm/s3perm.go @@ -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, diff --git a/pkg/s3perm/s3perm_test.go b/pkg/s3perm/s3perm_test.go index cba5b1e..5c199b2 100644 --- a/pkg/s3perm/s3perm_test.go +++ b/pkg/s3perm/s3perm_test.go @@ -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) }