* admin: add Service Account management UI Add admin UI for managing service accounts: New files: - handlers/service_account_handlers.go - HTTP handlers - dash/service_account_management.go - CRUD operations - view/app/service_accounts.templ - UI template Changes: - dash/types.go - Add ServiceAccount and related types - handlers/admin_handlers.go - Register routes and handlers - view/layout/layout.templ - Add sidebar navigation link Service accounts are stored as special identities with "sa:" prefix in their name, using ABIA access key prefix. They can be created, listed, enabled/disabled, and deleted through the admin UI. Features: - Create service accounts linked to parent users - View and manage service account status - Delete service accounts - Service accounts inherit parent user permissions Note: STS configuration is read-only (configured via JSON file). Full STS integration requires changes from PR #7901. * admin: use dropdown for parent user selection Change the Parent User field from text input to dropdown when creating a service account. The dropdown is populated with all existing Object Store users. Changes: - Add AvailableUsers field to ServiceAccountsData type - Populate available users in getServiceAccountsData handler - Update template to use <select> element with user options * admin: show secret access key on service account creation Display both access key and secret access key when creating a service account, with proper AWS CLI usage instructions. Changes: - Add SecretAccessKey field to ServiceAccount type (only populated on creation) - Return secret key from CreateServiceAccount - Add credentials modal with copy-to-clipboard buttons - Show AWS CLI usage example with actual credentials - Modal is non-dismissible until user confirms they saved credentials The secret key is only shown once during creation for security. After creation, only the access key ID is visible in the list. * admin: address code review comments for service account management - Persist creation dates in identity actions (createdAt:timestamp) - Replace magic number slicing with len(accessKeyPrefix) - Add bounds checking after strings.SplitN - Use accessKeyPrefix constant instead of hardcoded "ABIA" Creation dates are now stored as actions (e.g., "createdAt:1735473600") and will persist across restarts. Helper functions getCreationDate() and setCreationDate() manage the timestamp storage. Addresses review comments from gemini-code-assist[bot] and coderabbitai[bot] * admin: fix XSS vulnerabilities in service account details Replace innerHTML with template literals with safe DOM creation. The createSADetailsContent function now uses createElement and textContent to prevent XSS attacks from malicious service account data (id, description, parent_user, etc.). Also added try-catch for date parsing to prevent exceptions on malformed input. Addresses security review comments from coderabbitai[bot] * admin: add context.Context to service account management methods Addressed PR #7902 review feedback: 1. All service account management methods now accept context.Context as first parameter to enable cancellation, deadlines, and tracing 2. Removed all context.Background() calls 3. Updated handlers to pass c.Request.Context() from HTTP requests Methods updated: - GetServiceAccounts - GetServiceAccountDetails - CreateServiceAccount - UpdateServiceAccount - DeleteServiceAccount - GetServiceAccountByAccessKey Note: Creation date persistence was already implemented using the createdAt:<timestamp> action pattern as suggested in the review. * admin: fix render flow to prevent partial HTML writes Fixed ShowServiceAccounts handler to render template to an in-memory buffer first before writing to the response. This prevents partial HTML writes followed by JSON error responses, which would result in invalid mixed content. Changes: - Render to bytes.Buffer first - Only write to c.Writer if render succeeds - Use c.AbortWithStatus on error instead of attempting JSON response - Prevents any additional headers/body writes after partial write * admin: fix error handling, date validation, and event parameters Addressed multiple code review issues: 1. Proper 404 vs 500 error handling: - Added ErrServiceAccountNotFound sentinel error - GetServiceAccountDetails now wraps errors with sentinel - Handler uses errors.Is() to distinguish not-found from internal errors - Returns 404 only for missing resources, 500 for other errors - Logs internal errors before returning 500 2. Date validation in JavaScript: - Validate expiration date before using it - Check !isNaN(date.getTime()) to ensure valid date - Return validation error if date is invalid - Prevents invalid Date construction 3. Event parameter handling: - copyToClipboard now accepts event parameter - Updated onclick attributes to pass event object - Prevents reliance on window.event - More explicit and reliable event handling * admin: replace deprecated execCommand with Clipboard API Replaced deprecated document.execCommand('copy') with modern navigator.clipboard.writeText() API for better security and UX. Changes: - Made copyToClipboard async to support Clipboard API - Use navigator.clipboard.writeText() as primary method - Fallback to execCommand if Clipboard API fails (older browsers) - Added console warning when fallback is used - Maintains same visual feedback behavior * admin: improve security and UX for error handling Addressed code review feedback: 1. Security: Remove sensitive error details from API responses - CreateServiceAccount: Return generic error message - UpdateServiceAccount: Return generic error message - DeleteServiceAccount: Return generic error message - Detailed errors still logged server-side via glog.Errorf() - Prevents exposure of internal system details to clients 2. UX: Replace alert() with Bootstrap toast notifications - Implemented showToast() function using Bootstrap 5 toasts - Non-blocking, modern notification system - Auto-dismiss after 5 seconds - Proper HTML escaping to prevent XSS - Toast container positioned at top-right - Success (green) and error (red) variants * admin: complete error handling improvements Addressed remaining security review feedback: 1. GetServiceAccounts: Remove error details from response - Log errors server-side via glog.Errorf() - Return generic error message to client 2. UpdateServiceAccount & DeleteServiceAccount: - Wrap not-found errors with ErrServiceAccountNotFound sentinel - Enables proper 404 vs 500 distinction in handlers 3. Update & Delete handlers: - Added errors.Is() check for ErrServiceAccountNotFound - Return 404 for missing resources - Return 500 for internal errors with logging - Consistent with GetServiceAccountDetails behavior All handlers now properly distinguish not-found (404) from internal errors (500) and never expose sensitive error details to clients. * admin: implement expiration support and improve code quality Addressed final code review feedback: 1. Expiration Support: - Added expiration helper functions (getExpiration, setExpiration) - Implemented expiration in CreateServiceAccount - Implemented expiration in UpdateServiceAccount - Added Expiration field to ServiceAccount struct - Parse and validate RFC3339 expiration dates 2. Constants for Magic Strings: - Added StatusActive, StatusInactive constants - Added disabledAction, serviceAccountPrefix constants - Replaced all magic strings with constants throughout - Improves maintainability and prevents typos 3. Helper Function to Reduce Duplication: - Created identityToServiceAccount() helper - Reduces code duplication across Get/Update/Delete methods - Centralizes ServiceAccount struct building logic 4. Fixed time.Now() Fallback: - Changed from time.Now() to time.Time{} for legacy accounts - Prevents creation date from changing on each fetch - UI can display zero time as "N/A" or blank All code quality issues addressed! * admin: fix StatusActive reference in handler Use dash.StatusActive to properly reference the constant from the dash package. * admin: regenerate templ files Regenerated all templ Go files after recent template changes. The AWS CLI usage example already uses proper <pre><code> formatting which preserves line breaks for better readability. * admin: add explicit white-space CSS to AWS CLI example Added style="white-space: pre-wrap;" to the pre tag to ensure line breaks are preserved and displayed correctly in all browsers. This forces the browser to respect the newlines in the code block. * admin: fix AWS CLI example to display on separate lines Replaced pre/code block with individual div elements for each line. This ensures each command displays on its own line regardless of how templ processes whitespace. Each line is now a separate div with font-monospace styling for code appearance. * make * admin: filter service accounts from parent user dropdown Service accounts should not appear as selectable parent users when creating new service accounts. Added filter to GetObjectStoreUsers() to skip identities with "sa:" prefix, ensuring only actual IAM users are shown in the parent user dropdown. * admin: address code review feedback - Use constants for magic strings in service account management - Add Expiration field to service account responses - Add nil checks and context propagation - Improve templates (date validation, async clipboard, toast notifications) * Update service_accounts_templ.go
435 lines
12 KiB
Go
435 lines
12 KiB
Go
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:<parent_user>:<uuid>"
|
|
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
|
|
}
|