diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index c499ca8fe..eeeccf981 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -26,6 +26,7 @@ import ( "google.golang.org/grpc" "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" ) @@ -317,11 +318,12 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { quotaEnabled = false } - // Get versioning and object lock information from extended attributes + // Get versioning, object lock, and owner information from extended attributes versioningEnabled := false objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 + var owner string if resp.Entry.Extended != nil { // Use shared utility to extract versioning information @@ -329,6 +331,11 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry) + + // Extract owner information + if ownerBytes, ok := resp.Entry.Extended[s3_constants.AmzIdentityId]; ok { + owner = string(ownerBytes) + } } bucket := S3Bucket{ @@ -343,6 +350,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { ObjectLockEnabled: objectLockEnabled, ObjectLockMode: objectLockMode, ObjectLockDuration: objectLockDuration, + Owner: owner, } buckets = append(buckets, bucket) } @@ -394,11 +402,12 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error details.Bucket.Quota = quota details.Bucket.QuotaEnabled = quotaEnabled - // Get versioning and object lock information from extended attributes + // Get versioning, object lock, and owner information from extended attributes versioningEnabled := false objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 + var owner string if bucketResp.Entry.Extended != nil { // Use shared utility to extract versioning information @@ -406,12 +415,18 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry) + + // Extract owner information + if ownerBytes, ok := bucketResp.Entry.Extended[s3_constants.AmzIdentityId]; ok { + owner = string(ownerBytes) + } } details.Bucket.VersioningEnabled = versioningEnabled details.Bucket.ObjectLockEnabled = objectLockEnabled details.Bucket.ObjectLockMode = objectLockMode details.Bucket.ObjectLockDuration = objectLockDuration + details.Bucket.Owner = owner // List objects in bucket (recursively) return s.listBucketObjects(client, bucketPath, "", details) diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index 5942d5695..eb99e9fa4 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -11,8 +11,14 @@ import ( "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" ) +// MaxOwnerNameLength is the maximum allowed length for bucket owner identity names. +// This is a reasonable limit to prevent abuse; AWS IAM user names are limited to 64 chars, +// but we use 256 to allow for more complex identity formats (e.g., email addresses). +const MaxOwnerNameLength = 256 + // S3 Bucket management data structures for templates type S3BucketsData struct { Username string `json:"username"` @@ -33,6 +39,7 @@ type CreateBucketRequest struct { ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE" SetDefaultRetention bool `json:"set_default_retention"` // Whether to set default retention ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days + Owner string `json:"owner"` // Bucket owner identity (for S3 IAM authentication) } // S3 Bucket Management Handlers @@ -118,7 +125,14 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { // Convert quota to bytes quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit) - err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration) + // Sanitize owner: trim whitespace and enforce max length + owner := strings.TrimSpace(req.Owner) + if len(owner) > MaxOwnerNameLength { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + return + } + + err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()}) return @@ -134,6 +148,7 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { "object_lock_enabled": req.ObjectLockEnabled, "object_lock_mode": req.ObjectLockMode, "object_lock_duration": req.ObjectLockDuration, + "owner": owner, }) } @@ -193,6 +208,88 @@ func (s *AdminServer) DeleteBucket(c *gin.Context) { }) } +// UpdateBucketOwner updates the owner of an S3 bucket +func (s *AdminServer) UpdateBucketOwner(c *gin.Context) { + bucketName := c.Param("bucket") + if bucketName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"}) + return + } + + // Use pointer to detect if owner field was explicitly provided + var req struct { + Owner *string `json:"owner"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Require owner field to be explicitly provided + if req.Owner == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Owner field is required (use empty string to clear owner)"}) + return + } + + // Trim and validate owner + owner := strings.TrimSpace(*req.Owner) + if len(owner) > MaxOwnerNameLength { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)}) + return + } + + err := s.SetBucketOwner(bucketName, owner) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket owner: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Bucket owner updated successfully", + "bucket": bucketName, + "owner": owner, + }) +} + +// SetBucketOwner sets the owner of a bucket +func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error { + return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + // Get the current bucket entry + lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{ + Directory: "/buckets", + Name: bucketName, + }) + if err != nil { + return fmt.Errorf("lookup bucket %s: %w", bucketName, err) + } + + bucketEntry := lookupResp.Entry + + // Initialize Extended map if nil + if bucketEntry.Extended == nil { + bucketEntry.Extended = make(map[string][]byte) + } + + // Set or remove the owner + if owner == "" { + delete(bucketEntry.Extended, s3_constants.AmzIdentityId) + } else { + bucketEntry.Extended[s3_constants.AmzIdentityId] = []byte(owner) + } + + // Update the entry + _, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{ + Directory: "/buckets", + Entry: bucketEntry, + }) + if err != nil { + return fmt.Errorf("failed to update bucket owner: %w", err) + } + + return nil + }) +} + // ListBucketsAPI returns the list of buckets as JSON func (s *AdminServer) ListBucketsAPI(c *gin.Context) { buckets, err := s.GetS3Buckets() @@ -288,11 +385,11 @@ func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaE // CreateS3BucketWithQuota creates a new S3 bucket with quota settings func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error { - return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0) + return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0, "") } -// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, and object lock settings -func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32) error { +// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, object lock settings, and owner +func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32, owner string) error { return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { // First ensure /buckets directory exists _, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{ @@ -344,9 +441,14 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes TtlSec: 0, } - // Create extended attributes map for versioning + // Create extended attributes map for versioning and owner extended := make(map[string][]byte) + // Set bucket owner if specified + if owner != "" { + extended[s3_constants.AmzIdentityId] = []byte(owner) + } + // Create bucket entry bucketEntry := &filer_pb.Entry{ Name: bucketName, diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index ec2692321..5c2ac60e8 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -82,6 +82,7 @@ type S3Bucket struct { ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE" ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days + Owner string `json:"owner,omitempty"` // Bucket owner identity; empty means admin-only access } type S3Object struct { diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index b1f465d2e..31fa08113 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -119,6 +119,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket) s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota) + s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner) } // User management API routes @@ -245,6 +246,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket) s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota) + s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner) } // User management API routes diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 14117ba9f..524b5a5ad 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -113,6 +113,7 @@ templ S3Buckets(data dash.S3BucketsData) { Name + Owner Created Objects Size @@ -132,6 +133,15 @@ templ S3Buckets(data dash.S3BucketsData) { {bucket.Name} + + if bucket.Owner != "" { + + {bucket.Owner} + + } else { + No owner + } + {bucket.CreatedAt.Format("2006-01-02 15:04")} {fmt.Sprintf("%d", bucket.ObjectCount)} {formatBytes(bucket.Size)} @@ -193,6 +203,13 @@ templ S3Buckets(data dash.S3BucketsData) { title="View Details"> + + +
+ + +
+ + + + ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/shell/command_s3_bucket_create.go b/weed/shell/command_s3_bucket_create.go index becbd96e7..ee6d3ec6a 100644 --- a/weed/shell/command_s3_bucket_create.go +++ b/weed/shell/command_s3_bucket_create.go @@ -4,11 +4,14 @@ import ( "context" "flag" "fmt" - "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" - "github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket" "io" "os" + "strings" "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket" ) func init() { @@ -27,6 +30,15 @@ func (c *commandS3BucketCreate) Help() string { Example: s3.bucket.create -name + s3.bucket.create -name -owner + + The -owner flag sets the bucket owner identity. This is important when using + S3 IAM authentication, as non-admin users can only access buckets they own. + If not specified, the bucket will have no owner and will only be accessible + by admin users. + + The -owner value should match the identity name configured in your S3 IAM + system (the "name" field in s3.json identities configuration). ` } @@ -38,6 +50,7 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError) bucketName := bucketCommand.String("name", "", "bucket name") + bucketOwner := bucketCommand.String("owner", "", "bucket owner identity name (for S3 IAM authentication)") if err = bucketCommand.Parse(args); err != nil { return nil } @@ -51,6 +64,9 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer return err } + // Trim whitespace from owner and treat whitespace-only as empty + owner := strings.TrimSpace(*bucketOwner) + err = commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{}) @@ -59,7 +75,7 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer } filerBucketsPath := resp.DirBuckets - println("create bucket under", filerBucketsPath) + fmt.Fprintln(writer, "create bucket under", filerBucketsPath) entry := &filer_pb.Entry{ Name: *bucketName, @@ -71,14 +87,25 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer }, } - if err := filer_pb.CreateEntry(context.Background(), client, &filer_pb.CreateEntryRequest{ + // Set bucket owner if specified + if owner != "" { + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + entry.Extended[s3_constants.AmzIdentityId] = []byte(owner) + } + + if _, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{ Directory: filerBucketsPath, Entry: entry, }); err != nil { return err } - println("created bucket", *bucketName) + fmt.Fprintln(writer, "created bucket", *bucketName) + if owner != "" { + fmt.Fprintln(writer, "bucket owner:", owner) + } return nil diff --git a/weed/shell/command_s3_bucket_list.go b/weed/shell/command_s3_bucket_list.go index 031b22d2d..bb55fc013 100644 --- a/weed/shell/command_s3_bucket_list.go +++ b/weed/shell/command_s3_bucket_list.go @@ -8,6 +8,7 @@ import ( "math" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" ) func init() { @@ -71,6 +72,12 @@ func (c *commandS3BucketList) Do(args []string, commandEnv *CommandEnv, writer i if entry.Quota > 0 { fmt.Fprintf(writer, "\tquota:%d\tusage:%.2f%%", entry.Quota, float64(collectionSize)*100/float64(entry.Quota)) } + // Show bucket owner (use %q to escape special characters) + if entry.Extended != nil { + if owner, ok := entry.Extended[s3_constants.AmzIdentityId]; ok && len(owner) > 0 { + fmt.Fprintf(writer, "\towner:%q", string(owner)) + } + } fmt.Fprintln(writer) return nil }, "", false, math.MaxUint32) diff --git a/weed/shell/command_s3_bucket_owner.go b/weed/shell/command_s3_bucket_owner.go new file mode 100644 index 000000000..881cb730c --- /dev/null +++ b/weed/shell/command_s3_bucket_owner.go @@ -0,0 +1,150 @@ +package shell + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/util" +) + +func init() { + Commands = append(Commands, &commandS3BucketOwner{}) +} + +type commandS3BucketOwner struct { +} + +func (c *commandS3BucketOwner) Name() string { + return "s3.bucket.owner" +} + +func (c *commandS3BucketOwner) Help() string { + return `view or change the owner of an S3 bucket + + Example: + # View the current owner of a bucket + s3.bucket.owner -name + + # Set or change the owner of a bucket + s3.bucket.owner -name -owner + + # Remove the owner (make bucket admin-only) + s3.bucket.owner -name -delete + + The owner identity determines which S3 user can access the bucket. + Non-admin users can only access buckets they own. Admin users can + access all buckets regardless of ownership. + + The -owner value should match the identity name configured in your + S3 IAM system (the "name" field in s3.json identities configuration). +` +} + +func (c *commandS3BucketOwner) HasTag(CommandTag) bool { + return false +} + +func (c *commandS3BucketOwner) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) { + + bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError) + bucketName := bucketCommand.String("name", "", "bucket name") + bucketOwner := bucketCommand.String("owner", "", "new bucket owner identity name") + deleteOwner := bucketCommand.Bool("delete", false, "remove the bucket owner (make admin-only)") + if err = bucketCommand.Parse(args); err != nil { + return nil + } + + if *bucketName == "" { + return fmt.Errorf("empty bucket name") + } + + // Trim whitespace from owner + owner := strings.TrimSpace(*bucketOwner) + + // Validate flags: can't use both -owner and -delete + if owner != "" && *deleteOwner { + return fmt.Errorf("cannot use both -owner and -delete flags together") + } + + err = commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + + resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{}) + if err != nil { + return fmt.Errorf("get filer configuration: %w", err) + } + filerBucketsPath := resp.DirBuckets + + // Look up the bucket entry + lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{ + Directory: filerBucketsPath, + Name: *bucketName, + }) + if err != nil { + return fmt.Errorf("lookup bucket %s: %w", *bucketName, err) + } + + entry := lookupResp.Entry + + // If -owner is provided, set the owner + if owner != "" { + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + entry.Extended[s3_constants.AmzIdentityId] = []byte(owner) + fmt.Fprintf(writer, "Setting owner of bucket %s to: %s\n", *bucketName, owner) + + // Update the entry + if _, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{ + Directory: filerBucketsPath, + Entry: entry, + }); err != nil { + return fmt.Errorf("failed to update bucket: %w", err) + } + + fmt.Fprintf(writer, "Bucket owner updated successfully.\n") + return nil + } + + // If -delete is provided, remove the owner + if *deleteOwner { + if entry.Extended != nil { + delete(entry.Extended, s3_constants.AmzIdentityId) + } + fmt.Fprintf(writer, "Removing owner from bucket %s\n", *bucketName) + + // Update the entry + if _, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{ + Directory: filerBucketsPath, + Entry: entry, + }); err != nil { + return fmt.Errorf("failed to update bucket: %w", err) + } + + fmt.Fprintf(writer, "Bucket owner removed. Bucket is now admin-only.\n") + return nil + } + + // Display current owner (no flags provided) + fmt.Fprintf(writer, "Bucket: %s\n", *bucketName) + fmt.Fprintf(writer, "Path: %s\n", util.NewFullPath(filerBucketsPath, *bucketName)) + + if entry.Extended != nil { + if ownerBytes, ok := entry.Extended[s3_constants.AmzIdentityId]; ok && len(ownerBytes) > 0 { + fmt.Fprintf(writer, "Owner: %s\n", string(ownerBytes)) + } else { + fmt.Fprintf(writer, "Owner: (none - admin access only)\n") + } + } else { + fmt.Fprintf(writer, "Owner: (none - admin access only)\n") + } + + return nil + }) + + return err +}