fetch_ml/internal/auth/api_key.go
Jeremie Fraeys ea15af1833 Fix multi-user authentication and clean up debug code
- Fix YAML tags in auth config struct (json -> yaml)
- Update CLI configs to use pre-hashed API keys
- Remove double hashing in WebSocket client
- Fix port mapping (9102 -> 9103) in CLI commands
- Update permission keys to use jobs:read, jobs:create, etc.
- Clean up all debug logging from CLI and server
- All user roles now authenticate correctly:
  * Admin: Can queue jobs and see all jobs
  * Researcher: Can queue jobs and see own jobs
  * Analyst: Can see status (read-only access)

Multi-user authentication is now fully functional.
2025-12-06 12:35:32 -05:00

297 lines
7.8 KiB
Go

// 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"`
}
// 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
}
// Check if key is already hashed (64 hex chars = SHA256 hash)
var keyHash string
if len(key) == 64 && isHex(key) {
// Key is already hashed, use as-is
keyHash = key
} else {
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
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 true
}