fetch_ml/internal/auth/tokens.go
Jeremie Fraeys c52179dcbe
feat(auth): add token-based access and structured logging
Add comprehensive authentication and authorization enhancements:

- tokens.go: New token management system for public task access and cloning
  * SHA-256 hashed token storage for security
  * Token generation, validation, and automatic cleanup
  * Support for public access and clone permissions

- api_key.go: Extend User struct with Groups field
  * Lab group membership (ml-lab, nlp-group)
  * Integration with permission system for group-based access

- flags.go: Security hardening - migrate to structured logging
  * Replace log.Printf with log/slog to prevent log injection attacks
  * Consistent structured output for all auth warnings
  * Safe handling of file paths and errors in logs

- permissions.go: Add task sharing permission constants
  * PermissionTasksReadOwn: Access own tasks
  * PermissionTasksReadLab: Access lab group tasks
  * PermissionTasksReadAll: Admin/institution-wide access
  * PermissionTasksShare: Grant access to other users
  * PermissionTasksClone: Create copies of shared tasks
  * CanAccessTask() method with visibility checks

- database.go: Improve error handling
  * Add structured error logging on row close failures
2026-03-08 12:51:07 -04:00

64 lines
2.1 KiB
Go

package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/jfraeys/fetch_ml/internal/storage"
)
// ShareTokenOptions provides options for creating share tokens
type ShareTokenOptions struct {
ExpiresAt *time.Time // nil = never expires
MaxAccesses *int // nil = unlimited
}
// GenerateShareToken creates a new cryptographically random share token.
// Returns the token string that can be shared with others.
func GenerateShareToken(db *storage.DB, taskID, experimentID *string, createdBy string, opts ShareTokenOptions) (string, error) {
// Generate 32 bytes of random data
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("failed to generate random token: %w", err)
}
// Encode as base64url (URL-safe, no padding)
token := base64.RawURLEncoding.EncodeToString(raw)
// Store in database
if err := db.CreateShareToken(token, taskID, experimentID, createdBy, opts.ExpiresAt, opts.MaxAccesses); err != nil {
return "", fmt.Errorf("failed to store share token: %w", err)
}
return token, nil
}
// CanAccessWithToken checks if a token grants access to a task or experiment.
// This is called for unauthenticated access via signed share links.
// Returns true if the token is valid, not expired, and within access limits.
func CanAccessWithToken(db *storage.DB, token string, taskID, experimentID *string) bool {
t, err := db.ValidateShareToken(token, taskID, experimentID)
if err != nil {
return false
}
if t == nil {
return false
}
// Increment access count
if err := db.IncrementTokenAccessCount(token); err != nil {
// Log error but don't fail the request - access was already validated
// This is a best-effort operation for tracking purposes
fmt.Printf("WARNING: failed to increment token access count: %v\n", err)
}
return true
}
// BuildShareLink constructs a shareable URL from a token.
// The path should be the base API path (e.g., "/api/tasks/123" or "/api/experiments/456").
func BuildShareLink(baseURL, path, token string) string {
return fmt.Sprintf("%s%s?token=%s", baseURL, path, token)
}