diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 610f2288f..34ce82329 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -623,7 +623,7 @@ func (s *AdminServer) DeleteS3Bucket(bucketName string) error { } // GetObjectStoreUsers retrieves object store users from identity.json -func (s *AdminServer) GetObjectStoreUsers() ([]ObjectStoreUser, error) { +func (s *AdminServer) GetObjectStoreUsers(ctx context.Context) ([]ObjectStoreUser, error) { s3cfg := &iam_pb.S3ApiConfiguration{} // Load IAM configuration from filer @@ -656,6 +656,11 @@ func (s *AdminServer) GetObjectStoreUsers() ([]ObjectStoreUser, error) { continue } + // Skip service accounts - they should not be parent users + if strings.HasPrefix(identity.Name, serviceAccountPrefix) { + continue + } + user := ObjectStoreUser{ Username: identity.Name, Permissions: identity.Actions, diff --git a/weed/admin/dash/service_account_helpers.go b/weed/admin/dash/service_account_helpers.go new file mode 100644 index 000000000..226f7ec41 --- /dev/null +++ b/weed/admin/dash/service_account_helpers.go @@ -0,0 +1,53 @@ +package dash + +import ( + "fmt" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +// identityToServiceAccount converts an IAM identity to a ServiceAccount struct +// This helper reduces code duplication across GetServiceAccounts, GetServiceAccountDetails, +// UpdateServiceAccount, and GetServiceAccountByAccessKey +func identityToServiceAccount(identity *iam_pb.Identity) (*ServiceAccount, error) { + if identity == nil { + return nil, fmt.Errorf("identity cannot be nil") + } + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + return nil, fmt.Errorf("not a service account: %s", identity.GetName()) + } + + parts := strings.SplitN(identity.GetName(), ":", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid service account ID format") + } + + sa := &ServiceAccount{ + ID: identity.GetName(), + ParentUser: parts[1], + Status: StatusActive, + CreateDate: getCreationDate(identity.GetActions()), + Expiration: getExpiration(identity.GetActions()), + } + + // Get description from account display name + if identity.Account != nil { + sa.Description = identity.Account.GetDisplayName() + } + + // Get access key from credentials + if len(identity.Credentials) > 0 { + sa.AccessKeyId = identity.Credentials[0].GetAccessKey() + } + + // Check if disabled + for _, action := range identity.GetActions() { + if action == disabledAction { + sa.Status = StatusInactive + break + } + } + + return sa, nil +} diff --git a/weed/admin/dash/service_account_management.go b/weed/admin/dash/service_account_management.go new file mode 100644 index 000000000..165313888 --- /dev/null +++ b/weed/admin/dash/service_account_management.go @@ -0,0 +1,434 @@ +package dash + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +var ( + // ErrServiceAccountNotFound is returned when a service account is not found + ErrServiceAccountNotFound = errors.New("service account not found") +) + +const ( + createdAtActionPrefix = "createdAt:" + expirationActionPrefix = "expiresAt:" + disabledAction = "__disabled__" + serviceAccountPrefix = "sa:" + accessKeyPrefix = "ABIA" // Service account access keys use ABIA prefix + + // Status constants + StatusActive = "Active" + StatusInactive = "Inactive" +) + +// Helper functions for managing creation timestamps in actions +func getCreationDate(actions []string) time.Time { + for _, action := range actions { + if strings.HasPrefix(action, createdAtActionPrefix) { + timestampStr := strings.TrimPrefix(action, createdAtActionPrefix) + if timestamp, err := strconv.ParseInt(timestampStr, 10, 64); err == nil { + return time.Unix(timestamp, 0) + } + } + } + return time.Time{} // Return zero time for legacy service accounts without stored creation date +} + +func setCreationDate(actions []string, createDate time.Time) []string { + // Remove any existing createdAt action + filtered := make([]string, 0, len(actions)+1) + for _, action := range actions { + if !strings.HasPrefix(action, createdAtActionPrefix) { + filtered = append(filtered, action) + } + } + // Add new createdAt action + filtered = append(filtered, fmt.Sprintf("%s%d", createdAtActionPrefix, createDate.Unix())) + return filtered +} + +// Helper functions for managing expiration timestamps in actions +func getExpiration(actions []string) time.Time { + for _, action := range actions { + if strings.HasPrefix(action, expirationActionPrefix) { + timestampStr := strings.TrimPrefix(action, expirationActionPrefix) + if timestamp, err := strconv.ParseInt(timestampStr, 10, 64); err == nil { + return time.Unix(timestamp, 0) + } + } + } + return time.Time{} // No expiration set +} + +func setExpiration(actions []string, expiration time.Time) []string { + // Remove any existing expiration action + filtered := make([]string, 0, len(actions)+1) + for _, action := range actions { + if !strings.HasPrefix(action, expirationActionPrefix) { + filtered = append(filtered, action) + } + } + // Add new expiration action if not zero + if !expiration.IsZero() { + filtered = append(filtered, fmt.Sprintf("%s%d", expirationActionPrefix, expiration.Unix())) + } + return filtered +} + +// GetServiceAccounts returns all service accounts, optionally filtered by parent user +// NOTE: Service accounts are stored as special identities with "sa:" prefix +func (s *AdminServer) GetServiceAccounts(ctx context.Context, parentUser string) ([]ServiceAccount, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + // Load the current configuration to find service account identities + config, err := s.credentialManager.LoadConfiguration(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %w", err) + } + + var accounts []ServiceAccount + + // Service accounts are stored as identities with "sa:" prefix in their name + // Format: "sa::" + for _, identity := range config.GetIdentities() { + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + continue + } + + parts := strings.SplitN(identity.GetName(), ":", 3) + if len(parts) < 3 { + continue + } + + parent := parts[1] + saId := identity.GetName() + + // Filter by parent user if specified + if parentUser != "" && parent != parentUser { + continue + } + + // Extract description from account display name if available + description := "" + status := StatusActive + if identity.Account != nil { + description = identity.Account.GetDisplayName() + } + + // Get access key from credentials + accessKey := "" + if len(identity.Credentials) > 0 { + accessKey = identity.Credentials[0].GetAccessKey() + // Service accounts use ABIA prefix + if !strings.HasPrefix(accessKey, accessKeyPrefix) { + continue // Not a service account + } + } + + // Check if disabled (stored in actions) + for _, action := range identity.GetActions() { + if action == disabledAction { + status = StatusInactive + break + } + } + + accounts = append(accounts, ServiceAccount{ + ID: saId, + ParentUser: parent, + Description: description, + AccessKeyId: accessKey, + Status: status, + CreateDate: getCreationDate(identity.GetActions()), + Expiration: getExpiration(identity.GetActions()), + }) + } + + return accounts, nil +} + +// GetServiceAccountDetails returns detailed information about a specific service account +func (s *AdminServer) GetServiceAccountDetails(ctx context.Context, id string) (*ServiceAccount, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + // Get the identity + identity, err := s.credentialManager.GetUser(ctx, id) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) + } + + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + return nil, fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) + } + + parts := strings.SplitN(identity.GetName(), ":", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid service account ID format") + } + + account := &ServiceAccount{ + ID: id, + ParentUser: parts[1], + Status: StatusActive, + CreateDate: getCreationDate(identity.GetActions()), + Expiration: getExpiration(identity.GetActions()), + } + + if identity.Account != nil { + account.Description = identity.Account.GetDisplayName() + } + + if len(identity.Credentials) > 0 { + account.AccessKeyId = identity.Credentials[0].GetAccessKey() + } + + // Check if disabled + for _, action := range identity.GetActions() { + if action == disabledAction { + account.Status = StatusInactive + break + } + } + + return account, nil +} + +// CreateServiceAccount creates a new service account for a parent user +func (s *AdminServer) CreateServiceAccount(ctx context.Context, req CreateServiceAccountRequest) (*ServiceAccount, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + // Validate parent user exists + _, err := s.credentialManager.GetUser(ctx, req.ParentUser) + if err != nil { + return nil, fmt.Errorf("parent user not found: %s", req.ParentUser) + } + + // Generate unique ID and credentials + uuid := generateAccountId() + saId := fmt.Sprintf("sa:%s:%s", req.ParentUser, uuid) + accessKey := accessKeyPrefix + generateAccessKey()[len(accessKeyPrefix):] // Use ABIA prefix for service accounts + secretKey := generateSecretKey() + + // Create the service account as a special identity + now := time.Now() + + // Parse expiration if provided + var expiration time.Time + if req.Expiration != "" { + var err error + expiration, err = time.Parse(time.RFC3339, req.Expiration) + if err != nil { + return nil, fmt.Errorf("invalid expiration format: %w", err) + } + } + + identity := &iam_pb.Identity{ + Name: saId, + Account: &iam_pb.Account{ + Id: uuid, + DisplayName: req.Description, + }, + Credentials: []*iam_pb.Credential{ + { + AccessKey: accessKey, + SecretKey: secretKey, + }, + }, + // Store creation date and expiration in actions + Actions: setExpiration(setCreationDate([]string{}, now), expiration), + } + + // Create the service account + err = s.credentialManager.CreateUser(ctx, identity) + if err != nil { + return nil, fmt.Errorf("failed to create service account: %w", err) + } + + glog.V(1).Infof("Created service account %s for user %s", saId, req.ParentUser) + + return &ServiceAccount{ + ID: saId, + ParentUser: req.ParentUser, + Description: req.Description, + AccessKeyId: accessKey, + SecretAccessKey: secretKey, // Only returned on creation + Status: StatusActive, + CreateDate: now, + Expiration: expiration, + }, nil +} + +// UpdateServiceAccount updates an existing service account +func (s *AdminServer) UpdateServiceAccount(ctx context.Context, id string, req UpdateServiceAccountRequest) (*ServiceAccount, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + // Get existing identity + identity, err := s.credentialManager.GetUser(ctx, id) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) + } + + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + return nil, fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) + } + + // Update description if provided + if req.Description != "" { + if identity.Account == nil { + identity.Account = &iam_pb.Account{} + } + identity.Account.DisplayName = req.Description + } + + // Update status by adding/removing disabled action + if req.Status != "" { + // Remove existing disabled marker + newActions := make([]string, 0, len(identity.Actions)) + for _, action := range identity.Actions { + if action != disabledAction { + newActions = append(newActions, action) + } + } + // Add disabled action if setting to Inactive + if req.Status == StatusInactive { + newActions = append(newActions, disabledAction) + } + identity.Actions = newActions + } + + // Update expiration if provided + if req.Expiration != "" { + var expiration time.Time + var err error + expiration, err = time.Parse(time.RFC3339, req.Expiration) + if err != nil { + return nil, fmt.Errorf("invalid expiration format: %w", err) + } + identity.Actions = setExpiration(identity.Actions, expiration) + } + + // Update the identity + err = s.credentialManager.UpdateUser(ctx, id, identity) + if err != nil { + return nil, fmt.Errorf("failed to update service account: %w", err) + } + + glog.V(1).Infof("Updated service account %s", id) + + // Build response + parts := strings.SplitN(id, ":", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid service account ID format") + } + + result := &ServiceAccount{ + ID: id, + ParentUser: parts[1], + Description: identity.Account.GetDisplayName(), + Status: StatusActive, + CreateDate: getCreationDate(identity.Actions), + } + + if len(identity.Credentials) > 0 { + result.AccessKeyId = identity.Credentials[0].GetAccessKey() + } + + for _, action := range identity.Actions { + if action == disabledAction { + result.Status = StatusInactive + break + } + } + + return result, nil +} + +// DeleteServiceAccount deletes a service account +func (s *AdminServer) DeleteServiceAccount(ctx context.Context, id string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + + // Verify it's a service account + identity, err := s.credentialManager.GetUser(ctx, id) + if err != nil { + return fmt.Errorf("%w: %s", ErrServiceAccountNotFound, id) + } + + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + return fmt.Errorf("%w: not a service account: %s", ErrServiceAccountNotFound, id) + } + + // Delete the identity + err = s.credentialManager.DeleteUser(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete service account: %w", err) + } + + glog.V(1).Infof("Deleted service account %s", id) + return nil +} + +// GetServiceAccountByAccessKey finds a service account by its access key +func (s *AdminServer) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*ServiceAccount, error) { + if !strings.HasPrefix(accessKey, accessKeyPrefix) { + return nil, fmt.Errorf("not a service account access key") + } + + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + // Find identity by access key + identity, err := s.credentialManager.GetUserByAccessKey(ctx, accessKey) + if err != nil { + return nil, fmt.Errorf("service account not found for access key: %s", accessKey) + } + + if !strings.HasPrefix(identity.GetName(), serviceAccountPrefix) { + return nil, fmt.Errorf("not a service account") + } + + parts := strings.SplitN(identity.GetName(), ":", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid service account ID format") + } + + account := &ServiceAccount{ + ID: identity.GetName(), + ParentUser: parts[1], + AccessKeyId: accessKey, + Status: StatusActive, + CreateDate: getCreationDate(identity.GetActions()), + Expiration: getExpiration(identity.GetActions()), + } + + if identity.Account != nil { + account.Description = identity.Account.GetDisplayName() + } + + for _, action := range identity.GetActions() { + if action == disabledAction { + account.Status = StatusInactive + break + } + } + + return account, nil +} diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index 46fad0a5e..4881243a5 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -552,3 +552,49 @@ type CollectionDetailsData struct { SortBy string `json:"sort_by"` SortOrder string `json:"sort_order"` } + +// Service Account management structures +type ServiceAccount struct { + ID string `json:"id"` + ParentUser string `json:"parent_user"` + Description string `json:"description,omitempty"` + AccessKeyId string `json:"access_key_id,omitempty"` + SecretAccessKey string `json:"secret_access_key,omitempty"` // Only returned on creation + Status string `json:"status"` + CreateDate time.Time `json:"create_date"` + Expiration time.Time `json:"expiration,omitempty"` + // ServiceAccountId is used when returning a single ID in some API responses + ServiceAccountId string `json:"service_account_id,omitempty"` + // ServiceAccountIds is used when returning a list of IDs owned by a user + ServiceAccountIds []string `json:"service_account_ids,omitempty"` +} + +type ServiceAccountsData struct { + Username string `json:"username"` + ServiceAccounts []ServiceAccount `json:"service_accounts"` + TotalAccounts int `json:"total_accounts"` + ActiveAccounts int `json:"active_accounts"` + AvailableUsers []string `json:"available_users"` // For parent user dropdown + LastUpdated time.Time `json:"last_updated"` +} + +type CreateServiceAccountRequest struct { + ParentUser string `json:"parent_user"` + Description string `json:"description,omitempty"` + Expiration string `json:"expiration,omitempty"` // RFC3339 format +} + +type UpdateServiceAccountRequest struct { + Status string `json:"status,omitempty"` // Active, Inactive + Description string `json:"description,omitempty"` + Expiration string `json:"expiration,omitempty"` +} + +// STS Configuration display types +type STSConfigData struct { + Enabled bool `json:"enabled"` + Issuer string `json:"issuer,omitempty"` + TokenDuration string `json:"token_duration,omitempty"` + Providers []string `json:"providers,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 216e4801b..4c493427f 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -14,14 +14,15 @@ import ( // AdminHandlers contains all the HTTP handlers for the admin interface type AdminHandlers struct { - adminServer *dash.AdminServer - authHandlers *AuthHandlers - clusterHandlers *ClusterHandlers - fileBrowserHandlers *FileBrowserHandlers - userHandlers *UserHandlers - policyHandlers *PolicyHandlers - maintenanceHandlers *MaintenanceHandlers - mqHandlers *MessageQueueHandlers + adminServer *dash.AdminServer + authHandlers *AuthHandlers + clusterHandlers *ClusterHandlers + fileBrowserHandlers *FileBrowserHandlers + userHandlers *UserHandlers + policyHandlers *PolicyHandlers + maintenanceHandlers *MaintenanceHandlers + mqHandlers *MessageQueueHandlers + serviceAccountHandlers *ServiceAccountHandlers } // NewAdminHandlers creates a new instance of AdminHandlers @@ -33,15 +34,17 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { policyHandlers := NewPolicyHandlers(adminServer) maintenanceHandlers := NewMaintenanceHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer) + serviceAccountHandlers := NewServiceAccountHandlers(adminServer) return &AdminHandlers{ - adminServer: adminServer, - authHandlers: authHandlers, - clusterHandlers: clusterHandlers, - fileBrowserHandlers: fileBrowserHandlers, - userHandlers: userHandlers, - policyHandlers: policyHandlers, - maintenanceHandlers: maintenanceHandlers, - mqHandlers: mqHandlers, + adminServer: adminServer, + authHandlers: authHandlers, + clusterHandlers: clusterHandlers, + fileBrowserHandlers: fileBrowserHandlers, + userHandlers: userHandlers, + policyHandlers: policyHandlers, + maintenanceHandlers: maintenanceHandlers, + mqHandlers: mqHandlers, + serviceAccountHandlers: serviceAccountHandlers, } } @@ -77,6 +80,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies) + protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) // File browser routes protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -143,6 +147,16 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies) } + // Service Account management API routes + saApi := api.Group("/service-accounts") + { + saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts) + saApi.POST("", dash.RequireWriteAccess(), h.serviceAccountHandlers.CreateServiceAccount) + saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails) + saApi.PUT("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.UpdateServiceAccount) + saApi.DELETE("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.DeleteServiceAccount) + } + // Object Store Policy management API routes objectStorePoliciesApi := api.Group("/object-store/policies") { @@ -207,6 +221,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails) r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers) r.GET("/object-store/policies", h.policyHandlers.ShowPolicies) + r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts) // File browser routes r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser) @@ -272,6 +287,16 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) } + // Service Account management API routes + saApi := api.Group("/service-accounts") + { + saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts) + saApi.POST("", h.serviceAccountHandlers.CreateServiceAccount) + saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails) + saApi.PUT("/:id", h.serviceAccountHandlers.UpdateServiceAccount) + saApi.DELETE("/:id", h.serviceAccountHandlers.DeleteServiceAccount) + } + // Object Store Policy management API routes objectStorePoliciesApi := api.Group("/object-store/policies") { diff --git a/weed/admin/handlers/service_account_handlers.go b/weed/admin/handlers/service_account_handlers.go new file mode 100644 index 000000000..9c8c85f9a --- /dev/null +++ b/weed/admin/handlers/service_account_handlers.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "bytes" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/admin/view/app" + "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +// ServiceAccountHandlers contains HTTP handlers for service account management +type ServiceAccountHandlers struct { + adminServer *dash.AdminServer +} + +// NewServiceAccountHandlers creates a new instance of ServiceAccountHandlers +func NewServiceAccountHandlers(adminServer *dash.AdminServer) *ServiceAccountHandlers { + return &ServiceAccountHandlers{ + adminServer: adminServer, + } +} + +// ShowServiceAccounts renders the service accounts management page +func (h *ServiceAccountHandlers) ShowServiceAccounts(c *gin.Context) { + data := h.getServiceAccountsData(c) + + // Render to buffer first to avoid partial writes on error + var buf bytes.Buffer + component := app.ServiceAccounts(data) + layoutComponent := layout.Layout(c, component) + err := layoutComponent.Render(c.Request.Context(), &buf) + if err != nil { + glog.Errorf("Failed to render service accounts template: %v", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + // Only write to response if render succeeded + c.Header("Content-Type", "text/html") + c.Writer.Write(buf.Bytes()) +} + +// GetServiceAccounts returns the list of service accounts as JSON +func (h *ServiceAccountHandlers) GetServiceAccounts(c *gin.Context) { + parentUser := c.Query("parent_user") + + accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), parentUser) + if err != nil { + glog.Errorf("Failed to get service accounts: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service accounts"}) + return + } + c.JSON(http.StatusOK, gin.H{"service_accounts": accounts}) +} + +// CreateServiceAccount handles service account creation +func (h *ServiceAccountHandlers) CreateServiceAccount(c *gin.Context) { + var req dash.CreateServiceAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + if req.ParentUser == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ParentUser is required"}) + return + } + + sa, err := h.adminServer.CreateServiceAccount(c.Request.Context(), req) + if err != nil { + glog.Errorf("Failed to create service account for user %s: %v", req.ParentUser, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service account"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Service account created successfully", + "service_account": sa, + }) +} + +// GetServiceAccountDetails returns detailed information about a service account +func (h *ServiceAccountHandlers) GetServiceAccountDetails(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + return + } + + sa, err := h.adminServer.GetServiceAccountDetails(c.Request.Context(), id) + if err != nil { + // Distinguish not-found errors from internal errors + if errors.Is(err, dash.ErrServiceAccountNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found: " + err.Error()}) + } else { + glog.Errorf("Failed to get service account details for %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service account details"}) + } + return + } + + c.JSON(http.StatusOK, sa) +} + +// UpdateServiceAccount handles service account updates +func (h *ServiceAccountHandlers) UpdateServiceAccount(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + return + } + + var req dash.UpdateServiceAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + sa, err := h.adminServer.UpdateServiceAccount(c.Request.Context(), id, req) + if err != nil { + // Distinguish not-found errors from internal errors + if errors.Is(err, dash.ErrServiceAccountNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) + } else { + glog.Errorf("Failed to update service account %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service account"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Service account updated successfully", + "service_account": sa, + }) +} + +// DeleteServiceAccount handles service account deletion +func (h *ServiceAccountHandlers) DeleteServiceAccount(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"}) + return + } + + err := h.adminServer.DeleteServiceAccount(c.Request.Context(), id) + if err != nil { + // Distinguish not-found errors from internal errors + if errors.Is(err, dash.ErrServiceAccountNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"}) + } else { + glog.Errorf("Failed to delete service account %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service account"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Service account deleted successfully", + }) +} + +// getServiceAccountsData retrieves service accounts data for the template +func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.ServiceAccountsData { + username := c.GetString("username") + if username == "" { + username = "admin" + } + + // Get all service accounts + accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), "") + if err != nil { + glog.Errorf("Failed to get service accounts: %v", err) + return dash.ServiceAccountsData{ + Username: username, + ServiceAccounts: []dash.ServiceAccount{}, + TotalAccounts: 0, + LastUpdated: time.Now(), + } + } + + // Count active accounts + activeCount := 0 + for _, sa := range accounts { + if sa.Status == dash.StatusActive { + activeCount++ + } + } + + // Get available users for dropdown + var availableUsers []string + users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) + if err != nil { + glog.Errorf("Failed to get users for dropdown: %v", err) + } else { + for _, user := range users { + availableUsers = append(availableUsers, user.Username) + } + } + + return dash.ServiceAccountsData{ + Username: username, + ServiceAccounts: accounts, + TotalAccounts: len(accounts), + ActiveAccounts: activeCount, + AvailableUsers: availableUsers, + LastUpdated: time.Now(), + } +} diff --git a/weed/admin/handlers/user_handlers.go b/weed/admin/handlers/user_handlers.go index 9f36848c0..ed280a0ad 100644 --- a/weed/admin/handlers/user_handlers.go +++ b/weed/admin/handlers/user_handlers.go @@ -41,7 +41,7 @@ func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) { // GetUsers returns the list of users as JSON func (h *UserHandlers) GetUsers(c *gin.Context) { - users, err := h.adminServer.GetObjectStoreUsers() + users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users: " + err.Error()}) return @@ -234,7 +234,7 @@ func (h *UserHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreU } // Get object store users - users, err := h.adminServer.GetObjectStoreUsers() + users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context()) if err != nil { glog.Errorf("Failed to get object store users: %v", err) // Return empty data on error diff --git a/weed/admin/view/app/maintenance_queue.templ b/weed/admin/view/app/maintenance_queue.templ index 74540f285..fa56cdb3f 100644 --- a/weed/admin/view/app/maintenance_queue.templ +++ b/weed/admin/view/app/maintenance_queue.templ @@ -320,14 +320,14 @@ templ MaintenanceQueue(data *maintenance.MaintenanceQueueData) { .then(response => response.json()) .then(data => { if (data.success) { - alert('Maintenance scan triggered successfully'); + showToast('Success', 'Maintenance scan triggered successfully', 'success'); setTimeout(() => window.location.reload(), 2000); } else { - alert('Failed to trigger scan: ' + (data.error || 'Unknown error')); + showToast('Error', 'Failed to trigger scan: ' + (data.error || 'Unknown error'), 'danger'); } }) .catch(error => { - alert('Error: ' + error.message); + showToast('Error', 'Error: ' + error.message, 'danger'); }); }; @@ -412,18 +412,4 @@ func formatDuration(d time.Duration) string { } } -func formatTimeAgo(t time.Time) string { - duration := time.Since(t) - if duration < time.Minute { - return "just now" - } else if duration < time.Hour { - minutes := int(duration.Minutes()) - return fmt.Sprintf("%dm ago", minutes) - } else if duration < 24*time.Hour { - hours := int(duration.Hours()) - return fmt.Sprintf("%dh ago", hours) - } else { - days := int(duration.Hours() / 24) - return fmt.Sprintf("%dd ago", days) - } -} \ No newline at end of file + \ No newline at end of file diff --git a/weed/admin/view/app/maintenance_queue_templ.go b/weed/admin/view/app/maintenance_queue_templ.go index 05ecfbef8..bd2f845e5 100644 --- a/weed/admin/view/app/maintenance_queue_templ.go +++ b/weed/admin/view/app/maintenance_queue_templ.go @@ -610,7 +610,7 @@ func MaintenanceQueue(data *maintenance.MaintenanceQueueData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -857,20 +857,4 @@ func formatDuration(d time.Duration) string { } } -func formatTimeAgo(t time.Time) string { - duration := time.Since(t) - if duration < time.Minute { - return "just now" - } else if duration < time.Hour { - minutes := int(duration.Minutes()) - return fmt.Sprintf("%dm ago", minutes) - } else if duration < 24*time.Hour { - hours := int(duration.Hours()) - return fmt.Sprintf("%dh ago", hours) - } else { - days := int(duration.Hours() / 24) - return fmt.Sprintf("%dd ago", days) - } -} - var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/service_accounts.templ b/weed/admin/view/app/service_accounts.templ new file mode 100644 index 000000000..df7e1956d --- /dev/null +++ b/weed/admin/view/app/service_accounts.templ @@ -0,0 +1,653 @@ +package app + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +templ ServiceAccounts(data dash.ServiceAccountsData) { +
+ +
+
+

