fetch_ml/internal/auth/api_key.go

358 lines
9.4 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"`
}
// 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")
}
// 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 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