// Package auth provides authentication and authorization functionality package auth import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log" "net/http" "os" "strings" "time" ) // User represents an authenticated user type User struct { Permissions map[string]bool `json:"permissions"` Name string `json:"name"` Roles []string `json:"roles"` Admin bool `json:"admin"` } // 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 { Permissions map[string]bool `yaml:"permissions,omitempty"` Hash APIKeyHash `yaml:"hash"` Salt string `yaml:"salt,omitempty"` Algorithm string `yaml:"algorithm,omitempty"` Roles []string `yaml:"roles,omitempty"` Admin bool `yaml:"admin"` } // Username represents a user identifier type Username string // Config represents the authentication configuration type Config struct { APIKeys map[Username]APIKeyEntry `yaml:"api_keys"` Enabled bool `yaml:"enabled"` } // 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 { Created time.Time `json:"created"` Expires *time.Time `json:"expires,omitempty"` Revoked *time.Time `json:"revoked,omitempty"` UserID string `json:"user_id"` KeyHash string `json:"key_hash"` Admin bool `json:"admin"` } // ValidateAPIKey validates an API key and returns user information // Supports both legacy SHA256 and modern Argon2id hashing 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 } for username, entry := range c.APIKeys { // Build HashedKey from entry stored := &HashedKey{ Hash: string(entry.Hash), Salt: entry.Salt, Algorithm: HashAlgorithm(entry.Algorithm), } // Verify using appropriate algorithm match, err := VerifyAPIKey(key, stored) if err != nil { // Log error but continue to next key (don't leak info) continue } if match { // 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") } // ValidateAPIKeyHash validates a pre-hashed API key and returns user information func (c *Config) ValidateAPIKeyHash(hash []byte) (*User, error) { if !c.Enabled { // Auth disabled - return default admin user for development return &User{Name: "default", Admin: true}, nil } if len(hash) != 16 { return nil, fmt.Errorf("invalid api key hash length: %d", len(hash)) } for username, entry := range c.APIKeys { stored := strings.TrimSpace(string(entry.Hash)) if stored == "" { continue } storedBytes, err := hex.DecodeString(stored) if err != nil { continue } if len(storedBytes) != sha256.Size { continue } if string(storedBytes[:16]) == string(hash) { // 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 } func WithUserContext(ctx context.Context, user *User) context.Context { return context.WithValue(ctx, userContextKey, user) } // 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 creates a new API key with Argon2id hashing. // Returns plaintext key (show once) and the entry for storage. func GenerateNewAPIKey(admin bool, roles []string, permissions map[string]bool) (plaintext string, entry APIKeyEntry, err error) { plaintext, hashed, err := GenerateAPIKey() if err != nil { return "", APIKeyEntry{}, err } entry = APIKeyEntry{ Hash: APIKeyHash(hashed.Hash), Salt: hashed.Salt, Algorithm: string(hashed.Algorithm), Admin: admin, Roles: roles, Permissions: permissions, } return plaintext, entry, nil } // isHex checks if a string contains only hex characters func isHex(s string) bool { for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return len(s) > 0 }