diff --git a/cmd/server/bucket_journey_test.go b/cmd/server/bucket_journey_test.go index a1bc7b0..99c1349 100644 --- a/cmd/server/bucket_journey_test.go +++ b/cmd/server/bucket_journey_test.go @@ -46,6 +46,7 @@ func TestBucketJourney(t *testing.T) { mockClient.On("DataUsageInfo", mock.Anything).Return(madmin.DataUsageInfo{ BucketSizes: map[string]uint64{"bucket-1": 1024, "bucket-2": 2048}, }, nil) + mockClient.On("GetBucketPolicy", mock.Anything, mock.Anything).Return("", nil) mockClient.On("MakeBucket", mock.Anything, "newbucket", mock.Anything).Return(nil) mockClient.On("RemoveBucket", mock.Anything, "newbucket").Return(nil) @@ -117,6 +118,7 @@ func TestObjectBrowserJourney(t *testing.T) { IsTruncated: false, NextContinuationToken: "", }, nil) + mockClient.On("GetBucketPolicy", mock.Anything, "my-bucket").Return("", nil) mockClient.On("PutObject", mock.Anything, "my-bucket", "testfile.txt", mock.Anything, mock.Anything, mock.Anything).Return(minio.UploadInfo{}, nil) encrypted, _ := authService.EncryptCredentials(creds) diff --git a/cmd/server/main.go b/cmd/server/main.go index b4c3aad..949a771 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -140,6 +140,8 @@ func newServer(minioEndpoint string) *echo.Echo { e.GET("/buckets/:bucketName/replication", bucketsHandler.GetReplication) e.GET("/buckets/:bucketName/quota", bucketsHandler.GetBucketQuota) e.POST("/buckets/:bucketName/quota", bucketsHandler.SetBucketQuota) + e.GET("/buckets/:bucketName/policy", bucketsHandler.GetBucketPolicy) + e.POST("/buckets/:bucketName/policy", bucketsHandler.SetBucketPolicy) e.GET("/settings", settingsHandler.ShowSettings) e.POST("/settings/restart", settingsHandler.RestartService) diff --git a/cmd/server/mocks_test.go b/cmd/server/mocks_test.go index 25e0cb5..e8ce82c 100644 --- a/cmd/server/mocks_test.go +++ b/cmd/server/mocks_test.go @@ -243,6 +243,16 @@ func (m *MockMinioClient) GetBucketReplication(ctx context.Context, bucketName s return args.Get(0).(replication.Config), args.Error(1) } +func (m *MockMinioClient) GetBucketPolicy(ctx context.Context, bucketName string) (string, error) { + args := m.Called(ctx, bucketName) + return args.String(0), args.Error(1) +} + +func (m *MockMinioClient) SetBucketPolicy(ctx context.Context, bucketName, policy string) error { + args := m.Called(ctx, bucketName, policy) + return args.Error(0) +} + // MockMinioFactory implements MinioClientFactory for testing type MockMinioFactory struct { mock.Mock diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index 00c6296..45b9af4 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -2,12 +2,14 @@ package handlers import ( "archive/zip" + "encoding/json" "fmt" "io" "net/http" "path/filepath" "strconv" "strings" + "sync" "time" "github.com/damacus/iron-buckets/internal/models" @@ -30,6 +32,43 @@ func NewBucketsHandler(minioFactory services.MinioClientFactory) *BucketsHandler return &BucketsHandler{minioFactory: minioFactory} } +// canonicalJSON re-serializes JSON to a canonical form (sorted keys, no extra whitespace). +func canonicalJSON(raw string) string { + var obj interface{} + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return "" + } + b, err := json.Marshal(obj) + if err != nil { + return "" + } + return string(b) +} + +// detectPolicyType determines whether a policy matches a known preset +// by comparing canonical JSON representations. +func detectPolicyType(policyJSON string, bucketName string) string { + if policyJSON == "" { + return "private" + } + canonical := canonicalJSON(policyJSON) + if canonical == "" { + return "custom" + } + + presets := map[string]string{ + "public-read": fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucketName), + "public-read-write": fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucketName), + } + + for name, preset := range presets { + if canonical == canonicalJSON(preset) { + return name + } + } + return "custom" +} + // ListBuckets renders the buckets page func (h *BucketsHandler) ListBuckets(c echo.Context) error { creds, err := GetCredentialsOrRedirect(c) @@ -60,19 +99,38 @@ func (h *BucketsHandler) ListBuckets(c echo.Context) error { minio.BucketInfo Size uint64 FormattedSize string + PolicyType string + } + + // Fetch bucket policies concurrently + policyTypes := make([]string, len(buckets)) + var wg sync.WaitGroup + for i, b := range buckets { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + policy, err := client.GetBucketPolicy(c.Request().Context(), name) + if err != nil { + policyTypes[idx] = "unknown" + return + } + policyTypes[idx] = detectPolicyType(policy, name) + }(i, b.Name) } + wg.Wait() - var bucketsWithStats []BucketWithStats - for _, b := range buckets { + bucketsWithStats := make([]BucketWithStats, len(buckets)) + for i, b := range buckets { size := uint64(0) if usage.BucketSizes != nil { size = usage.BucketSizes[b.Name] } - bucketsWithStats = append(bucketsWithStats, BucketWithStats{ + bucketsWithStats[i] = BucketWithStats{ BucketInfo: b, Size: size, FormattedSize: utils.FormatBytes(size), - }) + PolicyType: policyTypes[i], + } } return c.Render(http.StatusOK, "buckets", map[string]interface{}{ @@ -237,6 +295,30 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { } } + // Construct MinIO endpoint base URL + scheme := "https" + if !services.ShouldUseSSL(creds.Endpoint) { + scheme = "http" + } + endpointURL := scheme + "://" + creds.Endpoint + + // Fetch bucket policy + policy, _ := client.GetBucketPolicy(c.Request().Context(), bucketName) + policyType := detectPolicyType(policy, bucketName) + formattedPolicy := "" + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } else { + formattedPolicy = policy + } + } else { + formattedPolicy = policy + } + } + return c.Render(http.StatusOK, "browser", map[string]interface{}{ "ActiveNav": "buckets", "BucketName": bucketName, @@ -246,6 +328,11 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { "Breadcrumbs": breadcrumbs, "HasMore": result.IsTruncated, "NextContinuationToken": result.NextContinuationToken, + "PolicyType": policyType, + "Policy": policy, + "FormattedPolicy": formattedPolicy, + "HasPolicy": policy != "", + "EndpointURL": endpointURL, }) } @@ -579,11 +666,31 @@ func (h *BucketsHandler) BucketSettings(c echo.Context) error { } } + // Get bucket policy + policy, _ := client.GetBucketPolicy(c.Request().Context(), bucketName) + policyType := detectPolicyType(policy, bucketName) + formattedPolicy := "" + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } else { + formattedPolicy = policy + } + } else { + formattedPolicy = policy + } + } + return c.Render(http.StatusOK, "bucket_settings", map[string]interface{}{ "ActiveNav": "buckets", "BucketName": bucketName, "VersioningStatus": versioningStatus, "VersioningEnabled": versioningConfig.Enabled(), + "PolicyType": policyType, + "FormattedPolicy": formattedPolicy, + "HasPolicy": policy != "", }) } @@ -1171,3 +1278,137 @@ func isPreviewable(contentType string, size int64) bool { } return isImageType(contentType) || isTextType(contentType) || isVideoType(contentType) } + +// GetBucketPolicy returns the policy for a bucket +func (h *BucketsHandler) GetBucketPolicy(c echo.Context) error { + bucketName := c.Param("bucketName") + + creds, err := GetCredentials(c) + if err != nil { + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "private", + "Error": "Unauthorized", + }) + } + + client, err := h.minioFactory.NewClient(*creds) + if err != nil { + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "private", + "Error": "Failed to connect to MinIO", + }) + } + + policy, err := client.GetBucketPolicy(c.Request().Context(), bucketName) + if err != nil { + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "unknown", + "Error": "Failed to fetch policy: " + err.Error(), + }) + } + + // Determine policy type for display + policyType := detectPolicyType(policy, bucketName) + + // Format JSON for display + formattedPolicy := policy + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } + } + } + + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": formattedPolicy, + "PolicyType": policyType, + "HasPolicy": policy != "", + }) +} + +// SetBucketPolicy sets the policy for a bucket +func (h *BucketsHandler) SetBucketPolicy(c echo.Context) error { + creds, err := GetCredentials(c) + if err != nil { + return err + } + + bucketName := c.Param("bucketName") + policyType := c.FormValue("policyType") + customPolicy := c.FormValue("policy") + + client, err := h.minioFactory.NewClient(*creds) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO") + } + + var policy string + + switch policyType { + case "private": + // Empty policy = private (remove policy) + policy = "" + case "public-read": + policy = fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucketName) + case "public-read-write": + policy = fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucketName) + case "custom": + policy = customPolicy + // Validate JSON if not empty + if policy != "" { + var jsonObj interface{} + if err := json.Unmarshal([]byte(policy), &jsonObj); err != nil { + return c.Render(http.StatusBadRequest, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": policy, + "PolicyType": "custom", + "HasPolicy": true, + "Error": "Invalid JSON: " + err.Error(), + }) + } + } + default: + return echo.NewHTTPError(http.StatusBadRequest, "Invalid policy type") + } + + if err := client.SetBucketPolicy(c.Request().Context(), bucketName, policy); err != nil { + return c.Render(http.StatusInternalServerError, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": policy, + "PolicyType": policyType, + "HasPolicy": policy != "", + "Error": "Failed to set policy: " + err.Error(), + }) + } + + return HTMXRedirect(c, "/buckets/"+bucketName+"/settings") +} diff --git a/internal/handlers/policy_test.go b/internal/handlers/policy_test.go new file mode 100644 index 0000000..e645c61 --- /dev/null +++ b/internal/handlers/policy_test.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanonicalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"invalid json", "not json", ""}, + {"already compact", `{"a":1}`, `{"a":1}`}, + {"with whitespace", `{ "a" : 1 }`, `{"a":1}`}, + {"sorts keys", `{"b":2,"a":1}`, `{"a":1,"b":2}`}, + {"nested sorting", `{"z":{"b":2,"a":1},"a":0}`, `{"a":0,"z":{"a":1,"b":2}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, canonicalJSON(tt.input)) + }) + } +} + +func TestDetectPolicyType(t *testing.T) { + bucket := "my-bucket" + + publicReadPolicy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket) + + publicReadWritePolicy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket) + + tests := []struct { + name string + policyJSON string + bucketName string + expected string + }{ + { + name: "empty policy is private", + policyJSON: "", + bucketName: bucket, + expected: "private", + }, + { + name: "invalid json is custom", + policyJSON: "not valid json", + bucketName: bucket, + expected: "custom", + }, + { + name: "public-read preset", + policyJSON: publicReadPolicy, + bucketName: bucket, + expected: "public-read", + }, + { + name: "public-read-write preset", + policyJSON: publicReadWritePolicy, + bucketName: bucket, + expected: "public-read-write", + }, + { + name: "public-read with different formatting", + policyJSON: fmt.Sprintf(`{"Statement":[{"Action":["s3:GetObject"],"Effect":"Allow","Principal":{"AWS":["*"]},"Resource":["arn:aws:s3:::%s/*"]}],"Version":"2012-10-17"}`, bucket), + bucketName: bucket, + expected: "public-read", + }, + { + name: "wrong bucket name is custom", + policyJSON: publicReadPolicy, + bucketName: "other-bucket", + expected: "custom", + }, + { + name: "custom policy with s3:GetObject should not match public-read", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + }, + { + "Effect": "Deny", + "Principal": {"AWS": ["arn:aws:iam::123456:user/bad"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/secret/*"] + } + ] +}`, bucket, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with extra actions is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with restricted principal is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::123456:root"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with condition is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"], + "Condition": {"IpAddress": {"aws:SourceIp": "192.168.1.0/24"}} + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectPolicyType(tt.policyJSON, tt.bucketName) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index bc2cdca..ee6f476 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -67,6 +67,7 @@ func (t *TemplateRenderer) parseTemplates() { t.Templates["replication"] = template.Must(template.ParseFiles("views/partials/replication.html")) t.Templates["versioning_status"] = template.Must(template.ParseFiles("views/partials/versioning_status.html")) t.Templates["bucket_quota"] = template.Must(template.ParseFiles("views/partials/bucket_quota.html")) + t.Templates["bucket_policy"] = template.Must(template.ParseFiles("views/partials/bucket_policy.html")) t.Templates["logs"] = template.Must(template.ParseFiles("views/partials/logs.html")) } @@ -90,6 +91,7 @@ var selfExecutingTemplates = map[string]bool{ "replication": true, "versioning_status": true, "bucket_quota": true, + "bucket_policy": true, "logs": true, } diff --git a/internal/services/minio_factory.go b/internal/services/minio_factory.go index cf9942c..b1c1618 100644 --- a/internal/services/minio_factory.go +++ b/internal/services/minio_factory.go @@ -107,6 +107,10 @@ type MinioClient interface { // Replication GetBucketReplication(ctx context.Context, bucketName string) (replication.Config, error) + + // Bucket Policy + GetBucketPolicy(ctx context.Context, bucketName string) (string, error) + SetBucketPolicy(ctx context.Context, bucketName, policy string) error } // MinioClientFactory creates authenticated clients @@ -262,11 +266,23 @@ func (c *WrappedMinioClient) GetBucketReplication(ctx context.Context, bucketNam return c.client.GetBucketReplication(ctx, bucketName) } +func (c *WrappedMinioClient) GetBucketPolicy(ctx context.Context, bucketName string) (string, error) { + return c.client.GetBucketPolicy(ctx, bucketName) +} + +func (c *WrappedMinioClient) SetBucketPolicy(ctx context.Context, bucketName, policy string) error { + return c.client.SetBucketPolicy(ctx, bucketName, policy) +} + // RealMinioFactory is the production implementation type RealMinioFactory struct{} -// shouldUseSSL determines if SSL should be used based on the endpoint. +// ShouldUseSSL determines if SSL should be used based on the endpoint. // Returns false for localhost, 127.0.0.1, and docker service names. +func ShouldUseSSL(endpoint string) bool { + return shouldUseSSL(endpoint) +} + func shouldUseSSL(endpoint string) bool { // Local development endpoints if endpoint == "localhost:9000" || endpoint == "127.0.0.1:9000" { diff --git a/views/layouts/base.html b/views/layouts/base.html index e230ef0..18ca840 100644 --- a/views/layouts/base.html +++ b/views/layouts/base.html @@ -55,8 +55,7 @@ -
+Preview not available for this file type
+ +Preview not available for this file type
+ + + Download to view + +{{ .FormattedPolicy }}
+ No policy configured (Private)
+Only the bucket owner can access objects
+