+ Service Accounts +

+

Manage application credentials for automated processes

+
+
+ +
+
+ + +
+
+
+
+
+
+
+ Total Service Accounts +
+
+ {fmt.Sprintf("%d", data.TotalAccounts)} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Active Accounts +
+
+ {fmt.Sprintf("%d", data.ActiveAccounts)} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Last Updated +
+
+ {data.LastUpdated.Format("15:04")} +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
+ Service Accounts +
+
+
+
+ + + + + + + + + + + + + for _, sa := range data.ServiceAccounts { + + + + + + + + + } + if len(data.ServiceAccounts) == 0 { + + + + } + +
IDParent UserAccess KeyStatusCreatedActions
+
+ + {sa.ID} +
+
+ + {sa.ParentUser} + + {sa.AccessKeyId} + + if sa.Status == "Active" { + Active + } else { + Inactive + } + {sa.CreateDate.Format("2006-01-02")} +
+ + + +
+
+ +
+
No service accounts found
+

Create your first service account for automated processes.

+
+
+
+
+
+
+
+ + +
+
+ + + Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")} + +
+
+
+ + + + + + + + + + + + +} diff --git a/weed/admin/view/app/service_accounts_templ.go b/weed/admin/view/app/service_accounts_templ.go new file mode 100644 index 000000000..3ac8392b2 --- /dev/null +++ b/weed/admin/view/app/service_accounts_templ.go @@ -0,0 +1,283 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.960 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +func ServiceAccounts(data dash.ServiceAccountsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Service Accounts

Manage application credentials for automated processes

Total Service Accounts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalAccounts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 38, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Active Accounts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveAccounts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 58, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Last Updated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 78, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
Service Accounts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, sa := range data.ServiceAccounts { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.ServiceAccounts) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
IDParent UserAccess KeyStatusCreatedActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(sa.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 118, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(sa.ParentUser) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 123, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(sa.AccessKeyId) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 126, Col: 88} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if sa.Status == "Active" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Active") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Inactive") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(sa.CreateDate.Format("2006-01-02")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 135, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
No service accounts found

Create your first service account for automated processes.

Last updated: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/service_accounts.templ`, Line: 182, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Create Service Account
The service account will inherit permissions from this user
Leave empty for no expiration
Service Account Details
Service Account Created Successfully
Important: This is the only time you will see the secret access key. Please save it securely.
AWS CLI Configuration

Use these credentials to configure AWS CLI or SDKs:

Example AWS CLI Usage:
export AWS_ACCESS_KEY_ID=
export AWS_SECRET_ACCESS_KEY=
export AWS_ENDPOINT_URL=http://localhost:8333
# List buckets
aws s3 ls
# Upload a file
aws s3 cp myfile.txt s3://mybucket/
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 9619c6aff..de74892f2 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -168,6 +168,11 @@ templ Layout(c *gin.Context, content templ.Component) { Users +
MANAGEMENT
MANAGEMENT