Skip to content
2 changes: 2 additions & 0 deletions cmd/server/bucket_journey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions cmd/server/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
249 changes: 245 additions & 4 deletions internal/handlers/buckets_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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 != "",
})
}

Expand Down Expand Up @@ -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")
}
Loading
Loading