// Package auth provides authentication and authorization functionality package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "log" "net/http" "os" "strings" "time" ) // User represents an authenticated user type User struct { Name string `json:"name"` Admin bool `json:"admin"` Roles []string `json:"roles"` Permissions map[string]bool `json:"permissions"` } // ExtractAPIKeyFromRequest extracts an API key from the standard headers. func ExtractAPIKeyFromRequest(r *http.Request) string { apiKey := r.Header.Get("X-API-Key") if apiKey != "" { return apiKey } authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { return strings.TrimPrefix(authHeader, "Bearer ") } return "" } // APIKeyHash represents a SHA256 hash of an API key type APIKeyHash string // APIKeyEntry represents an API key configuration type APIKeyEntry struct { Hash APIKeyHash `yaml:"hash"` Admin bool `yaml:"admin"` Roles []string `yaml:"roles,omitempty"` Permissions map[string]bool `yaml:"permissions,omitempty"` } // Username represents a user identifier type Username string // Config represents the authentication configuration type Config struct { Enabled bool `yaml:"enabled"` APIKeys map[Username]APIKeyEntry `yaml:"api_keys"` } // Store interface for different authentication backends type Store interface { ValidateAPIKey(ctx context.Context, key string) (*User, error) CreateAPIKey( ctx context.Context, userID string, keyHash string, admin bool, roles []string, permissions map[string]bool, expiresAt *time.Time, ) error RevokeAPIKey(ctx context.Context, userID string) error ListUsers(ctx context.Context) ([]UserInfo, error) } // contextKey is the type for context keys type contextKey string const userContextKey = contextKey("user") // UserInfo represents user information from authentication store type UserInfo struct { UserID string `json:"user_id"` Admin bool `json:"admin"` KeyHash string `json:"key_hash"` Created time.Time `json:"created"` Expires *time.Time `json:"expires,omitempty"` Revoked *time.Time `json:"revoked,omitempty"` } // ValidateAPIKey validates an API key and returns user information func (c *Config) ValidateAPIKey(key string) (*User, error) { if !c.Enabled { // Auth disabled - return default admin user for development return &User{Name: "default", Admin: true}, nil } // Always hash the incoming key for comparison keyHash := HashAPIKey(key) for username, entry := range c.APIKeys { if string(entry.Hash) == keyHash { // Build user with role and permission inheritance user := &User{ Name: string(username), Admin: entry.Admin, Roles: entry.Roles, Permissions: make(map[string]bool), } // Copy explicit permissions for perm, value := range entry.Permissions { user.Permissions[perm] = value } // Add role-based permissions rolePerms := getRolePermissions(entry.Roles) for perm, value := range rolePerms { if _, exists := user.Permissions[perm]; !exists { user.Permissions[perm] = value } } // Admin gets all permissions if entry.Admin { user.Permissions["*"] = true } return user, nil } } return nil, fmt.Errorf("invalid API key") } // AuthMiddleware creates HTTP middleware for API key authentication func (c *Config) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.Enabled { if os.Getenv("FETCH_ML_ALLOW_INSECURE_AUTH") != "1" || os.Getenv("FETCH_ML_DEBUG") != "1" { http.Error(w, "Unauthorized: Authentication disabled", http.StatusUnauthorized) return } log.Println( "WARNING: Insecure authentication bypass enabled: FETCH_ML_ALLOW_INSECURE_AUTH=1 " + "and FETCH_ML_DEBUG=1; do NOT use this configuration in production.", ) ctx := context.WithValue(r.Context(), userContextKey, &User{Name: "default", Admin: true}) next.ServeHTTP(w, r.WithContext(ctx)) return } // Only accept API key from header - no query parameters for security apiKey := r.Header.Get("X-API-Key") if apiKey == "" { http.Error(w, "Unauthorized: Missing API key in X-API-Key header", http.StatusUnauthorized) return } user, err := c.ValidateAPIKey(apiKey) if err != nil { http.Error(w, "Unauthorized: Invalid API key", http.StatusUnauthorized) return } // Add user to context ctx := context.WithValue(r.Context(), userContextKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } // GetUserFromContext retrieves user from request context func GetUserFromContext(ctx context.Context) *User { if user, ok := ctx.Value(userContextKey).(*User); ok { return user } return nil } // RequireAdmin creates middleware that requires admin privileges func RequireAdmin(next http.Handler) http.Handler { return RequirePermission("system:admin")(next) } // RequirePermission creates middleware that requires specific permission func RequirePermission(permission string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := GetUserFromContext(r.Context()) if user == nil { http.Error(w, "Unauthorized: No user context", http.StatusUnauthorized) return } if !user.HasPermission(permission) { http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } } // HasPermission checks if user has a specific permission func (u *User) HasPermission(permission string) bool { // Wildcard permission grants all access if u.Permissions["*"] { return true } // Direct permission check if u.Permissions[permission] { return true } // Hierarchical permission check (e.g., "jobs:create" matches "jobs") parts := strings.Split(permission, ":") for i := 1; i < len(parts); i++ { partial := strings.Join(parts[:i], ":") if u.Permissions[partial] { return true } } return false } // HasRole checks if user has a specific role func (u *User) HasRole(role string) bool { for _, userRole := range u.Roles { if userRole == role { return true } } return false } // getRolePermissions returns permissions for given roles func getRolePermissions(roles []string) map[string]bool { permissions := make(map[string]bool) // Use YAML permission manager if available if pm := GetGlobalPermissionManager(); pm != nil && pm.loaded { for _, role := range roles { rolePerms := pm.GetRolePermissions(role) for perm, value := range rolePerms { permissions[perm] = value } } return permissions } // Fallback to inline permissions rolePermissions := map[string]map[string]bool{ "admin": {"*": true}, "data_scientist": { "jobs:create": true, "jobs:read": true, "jobs:update": true, "data:read": true, "models:read": true, }, "data_engineer": { "data:create": true, "data:read": true, "data:update": true, "data:delete": true, }, "viewer": { "jobs:read": true, "data:read": true, "models:read": true, "metrics:read": true, }, "operator": { "jobs:read": true, "jobs:update": true, "metrics:read": true, "system:read": true, }, } for _, role := range roles { if rolePerms, exists := rolePermissions[role]; exists { for perm, value := range rolePerms { permissions[perm] = value } } } return permissions } // GenerateAPIKey generates a new random API key func GenerateAPIKey() string { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return fmt.Sprintf("%x", sha256.Sum256([]byte(time.Now().String()))) } return hex.EncodeToString(buf) } // HashAPIKey creates a SHA256 hash of an API key func HashAPIKey(key string) string { hash := sha256.Sum256([]byte(key)) return hex.EncodeToString(hash[:]) } // isHex checks if a string contains only hex characters