diff --git a/docs/resources/s3_bucket_retention.md b/docs/resources/s3_bucket_retention.md new file mode 100644 index 00000000..a2108b55 --- /dev/null +++ b/docs/resources/s3_bucket_retention.md @@ -0,0 +1,73 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "minio_s3_bucket_retention Resource - terraform-provider-minio" +subcategory: "" +description: |- + Manages object lock retention settings for a MinIO bucket. Object locking enforces Write-Once Read-Many (WORM) immutability to protect versioned objects from deletion. +--- + +# minio_s3_bucket_retention (Resource) + +Manages object lock retention settings for a MinIO bucket. Object locking enforces Write-Once Read-Many (WORM) immutability to protect versioned objects from deletion. This resource provides compliance with SEC17a-4(f), FINRA 4511(C), and CFTC 1.31(c)-(d) requirements. + +-> **Note** Object locking can only be enabled during bucket creation and requires versioning. You cannot enable object locking on an existing bucket. + +## Example Usage + +### Basic Retention Configuration + +```terraform +# First, create a bucket with object locking enabled +resource "minio_s3_bucket" "example" { + bucket = "my-bucket" + force_destroy = true + object_locking = true +} + +# Configure retention with COMPLIANCE mode +resource "minio_s3_bucket_retention" "example" { + bucket = minio_s3_bucket.example.bucket + mode = "COMPLIANCE" + unit = "DAYS" + validity_period = 30 + +} +``` + +### Governance Mode Configuration + +```terraform +resource "minio_s3_bucket_retention" "governance_example" { + bucket = minio_s3_bucket.example.bucket + mode = "GOVERNANCE" + unit = "YEARS" + validity_period = 1 +} +``` + +## Interaction with Lifecycle Rules + +If a bucket has lifecycle rules configured, the retention settings will take precedence. Objects cannot be deleted by lifecycle rules until their retention period expires. The provider will issue a warning if lifecycle rules are detected during retention configuration. + +## Schema + +### Required + +- `bucket` (String) Name of the bucket to configure object locking. The bucket must be created with object locking enabled. +- `mode` (String) Retention mode for the bucket. Valid values are: + - `GOVERNANCE`: Prevents object modification by non-privileged users. Users with s3:BypassGovernanceRetention permission can modify objects. + - `COMPLIANCE`: Prevents any object modification by all users, including the root user, until retention period expires. +- `unit` (String) Time unit for the validity period. Valid values are `DAYS` or `YEARS`. +- `validity_period` (Number) Duration for which objects should be retained under WORM lock, in the specified unit. Must be a positive integer. + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Bucket retention configuration can be imported using the bucket name: + +```shell +$ terraform import minio_s3_bucket_retention.example my-bucket +``` \ No newline at end of file diff --git a/examples/resources/minio_s3_bucket_retention/main.tf b/examples/resources/minio_s3_bucket_retention/main.tf new file mode 100644 index 00000000..2afcbbdc --- /dev/null +++ b/examples/resources/minio_s3_bucket_retention/main.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + version = ">= 3.0.0" + } + } +} + +provider "minio" { + minio_server = var.minio_server + minio_region = var.minio_region + minio_user = var.minio_user + minio_password = var.minio_password +} + diff --git a/examples/resources/minio_s3_bucket_retention/resource.tf b/examples/resources/minio_s3_bucket_retention/resource.tf new file mode 100644 index 00000000..2c123108 --- /dev/null +++ b/examples/resources/minio_s3_bucket_retention/resource.tf @@ -0,0 +1,13 @@ +resource "minio_s3_bucket" "test_bucket" { + bucket = "test-retention-bucket" + force_destroy = true + object_locking = false +} + +# Add basic retention configuration +resource "minio_s3_bucket_retention" "test_retention" { + bucket = minio_s3_bucket.test_bucket.bucket + mode = "GOVERNANCE" + unit = "YEARS" + validity_period = 1 +} diff --git a/examples/resources/minio_s3_bucket_retention/variables.tf b/examples/resources/minio_s3_bucket_retention/variables.tf new file mode 100644 index 00000000..c44044c2 --- /dev/null +++ b/examples/resources/minio_s3_bucket_retention/variables.tf @@ -0,0 +1,19 @@ +variable "minio_region" { + description = "Default MINIO region" + default = "us-east-1" +} + +variable "minio_server" { + description = "Default MINIO host and port" + default = "localhost:9000" +} + +variable "minio_user" { + description = "MINIO user" + default = "minio" +} + +variable "minio_password" { + description = "MINIO password" + default = "minio123" +} \ No newline at end of file diff --git a/minio/provider.go b/minio/provider.go index bd10575e..1ad764d3 100644 --- a/minio/provider.go +++ b/minio/provider.go @@ -132,6 +132,7 @@ func newProvider(envvarPrefixed ...string) *schema.Provider { "minio_s3_bucket_policy": resourceMinioBucketPolicy(), "minio_s3_bucket_versioning": resourceMinioBucketVersioning(), "minio_s3_bucket_replication": resourceMinioBucketReplication(), + "minio_s3_bucket_retention": resourceMinioBucketRetention(), "minio_s3_bucket_notification": resourceMinioBucketNotification(), "minio_s3_bucket_server_side_encryption": resourceMinioBucketServerSideEncryption(), "minio_s3_object": resourceMinioObject(), diff --git a/minio/resource_minio_s3_bucket_retention.go b/minio/resource_minio_s3_bucket_retention.go new file mode 100644 index 00000000..fd1ef502 --- /dev/null +++ b/minio/resource_minio_s3_bucket_retention.go @@ -0,0 +1,247 @@ +package minio + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/minio/minio-go/v7" +) + +var ValidityUnits = map[minio.ValidityUnit]bool{ + minio.Days: true, + minio.Years: true, +} + +func resourceMinioBucketRetention() *schema.Resource { + return &schema.Resource{ + CreateContext: minioCreateRetention, + ReadContext: minioReadRetention, + UpdateContext: minioUpdateRetention, + DeleteContext: minioDeleteRetention, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Description: `Manages object lock retention settings for a MinIO bucket. Object locking enforces Write-Once Read-Many (WORM) immutability to protect versioned objects from deletion. + +Note: Object locking can only be enabled during bucket creation and requires versioning. You cannot enable object locking on an existing bucket. + +This resource provides compliance with SEC17a-4(f), FINRA 4511(C), and CFTC 1.31(c)-(d) requirements.`, + + Schema: map[string]*schema.Schema{ + "bucket": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(0, 63)), + Description: "Name of the bucket to configure object locking. The bucket must be created with object locking enabled.", + }, + "mode": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validateRetentionMode, + Description: `Retention mode for the bucket. Valid values are: + - GOVERNANCE: Prevents object modification by non-privileged users. Users with s3:BypassGovernanceRetention permission can modify objects. + - COMPLIANCE: Prevents any object modification by all users, including the root user, until retention period expires.`, + }, + "unit": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validateRetentionUnit, + Description: "Time unit for the validity period. Valid values are DAYS or YEARS.", + }, + "validity_period": { + Type: schema.TypeInt, + Required: true, + ValidateDiagFunc: validateValidityPeriod, + Description: "Duration for which objects should be retained under WORM lock, in the specified unit. Must be a positive integer.", + }, + }, + } +} + +func validateRetentionMode(v interface{}, p cty.Path) diag.Diagnostics { + mode := minio.RetentionMode(v.(string)) + if !mode.IsValid() { + return diag.Errorf("retention mode must be either GOVERNANCE or COMPLIANCE, got: %s", mode) + } + return nil +} + +func validateRetentionUnit(v interface{}, p cty.Path) diag.Diagnostics { + unit := minio.ValidityUnit(v.(string)) + if !ValidityUnits[unit] { + return diag.Errorf("validity unit must be either DAYS or YEARS, got: %s", unit) + } + return nil +} +func validateValidityPeriod(v interface{}, p cty.Path) diag.Diagnostics { + value := v.(int) + if value < 1 { + return diag.Errorf("validity period must be positive, got: %d", value) + } + return nil +} + +func validateBucketObjectLock(ctx context.Context, client *minio.Client, bucket string) error { + // Check if bucket exists + exists, err := client.BucketExists(ctx, bucket) + if err != nil { + return fmt.Errorf("error checking bucket existence: %w", err) + } + if !exists { + return fmt.Errorf("bucket %s does not exist", bucket) + } + + // Check if versioning is enabled (required for object locking) + versioning, err := client.GetBucketVersioning(ctx, bucket) + if err != nil { + return fmt.Errorf("error checking bucket versioning: %w", err) + } + + if !versioning.Enabled() { // Use the method, not the field + return fmt.Errorf("bucket %s does not have versioning enabled. Object locking requires versioning", bucket) + } + + // Check if object lock is enabled + objectLock, _, _, _, err := client.GetObjectLockConfig(ctx, bucket) + if err != nil { + if strings.Contains(err.Error(), "Object Lock configuration does not exist") { + return fmt.Errorf("bucket %s does not have object lock enabled. Object lock must be enabled when creating the bucket", bucket) + } + return fmt.Errorf("error checking object lock configuration: %w", err) + } + + if objectLock != "Enabled" { + return fmt.Errorf("bucket %s does not have object lock enabled. Object lock must be enabled when creating the bucket", bucket) + } + + return nil +} + +func minioCreateRetention(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*S3MinioClient).S3Client + bucket := d.Get("bucket").(string) + var diags diag.Diagnostics + + // Validate bucket object lock status before proceeding + if err := validateBucketObjectLock(ctx, client, bucket); err != nil { + return diag.FromErr(err) + } + + if hasLifecycleRules(ctx, client, bucket) { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Bucket has lifecycle rules configured", + Detail: "This bucket has lifecycle management rules. Note that object expiration respects retention " + + "settings. Objects cannot be deleted by lifecycle rules until their retention period expires.", + }) + } + + mode := minio.RetentionMode(d.Get("mode").(string)) + unit := minio.ValidityUnit(d.Get("unit").(string)) + validity := uint(d.Get("validity_period").(int)) + + err := client.SetBucketObjectLockConfig(ctx, bucket, &mode, &validity, &unit) + if err != nil { + return diag.FromErr(fmt.Errorf("error setting bucket object lock config: %w", err)) + } + + d.SetId(bucket) + + readDiags := minioReadRetention(ctx, d, meta) + diags = append(diags, readDiags...) + return diags +} + +func minioReadRetention(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*S3MinioClient).S3Client + + // First check if bucket still exists + exists, err := client.BucketExists(ctx, d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error checking bucket existence: %w", err)) + } + if !exists { + d.SetId("") + return nil + } + + mode, validity, unit, err := client.GetBucketObjectLockConfig(ctx, d.Id()) + if err != nil { + // Check if the error indicates the retention config is gone + if strings.Contains(err.Error(), "Object Lock configuration does not exist") { + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error reading bucket retention config: %w", err)) + } + + // If any of the required fields are nil, the retention config is effectively gone + if mode == nil || validity == nil || unit == nil { + d.SetId("") + return nil + } + + if err := d.Set("bucket", d.Id()); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("mode", mode.String()); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("validity_period", *validity); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("unit", unit.String()); err != nil { + return diag.FromErr(err) + } + + return nil +} +func minioUpdateRetention(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*S3MinioClient).S3Client + bucket := d.Id() + + // Validate bucket object lock status before proceeding + if err := validateBucketObjectLock(ctx, client, bucket); err != nil { + return diag.FromErr(err) + } + + if d.HasChanges("mode", "unit", "validity_period") { + mode := minio.RetentionMode(d.Get("mode").(string)) + unit := minio.ValidityUnit(d.Get("unit").(string)) + validity := uint(d.Get("validity_period").(int)) + + err := client.SetBucketObjectLockConfig(ctx, bucket, &mode, &validity, &unit) + if err != nil { + return diag.FromErr(fmt.Errorf("error updating bucket object lock config: %w", err)) + } + } + + return minioReadRetention(ctx, d, meta) +} + +func minioDeleteRetention(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*S3MinioClient).S3Client + + // To clear object lock config, we pass nil for all optional parameters + err := client.SetBucketObjectLockConfig(ctx, d.Id(), nil, nil, nil) + if err != nil { + return diag.FromErr(fmt.Errorf("error clearing bucket object lock config: %v", err)) + } + + d.SetId("") + return nil +} + +func hasLifecycleRules(ctx context.Context, client *minio.Client, bucket string) bool { + _, err := client.GetBucketLifecycle(ctx, bucket) + return err == nil +} diff --git a/minio/resource_minio_s3_bucket_retention_test.go b/minio/resource_minio_s3_bucket_retention_test.go new file mode 100644 index 00000000..9d0d6f42 --- /dev/null +++ b/minio/resource_minio_s3_bucket_retention_test.go @@ -0,0 +1,192 @@ +package minio + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccMinioBucketRetention_basic(t *testing.T) { + bucketName := fmt.Sprintf("tf-test-bucket-%d", acctest.RandInt()) + resourceName := "minio_s3_bucket_retention.retention" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviders, + CheckDestroy: testAccCheckMinioBucketRetentionDestroy, + Steps: []resource.TestStep{ + { + Config: testAccMinioBucketRetentionConfig_basic(bucketName), + Check: resource.ComposeTestCheckFunc( + testAccCheckMinioBucketRetentionExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "bucket", bucketName), + resource.TestCheckResourceAttr(resourceName, "mode", "COMPLIANCE"), + resource.TestCheckResourceAttr(resourceName, "unit", "DAYS"), + resource.TestCheckResourceAttr(resourceName, "validity_period", "30"), + ), + }, + }, + }) +} + +func TestAccMinioBucketRetention_update(t *testing.T) { + bucketName := fmt.Sprintf("tf-test-bucket-%d", acctest.RandInt()) + resourceName := "minio_s3_bucket_retention.retention" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviders, + CheckDestroy: testAccCheckMinioBucketRetentionDestroy, + Steps: []resource.TestStep{ + { + Config: testAccMinioBucketRetentionConfig_basic(bucketName), + Check: resource.ComposeTestCheckFunc( + testAccCheckMinioBucketRetentionExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "mode", "COMPLIANCE"), + resource.TestCheckResourceAttr(resourceName, "unit", "DAYS"), + resource.TestCheckResourceAttr(resourceName, "validity_period", "30"), + ), + }, + { + Config: testAccMinioBucketRetentionConfig_update(bucketName), + Check: resource.ComposeTestCheckFunc( + testAccCheckMinioBucketRetentionExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "mode", "GOVERNANCE"), + resource.TestCheckResourceAttr(resourceName, "unit", "YEARS"), + resource.TestCheckResourceAttr(resourceName, "validity_period", "1"), + ), + }, + }, + }) +} + +func TestAccMinioBucketRetention_disappears(t *testing.T) { + bucketName := fmt.Sprintf("tf-test-bucket-%d", acctest.RandInt()) + resourceName := "minio_s3_bucket_retention.retention" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviders, + CheckDestroy: testAccCheckMinioBucketRetentionDestroy, + Steps: []resource.TestStep{ + { + Config: testAccMinioBucketRetentionConfig_basic(bucketName), + Check: resource.ComposeTestCheckFunc( + testAccCheckMinioBucketRetentionExists(resourceName), + testAccCheckMinioBucketRetentionDisappears(resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckMinioBucketRetentionExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + client := testAccProvider.Meta().(*S3MinioClient).S3Client + mode, validity, unit, err := client.GetBucketObjectLockConfig(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting bucket retention: %w", err) + } + + if mode == nil || validity == nil || unit == nil { + return fmt.Errorf("retention configuration not found") + } + + return nil + } +} + +func testAccCheckMinioBucketRetentionDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*S3MinioClient).S3Client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "minio_s3_bucket_retention" { + continue + } + + // Try to get retention config + mode, _, _, err := client.GetBucketObjectLockConfig(context.Background(), rs.Primary.ID) + if err == nil && mode != nil { + return fmt.Errorf("bucket retention still exists") + } + } + + return nil +} + +func testAccCheckMinioBucketRetentionDisappears(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + client := testAccProvider.Meta().(*S3MinioClient).S3Client + + // Clear the retention configuration + err := client.SetBucketObjectLockConfig(context.Background(), rs.Primary.ID, nil, nil, nil) + if err != nil { + return fmt.Errorf("error clearing bucket retention: %w", err) + } + + // Force a read of the configuration to update state + mode, _, _, err := client.GetBucketObjectLockConfig(context.Background(), rs.Primary.ID) + if err == nil && mode != nil { + return fmt.Errorf("bucket retention still exists after clearing") + } + + return nil + } +} + +func testAccMinioBucketRetentionConfig_basic(bucketName string) string { + return fmt.Sprintf(` +resource "minio_s3_bucket" "test" { + bucket = %[1]q + acl = "private" + force_destroy = true + object_locking = true +} + +resource "minio_s3_bucket_retention" "retention" { + bucket = minio_s3_bucket.test.bucket + mode = "COMPLIANCE" + unit = "DAYS" + validity_period = 30 + +} +`, bucketName) +} + +func testAccMinioBucketRetentionConfig_update(bucketName string) string { + return fmt.Sprintf(` +resource "minio_s3_bucket" "test" { + bucket = %[1]q + acl = "private" + force_destroy = true + object_locking = true +} + +resource "minio_s3_bucket_retention" "retention" { + bucket = minio_s3_bucket.test.bucket + mode = "GOVERNANCE" + unit = "YEARS" + validity_period = 1 + +} +`, bucketName) +}