Files
seaweedFS/weed/admin/dash/bucket_management.go
Chris Lu a1eab5ff99 shell: add -owner flag to s3.bucket.create command (#7728)
* shell: add -owner flag to s3.bucket.create command

This fixes an issue where buckets created via weed shell cannot be accessed
by non-admin S3 users because the bucket has no owner set.

When using S3 IAM authentication, non-admin users can only access buckets
they own. Buckets created via lazy S3 creation automatically have their
owner set from the request context, but buckets created via weed shell
had no owner, making them inaccessible to non-admin users.

The new -owner flag allows setting the bucket owner identity (s3-identity-id)
at creation time:

    s3.bucket.create -name my-bucket -owner my-identity-name

Fixes: https://github.com/seaweedfs/seaweedfs/discussions/7599

* shell: add s3.bucket.owner command to view/change bucket ownership

This command allows viewing and changing the owner of an S3 bucket,
making it easier to manage bucket access for IAM users.

Usage:
    # View the current owner of a bucket
    s3.bucket.owner -name my-bucket

    # Set or change the owner of a bucket
    s3.bucket.owner -name my-bucket -set -owner new-identity

    # Remove the owner (make bucket admin-only)
    s3.bucket.owner -name my-bucket -set -owner ""

* shell: show bucket owner in s3.bucket.list output

Display the bucket owner (s3-identity-id) when listing buckets,
making it easier to see which identity owns each bucket.

Example output:
  my-bucket    size:1024    chunk:5    owner:my-identity

* admin: add bucket owner support to admin UI

- Add Owner field to S3Bucket struct for displaying bucket ownership
- Add Owner field to CreateBucketRequest for setting owner at creation
- Add UpdateBucketOwner API endpoint (PUT /api/s3/buckets/:bucket/owner)
- Add SetBucketOwner function for updating bucket ownership
- Update GetS3Buckets to populate owner from s3-identity-id extended attribute
- Update CreateS3BucketWithObjectLock to set owner when creating bucket

This allows the admin UI to display bucket owners and supports creating/
editing bucket ownership, which is essential for S3 IAM authentication
where non-admin users can only access buckets they own.

* admin: show bucket owner in buckets list and create form

- Add Owner column to buckets table to display bucket ownership
- Add Owner field to create bucket form for setting owner at creation
- Show owner in bucket details modal
- Update JavaScript to include owner when creating buckets

This makes bucket ownership visible and configurable from the admin UI,
which is essential for S3 IAM authentication where non-admin users can
only access buckets they own.

* admin: add bucket owner management with user dropdown

- Add 'Manage Owner' button to bucket actions
- Add modal with dropdown to select owner from existing users
- Fetch users from /api/users endpoint to populate dropdown
- Update create bucket form to use dropdown for owner selection
- Allow setting owner to empty (no owner = admin-only access)

This provides a user-friendly way to manage bucket ownership by selecting
from existing S3 identities rather than manually typing identity names.

* fix: use username instead of name for user dropdown

The /api/users endpoint returns 'username' field, not 'name'.
Fixed both the manage owner modal and create bucket form.

* Update s3_buckets_templ.go

* fix: address code review feedback for s3.bucket.create

- Check if entry.Extended is nil before making a new map to prevent
  overwriting any previously set extended attributes
- Use fmt.Fprintln(writer, ...) instead of println() for consistent
  output handling across the shell command framework

* fix: improve help text and validate owner input

- Add note that -owner value should match identity name in s3.json
- Trim whitespace from owner and treat whitespace-only as empty

* fix: address code review feedback for list and owner commands

- s3.bucket.list: Use %q to escape owner value and prevent malformed
  tabular output from special characters (tabs/newlines/control chars)
- s3.bucket.owner: Use neutral error message for lookup failures since
  they can occur for reasons other than missing bucket (e.g., permission)

* fix: improve s3.bucket.owner CLI UX

- Remove confusing -set flag that was required but not shown in examples
- Add explicit -delete flag to remove owner (safer than empty string)
- Presence of -owner now implies set operation (no extra flag needed)
- Validate that -owner and -delete cannot be used together
- Trim whitespace from owner value
- Update help text with correct examples and add note about identity name
- Clearer success messages for each operation

* fix: address code review feedback for admin UI

- GetBucketDetails: Extract and return owner from extended attributes
- CSV export: Fix column indices after adding Owner column, add Owner to header
- XSS prevention: Add escapeHtml() function to sanitize user data in innerHTML
  (bucket.name, bucket.owner, bucket.object_lock_mode, obj.key, obj.storage_class)

* fix: address additional code review feedback

- types.go: Add omitempty to Owner JSON tag, update comment
- bucket_management.go: Trim and validate owner (max 256 chars) in CreateBucket
- bucket_management.go: Use neutral error message in SetBucketOwner lookup

* fix: improve owner field handling and error recovery

bucket_management.go:
- Use *string pointer for Owner to detect if field was explicitly provided
- Return HTTP 400 if owner field is missing (use empty string to clear)
- Trim and validate owner (max 256 chars) in UpdateBucketOwner

s3_buckets.templ:
- Re-enable owner select dropdown on fetch error
- Reset dropdown to default 'No owner' option on error
- Allow users to retry or continue without selecting an owner

* fix: move modal instance variables to global scope

Move deleteModalInstance, quotaModalInstance, ownerModalInstance,
detailsModalInstance, and cachedUsers to global scope so they are
accessible from both DOMContentLoaded handlers and global functions
like deleteBucket(). This fixes the undefined variable issue.

* refactor: improve modal handling and avoid global window properties

- Initialize modal instances once on DOMContentLoaded and reuse with show()
- Replace window.currentBucket* global properties with data attributes on forms
- Remove modal dispose/recreate pattern and unnecessary cleanup code
- Scope state to relevant DOM elements instead of global namespace

* Update s3_buckets_templ.go

* fix: define MaxOwnerNameLength constant and implement RFC 4180 CSV escaping

bucket_management.go:
- Add MaxOwnerNameLength constant (256) with documentation
- Replace magic number 256 with constant in both validation checks

s3_buckets.templ:
- Add escapeCsvField() helper for RFC 4180 compliant CSV escaping
- Properly handle commas, double quotes, and newlines in field values
- Escape internal quotes by doubling them (")→("")

* Update s3_buckets_templ.go

* refactor: use direct gRPC client methods for consistency

- command_s3_bucket_create.go: Use client.CreateEntry instead of filer_pb.CreateEntry
- command_s3_bucket_owner.go: Use client.LookupDirectoryEntry instead of filer_pb.LookupEntry
- command_s3_bucket_owner.go: Use client.UpdateEntry instead of filer_pb.UpdateEntry

This aligns with the pattern used in weed/admin/dash/bucket_management.go
2025-12-12 18:06:13 -08:00

498 lines
15 KiB
Go

package dash
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"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"`
Buckets []S3Bucket `json:"buckets"`
TotalBuckets int `json:"total_buckets"`
TotalSize int64 `json:"total_size"`
LastUpdated time.Time `json:"last_updated"`
}
type CreateBucketRequest struct {
Name string `json:"name" binding:"required"`
Region string `json:"region"`
QuotaSize int64 `json:"quota_size"` // Quota size in bytes
QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
VersioningEnabled bool `json:"versioning_enabled"` // Whether versioning is enabled
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
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
// ShowS3Buckets displays the Object Store buckets management page
func (s *AdminServer) ShowS3Buckets(c *gin.Context) {
username := c.GetString("username")
buckets, err := s.GetS3Buckets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()})
return
}
// Calculate totals
var totalSize int64
for _, bucket := range buckets {
totalSize += bucket.Size
}
data := S3BucketsData{
Username: username,
Buckets: buckets,
TotalBuckets: len(buckets),
TotalSize: totalSize,
LastUpdated: time.Now(),
}
c.JSON(http.StatusOK, data)
}
// ShowBucketDetails displays detailed information about a specific bucket
func (s *AdminServer) ShowBucketDetails(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
details, err := s.GetBucketDetails(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
return
}
c.JSON(http.StatusOK, details)
}
// CreateBucket creates a new S3 bucket
func (s *AdminServer) CreateBucket(c *gin.Context) {
var req CreateBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Validate bucket name (basic validation)
if len(req.Name) < 3 || len(req.Name) > 63 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"})
return
}
// Validate object lock settings
if req.ObjectLockEnabled {
// Object lock requires versioning to be enabled
req.VersioningEnabled = true
// Validate object lock mode
if req.ObjectLockMode != "GOVERNANCE" && req.ObjectLockMode != "COMPLIANCE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock mode must be either GOVERNANCE or COMPLIANCE"})
return
}
// Validate retention duration if default retention is enabled
if req.SetDefaultRetention {
if req.ObjectLockDuration <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock duration must be greater than 0 days when default retention is enabled"})
return
}
}
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
// 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
}
c.JSON(http.StatusCreated, gin.H{
"message": "Bucket created successfully",
"bucket": req.Name,
"quota_size": req.QuotaSize,
"quota_unit": req.QuotaUnit,
"quota_enabled": req.QuotaEnabled,
"versioning_enabled": req.VersioningEnabled,
"object_lock_enabled": req.ObjectLockEnabled,
"object_lock_mode": req.ObjectLockMode,
"object_lock_duration": req.ObjectLockDuration,
"owner": owner,
})
}
// UpdateBucketQuota updates the quota settings for a bucket
func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
var req struct {
QuotaSize int64 `json:"quota_size"`
QuotaUnit string `json:"quota_unit"`
QuotaEnabled bool `json:"quota_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
err := s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket quota: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Bucket quota updated successfully",
"bucket": bucketName,
"quota_size": req.QuotaSize,
"quota_unit": req.QuotaUnit,
"quota_enabled": req.QuotaEnabled,
})
}
// DeleteBucket deletes an S3 bucket
func (s *AdminServer) DeleteBucket(c *gin.Context) {
bucketName := c.Param("bucket")
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
return
}
err := s.DeleteS3Bucket(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Bucket deleted successfully",
"bucket": bucketName,
})
}
// 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()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get buckets: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"buckets": buckets,
"total": len(buckets),
})
}
// Helper function to convert quota size and unit to bytes
func convertQuotaToBytes(size int64, unit string) int64 {
if size <= 0 {
return 0
}
switch strings.ToUpper(unit) {
case "TB":
return size * 1024 * 1024 * 1024 * 1024
case "GB":
return size * 1024 * 1024 * 1024
case "MB":
return size * 1024 * 1024
default:
// Default to MB if unit is not recognized
return size * 1024 * 1024
}
}
// Helper function to convert bytes to appropriate unit and size
func convertBytesToQuota(bytes int64) (int64, string) {
if bytes == 0 {
return 0, "MB"
}
// Convert to TB if >= 1TB
if bytes >= 1024*1024*1024*1024 && bytes%(1024*1024*1024*1024) == 0 {
return bytes / (1024 * 1024 * 1024 * 1024), "TB"
}
// Convert to GB if >= 1GB
if bytes >= 1024*1024*1024 && bytes%(1024*1024*1024) == 0 {
return bytes / (1024 * 1024 * 1024), "GB"
}
// Convert to MB (default)
return bytes / (1024 * 1024), "MB"
}
// SetBucketQuota sets the quota for a bucket
func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaEnabled bool) 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("bucket not found: %w", err)
}
bucketEntry := lookupResp.Entry
// Determine quota value (negative if disabled)
var quota int64
if quotaEnabled && quotaBytes > 0 {
quota = quotaBytes
} else if !quotaEnabled && quotaBytes > 0 {
quota = -quotaBytes
} else {
quota = 0
}
// Update the quota
bucketEntry.Quota = quota
// 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 quota: %w", err)
}
return nil
})
}
// 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, "")
}
// 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{
Directory: "/",
Entry: &filer_pb.Entry{
Name: "buckets",
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: uint32(1000),
Gid: uint32(1000),
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
},
},
})
// Ignore error if directory already exists
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "existing entry") {
return fmt.Errorf("failed to create /buckets directory: %w", err)
}
// Check if bucket already exists
_, err = client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: "/buckets",
Name: bucketName,
})
if err == nil {
return fmt.Errorf("bucket %s already exists", bucketName)
}
// Determine quota value (negative if disabled)
var quota int64
if quotaEnabled && quotaBytes > 0 {
quota = quotaBytes
} else if !quotaEnabled && quotaBytes > 0 {
quota = -quotaBytes
} else {
quota = 0
}
// Prepare bucket attributes with versioning and object lock metadata
attributes := &filer_pb.FuseAttributes{
FileMode: uint32(0755 | os.ModeDir), // Directory mode
Uid: filer_pb.OS_UID,
Gid: filer_pb.OS_GID,
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
TtlSec: 0,
}
// 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,
IsDirectory: true,
Attributes: attributes,
Extended: extended,
Quota: quota,
}
// Handle versioning using shared utilities
if err := s3api.StoreVersioningInExtended(bucketEntry, versioningEnabled); err != nil {
return fmt.Errorf("failed to store versioning configuration: %w", err)
}
// Handle Object Lock configuration using shared utilities
if objectLockEnabled {
var duration int32 = 0
if setDefaultRetention {
// Validate Object Lock parameters only when setting default retention
if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil {
return fmt.Errorf("invalid Object Lock parameters: %w", err)
}
duration = objectLockDuration
}
// Create Object Lock configuration using shared utility
objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, objectLockMode, duration)
// Store Object Lock configuration in extended attributes using shared utility
if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil {
return fmt.Errorf("failed to store Object Lock configuration: %w", err)
}
}
// Create bucket directory under /buckets
_, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: "/buckets",
Entry: bucketEntry,
})
if err != nil {
return fmt.Errorf("failed to create bucket directory: %w", err)
}
return nil
})
}