Files
versitygw/auth/object_lock_test.go
niksis02 bbad4b5988 fix: store bucket default retention on each object at write time and support Object Lock fields in browser POST
The bucket default retention was never stored on objects: protection was computed at delete time from the live bucket rule (`CreatedAt` + period), so changing or removing the rule unlocked existing objects, `COMPLIANCE` ones included, and a rule set N days ago stopped protecting even new objects. The posix and azure backends now store the rule's retention, `{Mode, now + Days/Years}` truncated to milliseconds, as the object's own retention whenever a write sets no Object Lock parameters: `PutObject` (which also covers POST, RDMA and `CopyObject`), `CreateMultipartUpload` (the rule and start time are fixed at creation, as S3 does) and the unversioned in-place self-copy, which applied no lock settings before. As on S3, any explicit `x-amz-object-lock-legal-hold`, `ON` or `OFF`, means the default retention is not applied. The bucket-rule fallback in `checkObject` and `BucketLockConfig.CreatedAt` are removed, so objects written before this change lose bucket-derived protection, and an expired retention no longer skips the legal hold check.

`PutObjectLockConfiguration` now rejects a `DefaultRetention` with a mode but no `Days`/`Years` with `InvalidRequest` "DefaultRetention must specify a fixed retention (Days or Years) or a DefaultEventHold.", and a `<Rule>` without `DefaultRetention` with `MalformedXML`. Azure `CopyObject` with the `COPY` metadata directive no longer copies the source's `Objectretention`/`Objectlegalhold` metadata to the destination.

Browser POST uploads now validate and apply the `x-amz-object-lock-mode`, `x-amz-object-lock-retain-until-date` and `x-amz-object-lock-legal-hold` form fields. They require `s3:PutObjectRetention`/`s3:PutObjectLegalHold`, for anonymous uploads too, and return `ErrMissingObjectLockConfigurationNoSpaces` on buckets without Object Lock after authorization. A denied POST names the actions in S3's order: retention, legal hold, tagging, then `s3:PutObject`.

`NoSuchObjectLockConfiguration` now returns 404 without a trailing period. `x-amz-object-lock-retain-until-date` on HEAD and GET has millisecond precision and omits zero milliseconds, `GetObjectRetention` returns `<Retention>` with a `.000Z` date, and posix and azure `GetObject` now return the Object Lock headers.
2026-10-04 22:01:57 +02:00

