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
197 lines
5.6 KiB
Go
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)
|
|
}
|