fetch_ml/internal/api/tokens/handlers.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

197 lines
5.6 KiB
Go

// Package tokens provides HTTP handlers for share token management
package tokens
import (
"encoding/json"
"net/http"
"time"
"github.com/jfraeys/fetch_ml/internal/auth"
"github.com/jfraeys/fetch_ml/internal/logging"
"github.com/jfraeys/fetch_ml/internal/storage"
)
// Handler provides share token management HTTP handlers
type Handler struct {
db *storage.DB
logger *logging.Logger
}
// NewHandler creates a new share tokens handler
func NewHandler(db *storage.DB, logger *logging.Logger) *Handler {
return &Handler{
db: db,
logger: logger,
}
}
// CreateTokenRequest represents a request to create a new share token
type CreateTokenRequest struct {
TaskID *string `json:"task_id,omitempty"`
ExperimentID *string `json:"experiment_id,omitempty"`
ExpiresIn *int `json:"expires_in_days,omitempty"` // Number of days until expiry
MaxAccesses *int `json:"max_accesses,omitempty"` // Max number of accesses allowed
}
// CreateTokenResponse represents the response with the created token
type CreateTokenResponse struct {
Token string `json:"token"`
ShareLink string `json:"share_link"`
}
// CreateShareToken handles POST /api/tokens
// Creates a new share token for a task or experiment
func (h *Handler) CreateShareToken(w http.ResponseWriter, r *http.Request) {
user := auth.GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Check if user has permission to share tasks
if !user.HasPermission(auth.PermissionTasksShare) {
http.Error(w, "forbidden: insufficient permissions to share tasks", http.StatusForbidden)
return
}
var req CreateTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Must specify either task_id or experiment_id, not both
if (req.TaskID == nil && req.ExperimentID == nil) || (req.TaskID != nil && req.ExperimentID != nil) {
http.Error(w, "must specify exactly one of task_id or experiment_id", http.StatusBadRequest)
return
}
// Build options
opts := auth.ShareTokenOptions{}
if req.ExpiresIn != nil {
expiresAt := time.Now().Add(time.Duration(*req.ExpiresIn) * 24 * time.Hour)
opts.ExpiresAt = &expiresAt
}
if req.MaxAccesses != nil {
opts.MaxAccesses = req.MaxAccesses
}
// Generate token
token, err := auth.GenerateShareToken(h.db, req.TaskID, req.ExperimentID, user.Name, opts)
if err != nil {
h.logger.Error("failed to generate share token", "error", err)
http.Error(w, "failed to create share token", http.StatusInternalServerError)
return
}
// Build share link
var path string
if req.TaskID != nil {
path = "/api/tasks/" + *req.TaskID
} else {
path = "/api/experiments/" + *req.ExperimentID
}
shareLink := auth.BuildShareLink("", path, token)
h.logger.Info("share token created",
"token", token[:8]+"...",
"created_by", user.Name,
"task_id", req.TaskID,
"experiment_id", req.ExperimentID,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(CreateTokenResponse{
Token: token,
ShareLink: shareLink,
}); err != nil {
h.logger.Error("failed to encode response", "error", err)
}
}
// ListShareTokens handles GET /api/tokens
// Lists share tokens for a task or experiment
func (h *Handler) ListShareTokens(w http.ResponseWriter, r *http.Request) {
user := auth.GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
taskID := r.URL.Query().Get("task_id")
experimentID := r.URL.Query().Get("experiment_id")
// Must specify either task_id or experiment_id
if taskID == "" && experimentID == "" {
http.Error(w, "must specify task_id or experiment_id", http.StatusBadRequest)
return
}
var tokens []*storage.ShareToken
var err error
if taskID != "" {
tokens, err = h.db.ListShareTokensForTask(taskID)
} else {
tokens, err = h.db.ListShareTokensForExperiment(experimentID)
}
if err != nil {
h.logger.Error("failed to list share tokens", "error", err)
http.Error(w, "failed to list share tokens", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"tokens": tokens,
"count": len(tokens),
}); err != nil {
h.logger.Error("failed to encode response", "error", err)
}
}
// RevokeShareToken handles DELETE /api/tokens/:token
// Revokes a share token
func (h *Handler) RevokeShareToken(w http.ResponseWriter, r *http.Request) {
user := auth.GetUserFromContext(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
token := r.PathValue("token")
if token == "" {
http.Error(w, "token required", http.StatusBadRequest)
return
}
// Get token details to check ownership
t, err := h.db.GetShareToken(token)
if err != nil {
h.logger.Error("failed to get share token", "error", err)
http.Error(w, "token not found", http.StatusNotFound)
return
}
if t == nil {
http.Error(w, "token not found", http.StatusNotFound)
return
}
// Only the creator or an admin can revoke
if t.CreatedBy != user.Name && !user.Admin {
http.Error(w, "forbidden: not token owner or admin", http.StatusForbidden)
return
}
if err := h.db.DeleteShareToken(token); err != nil {
h.logger.Error("failed to delete share token", "error", err)
http.Error(w, "failed to revoke token", http.StatusInternalServerError)
return
}
h.logger.Info("share token revoked", "token", token[:8]+"...", "revoked_by", user.Name)
w.WriteHeader(http.StatusNoContent)
}