574 lines
25 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package auth
import (
"context"
"encoding/json"
"encoding/xml"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/assert"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
// TestVerifyBypassGovernancePermission_IdentityAllowNoBucketPolicy is the
// same-account fix this function exists for: an IAM identity policy Allow
// is sufficient to use x-amz-bypass-governance-retention even when the
// bucket has no policy at all. The old bucket-policy-only check treated "no
// bucket policy" as an immediate ErrObjectLocked, never even consulting the
// identity policy.
func TestVerifyBypassGovernancePermission_IdentityAllowNoBucketPolicy(t *testing.T) {
be := noBucketPolicyBackend{}
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err)
}
// TestVerifyBypassGovernancePermission_ResourceAllowIdentitySilent is the
// reverse: a bucket policy Allow is sufficient when the identity policy has
// no opinion on the action, but the identity policy must still be
// consulted (not skipped) so an explicit Deny there can override it.
func TestVerifyBypassGovernancePermission_ResourceAllowIdentitySilent(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err)
assert.Len(t, pe.calls, 1, "identity policy must be consulted even though the bucket policy already allows")
}
// TestVerifyBypassGovernancePermission_IdentityExplicitDenyOverridesResourceAllow
// is the explicit-deny-wins case: a bucket policy Allow does not save a
// bypass request the caller's own identity policy explicitly denies. AWS
// reports this as a specific AccessDenied naming
// s3:BypassGovernanceRetention, not the generic "object protected by object
// lock" message.
func TestVerifyBypassGovernancePermission_IdentityExplicitDenyOverridesResourceAllow(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionDeny)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "s3:BypassGovernanceRetention")
assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy")
}
// TestVerifyBypassGovernancePermission_ResourceExplicitDenyOverridesIdentityAllow
// is the reverse: an identity policy Allow does not save a bypass request
// the bucket policy explicitly denies, and the resource-level Deny
// short-circuits before the identity policy is even consulted.
func TestVerifyBypassGovernancePermission_ResourceExplicitDenyOverridesIdentityAllow(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "testuser",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
pe := newMockPolicyEvaluator(policyDecisionAllow)
err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
assert.Empty(t, pe.calls, "a resource-level explicit deny should short-circuit before consulting the identity policy")
}
// TestVerifyBypassGovernancePermission_ImplicitDenyWhenNeitherAllows: with
// no bucket policy and no identity-policy grant, AWS denies with "because
// no identity-based policy allows the s3:BypassGovernanceRetention action"
// — the same implicit-deny shape VerifyAccess uses for ordinary actions.
func TestVerifyBypassGovernancePermission_ImplicitDenyWhenNeitherAllows(t *testing.T) {
be := noBucketPolicyBackend{}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
apiErr := requireAccessDeniedAPIError(t, err)
assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser")
assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:BypassGovernanceRetention action")
}
// TestVerifyBypassGovernancePermission_NoPolicyEvaluatorPreservesGenericMessage
// confirms backends with no identity-policy layer (every backend except the
// standalone IAM service) are unaffected: the generic ErrObjectLocked stays
// exactly as before when there is no bucket policy to grant the bypass.
func TestVerifyBypassGovernancePermission_NoPolicyEvaluatorPreservesGenericMessage(t *testing.T) {
be := noBucketPolicyBackend{}
iam := NewIAMServiceSingle(Account{})
err := verifyBypassGovernancePermission(context.Background(), be, iam, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err)
}
// TestVerifyBypassGovernancePermission_NoPolicyEvaluatorBucketPolicyAllowStillWorks
// pins that, without a PolicyEvaluator, a bucket policy Allow alone is still
// sufficient — the pre-existing (bucket-policy-only) behavior.
func TestVerifyBypassGovernancePermission_NoPolicyEvaluatorBucketPolicyAllowStillWorks(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "testuser",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
iam := NewIAMServiceSingle(Account{})
err := verifyBypassGovernancePermission(context.Background(), be, iam, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err)
}
// TestVerifyBypassGovernancePermission_PublicBucketAllowed and
// TestVerifyBypassGovernancePermission_PublicBucketDenied confirm the
// isBucketPublic branch (anonymous requests, evaluated only against the
// bucket's public policy grant, wrapped in the generic ErrObjectLocked) is
// unchanged by this refactor.
func TestVerifyBypassGovernancePermission_PublicBucketAllowed(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
err := verifyBypassGovernancePermission(context.Background(), be, nil, Account{}, "bucket", "key.txt", BypassRequested, true, nil)
assert.NoError(t, err)
}
func TestVerifyBypassGovernancePermission_PublicBucketDenied(t *testing.T) {
be := &publicBucketPolicyBackend{
policy: []byte(`{
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}]
}`),
}
err := verifyBypassGovernancePermission(context.Background(), be, nil, Account{}, "bucket", "key.txt", BypassRequested, true, nil)
assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err)
}
// TestVerifyBypassGovernancePermission_RootBypassesOnlyWhenRequested pins the
// asymmetry between the two ways a governance retention can be overridden.
//
// Root bypasses unconditionally when the client actually sent
// x-amz-bypass-governance-retention, matching real AWS, where the account
// root can bypass regardless of policy. It does not get that on the
// overwrite path, where no client asked for anything and letting root
// through would mean silently replacing a locked object.
func TestVerifyBypassGovernancePermission_RootBypassesOnlyWhenRequested(t *testing.T) {
root := Account{Access: "root", Role: RoleAdmin}
// No bucket policy and no identity policy: the only thing that could
// possibly permit this is root's own status.
be := &publicBucketPolicyBackend{policy: []byte(`{"Statement":[]}`)}
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
err := verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err, "root must bypass a governance retention it explicitly asked to bypass")
err = verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassOverwrite, false, nil)
assert.Error(t, err, "root must not silently overwrite a governance-locked object: no bypass was requested")
err = verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassNone, false, nil)
assert.Error(t, err, "root must not bypass when the request did not ask to")
}
// TestVerifyBypassGovernancePermission_NonRootStillNeedsPermission confirms
// the root shortcut is exactly that, and does not leak to ordinary users.
func TestVerifyBypassGovernancePermission_NonRootStillNeedsPermission(t *testing.T) {
user := Account{Access: "testuser", Role: RoleUser}
be := &publicBucketPolicyBackend{policy: []byte(`{"Statement":[]}`)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil)
assert.Error(t, err, "a plain user with no grant anywhere must not bypass")
err = verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionAllow), user, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err, "an identity-policy Allow grants the bypass")
}
// TestIsObjectLockRetentionPutAllowed_WeakeningRules covers which retention
// rewrites need s3:BypassGovernanceRetention and which need nothing.
//
// Extending a GOVERNANCE or COMPLIANCE retention, or rewriting it with the
// identical date, succeeds with no bypass header, while shortening either
// one without the header fails with "Access Denied because object
// protected by object lock." A COMPLIANCE retention cannot be weakened at
// all, even with the header.
func TestIsObjectLockRetentionPutAllowed_WeakeningRules(t *testing.T) {
now := time.Now()
stored := now.Add(time.Hour)
tests := []struct {
name string
mode types.ObjectLockRetentionMode
newMode types.ObjectLockRetentionMode
newDate time.Time
bypass bool
wantAllow bool
}{
{name: "governance extended", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored.Add(time.Hour), wantAllow: true},
{name: "governance same date", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored, wantAllow: true},
{name: "governance shortened without bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: now.Add(time.Minute)},
{name: "governance shortened with bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: now.Add(time.Minute), bypass: true, wantAllow: true},
{name: "compliance extended", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored.Add(time.Hour), wantAllow: true},
{name: "compliance shortened without bypass", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: now.Add(time.Minute)},
{name: "compliance shortened with bypass", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: now.Add(time.Minute), bypass: true},
{name: "compliance downgraded to governance", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored.Add(time.Hour), bypass: true},
{name: "governance upgraded to compliance with bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored, bypass: true, wantAllow: true},
{name: "governance upgraded to compliance without bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retention, err := json.Marshal(types.ObjectLockRetention{Mode: tt.mode, RetainUntilDate: &stored})
assert.NoError(t, err)
be := &objectRetentionBackend{retention: retention}
// A permissive evaluator, so any denial below is the retention
// rule talking rather than a missing permission.
pe := newMockPolicyEvaluator(policyDecisionAllow)
err = IsObjectLockRetentionPutAllowed(testFiberCtx(t), be, pe, "bucket", "key.txt", "",
Account{Access: "testuser", Role: RoleUser},
&s3response.PutObjectRetentionInput{Mode: tt.newMode, RetainUntilDate: s3response.AmzDate{Time: tt.newDate}},
tt.bypass)
if tt.wantAllow {
assert.NoError(t, err)
} else {
assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err)
}
})
}
}
// objectRetentionBackend serves one canned object retention.
type objectRetentionBackend struct {
backend.BackendUnsupported
retention []byte
}
func (b *objectRetentionBackend) GetObjectRetention(_ context.Context, _, _, _ string) ([]byte, error) {
return b.retention, nil
}
func (b *objectRetentionBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
}
// TestVerifyBypassGovernancePermission_ArnPrincipals covers the
// governance-bypass path's own bucket-policy evaluation under an IAM backend
// whose identities have ARNs. It is a separate evaluation from VerifyAccess's
// and has to agree with it: the same principal forms match, the account
// principal delegates under Allow but not under Deny, and a denial names the
// caller by its ARN.
func TestVerifyBypassGovernancePermission_ArnPrincipals(t *testing.T) {
const (
userArn = "arn:aws:iam::000000000000:user/alice"
rootArn = "arn:aws:iam::000000000000:root"
)
user := Account{Access: "AKIAALICE", Role: RoleUser, Arn: userArn}
bypassPolicy := func(effect, principal string) []byte {
return []byte(`{"Statement":[{"Effect":"` + effect + `","Principal":{"AWS":"` + principal +
`"},"Action":"s3:BypassGovernanceRetention","Resource":"arn:aws:s3:::bucket/*"}]}`)
}
t.Run("user arn allows the bypass", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Allow", userArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil)
assert.NoError(t, err)
})
t.Run("account arn delegates and so allows nothing", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Allow", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, "because no identity-based policy allows")
})
t.Run("account arn deny names the caller by arn", func(t *testing.T) {
be := &publicBucketPolicyBackend{policy: bypassPolicy("Deny", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionAllow), user, "bucket", "key.txt", BypassRequested, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, userArn)
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
})
t.Run("root is named by the account arn it carries", func(t *testing.T) {
// Root reaches here only for BypassOverwrite; a requested bypass
// returns earlier. rootIdentity gives it the account root ARN, which
// is how a Deny naming the account reaches it at all.
root := Account{Access: "root", Role: RoleAdmin, Arn: rootArn}
be := &publicBucketPolicyBackend{policy: bypassPolicy("Deny", rootArn)}
err := verifyBypassGovernancePermission(context.Background(), be,
newMockPolicyEvaluator(policyDecisionAllow), root, "bucket", "key.txt", BypassOverwrite, false, nil)
apiErr, ok := err.(s3err.APIError)
assert.True(t, ok, "err = %#v, want s3err.APIError", err)
assert.Contains(t, apiErr.Description, rootArn)
assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy")
})
}
// TestParseBucketLockConfigurationOutput covers the object lock configuration
// reported to clients. A bucket with object lock enabled and no default
// retention must omit the <Rule> element, the way AWS S3 does: an empty
// <Rule></Rule> is accepted by the XML parser but leaves AWS SDK v2 clients
// (the Java SDK among them) with nothing to read the rule from.
func TestParseBucketLockConfigurationOutput(t *testing.T) {
days := int32(30)
tests := []struct {
name string
config string
wantRule bool
wantMarker string
}{
{name: "no default retention", config: `{"Enabled":true}`, wantMarker: "<ObjectLockEnabled>Enabled</ObjectLockEnabled>"},
{name: "default retention", config: `{"Enabled":true,"DefaultRetention":{"Mode":"GOVERNANCE","Days":30}}`, wantRule: true, wantMarker: "<Days>30</Days>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := ParseBucketLockConfigurationOutput([]byte(tt.config))
assert.NoError(t, err)
if !tt.wantRule {
assert.Nil(t, out.Rule)
} else {
assert.NotNil(t, out.Rule)
assert.Equal(t, &days, out.Rule.DefaultRetention.Days)
}
data, err := xml.Marshal(out)
assert.NoError(t, err)
assert.Contains(t, string(data), tt.wantMarker)
if tt.wantRule {
assert.Contains(t, string(data), "<Rule>")
} else {
assert.NotContains(t, string(data), "<Rule>")
}
})
}
}
// TestDefaultObjectRetention covers the retention a bucket's default rule
// gives an object: the rule's mode, until the rule's period from the write,
// in UTC with millisecond precision. Years are calendar years.
func TestDefaultObjectRetention(t *testing.T) {
written := time.Date(2026, 10, 4, 10, 5, 32, 45123456, time.FixedZone("UTC+2", 2*60*60))
days, years := int32(1), int32(2)
tests := []struct {
name string
config BucketLockConfig
wantMode types.ObjectLockRetentionMode
wantUntil time.Time
}{
{
name: "days",
config: BucketLockConfig{Enabled: true, DefaultRetention: &types.DefaultRetention{Mode: types.ObjectLockRetentionModeGovernance, Days: &days}},
wantMode: types.ObjectLockRetentionModeGovernance,
wantUntil: time.Date(2026, 10, 5, 8, 5, 32, 45000000, time.UTC),
},
{
name: "years across a leap day",
config: BucketLockConfig{Enabled: true, DefaultRetention: &types.DefaultRetention{Mode: types.ObjectLockRetentionModeCompliance, Years: &years}},
wantMode: types.ObjectLockRetentionModeCompliance,
wantUntil: time.Date(2028, 10, 4, 8, 5, 32, 45000000, time.UTC),
},
{name: "no default rule", config: BucketLockConfig{Enabled: true}},
{name: "object lock disabled", config: BucketLockConfig{DefaultRetention: &types.DefaultRetention{Mode: types.ObjectLockRetentionModeGovernance, Days: &days}}},
{name: "rule without a period", config: BucketLockConfig{Enabled: true, DefaultRetention: &types.DefaultRetention{Mode: types.ObjectLockRetentionModeGovernance}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ret := tt.config.DefaultObjectRetention(written)
if tt.wantMode == "" {
assert.Nil(t, ret)
return
}
if assert.NotNil(t, ret) && assert.NotNil(t, ret.RetainUntilDate) {
assert.Equal(t, tt.wantMode, ret.Mode)
assert.Equal(t, tt.wantUntil, *ret.RetainUntilDate)
}
})
}
}
// TestParseBucketLockConfigurationInput covers the default retention rules
// S3 rejects. A rule needs a valid mode and exactly one of Days and Years.
func TestParseBucketLockConfigurationInput(t *testing.T) {
config := func(rule string) []byte {
return []byte("<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled>" + rule + "</ObjectLockConfiguration>")
}
tests := []struct {
name string
input []byte
wantErr error
}{
{name: "mode without a period", input: config("<Rule><DefaultRetention><Mode>GOVERNANCE</Mode></DefaultRetention></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrObjectLockDefaultRetentionPeriodRequired)},
{name: "invalid mode without a period", input: config("<Rule><DefaultRetention><Mode>INVALID</Mode></DefaultRetention></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
{name: "days without a mode", input: config("<Rule><DefaultRetention><Days>1</Days></DefaultRetention></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
{name: "both days and years", input: config("<Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>1</Days><Years>1</Years></DefaultRetention></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
{name: "empty default retention", input: config("<Rule><DefaultRetention></DefaultRetention></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
{name: "rule without default retention", input: config("<Rule></Rule>"), wantErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
{name: "no rule", input: config("")},
{name: "valid rule", input: config("<Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>1</Days></DefaultRetention></Rule>")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseBucketLockConfigurationInput(tt.input)
assert.Equal(t, tt.wantErr, err)
})
}
}
// TestCheckObjectAccess_ObjectOwnLockOnly covers the locks a delete is
// checked against: only the object's own retention and legal hold. A legal
// hold still protects the object once its retention has expired, and the
// bucket's default retention rule protects nothing by itself, since it is
// stored on each object when the object is written.
func TestCheckObjectAccess_ObjectOwnLockOnly(t *testing.T) {
days := int32(1)
ruleConfig, err := json.Marshal(BucketLockConfig{
Enabled: true,
DefaultRetention: &types.DefaultRetention{Mode: types.ObjectLockRetentionModeCompliance, Days: &days},
})
assert.NoError(t, err)
expired := time.Now().Add(-time.Minute)
expiredRetention, err := json.Marshal(types.ObjectLockRetention{Mode: types.ObjectLockRetentionModeGovernance, RetainUntilDate: &expired})
assert.NoError(t, err)
legalHold := true
key := "key.txt"
tests := []struct {
name string
be *objectLockStateBackend
wantErr error
}{
{
name: "legal hold with expired retention",
be: &objectLockStateBackend{config: ruleConfig, retention: expiredRetention, legalHold: &legalHold},
wantErr: s3err.GetAPIError(s3err.ErrObjectLocked),
},
{
name: "expired retention",
be: &objectLockStateBackend{config: ruleConfig, retention: expiredRetention},
},
{
name: "default retention rule only",
be: &objectLockStateBackend{config: ruleConfig},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckObjectAccess(testFiberCtx(t), "bucket", Account{Access: "testuser", Role: RoleUser},
[]types.ObjectIdentifier{{Key: &key}}, BypassRequested, false, tt.be, nil, false)
assert.Equal(t, tt.wantErr, err)
})
}
}
// objectLockStateBackend serves a bucket's object lock configuration and
// one object's retention and legal hold, nil meaning the object has none.
type objectLockStateBackend struct {
backend.BackendUnsupported
config []byte
retention []byte
legalHold *bool
}
func (b *objectLockStateBackend) GetObjectLockConfiguration(_ context.Context, _ string) ([]byte, error) {
return b.config, nil
}
func (b *objectLockStateBackend) GetObjectRetention(_ context.Context, _, _, _ string) ([]byte, error) {
if b.retention == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)
}
return b.retention, nil
}
func (b *objectLockStateBackend) GetObjectLegalHold(_ context.Context, _, _, _ string) (*bool, error) {
if b.legalHold == nil {
return nil, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)
}
return b.legalHold, nil
}