Files
silo/internal/api/middleware.go
Forbes b8abd8859d feat(modules): RequireModule middleware to gate route groups
Add RequireModule middleware that returns 404 with
{"error":"module '<id>' is not enabled"} when a module is disabled.

Wrap route groups:
- projects → RequireModule("projects")
- audit → RequireModule("audit")
- integrations/odoo → RequireModule("odoo")
- jobs, job-definitions, runners → RequireModule("jobs")
- /api/runner (runner-facing) → RequireModule("jobs")
- dag → RequireModule("dag") (extracted into sub-route)

Ref #98
2026-02-14 14:01:32 -06:00

213 lines
6.4 KiB
Go

// Package api provides HTTP handlers and middleware for the Silo API.
package api
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/kindredsystems/silo/internal/auth"
"github.com/rs/zerolog"
)
// RequestLogger returns a middleware that logs HTTP requests using zerolog.
func RequestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
logger.Info().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("query", r.URL.RawQuery).
Int("status", ww.Status()).
Int("bytes", ww.BytesWritten()).
Dur("duration", time.Since(start)).
Str("remote", r.RemoteAddr).
Str("user_agent", r.UserAgent()).
Msg("request")
}()
next.ServeHTTP(ww, r)
})
}
}
// Recoverer returns a middleware that recovers from panics and logs them.
func Recoverer(logger zerolog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error().
Interface("panic", rec).
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("panic recovered")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// RequireAuth extracts the user from a session cookie or API token and injects
// it into the request context. If auth is disabled, injects a synthetic dev user.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Dev mode: inject synthetic admin user
if s.authConfig == nil || !s.authConfig.Enabled {
devUser := &auth.User{
ID: "00000000-0000-0000-0000-000000000000",
Username: "dev",
DisplayName: "Developer",
Email: "dev@localhost",
Role: auth.RoleAdmin,
AuthSource: "local",
}
ctx := auth.ContextWithUser(r.Context(), devUser)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// 1. Check for API token (Authorization: Bearer silo_...)
if token := extractBearerToken(r); token != "" {
user, err := s.auth.ValidateToken(r.Context(), token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired API token")
return
}
ctx := auth.ContextWithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// 2. Check session
if s.sessions != nil {
userID := s.sessions.GetString(r.Context(), "user_id")
if userID != "" {
user, err := s.auth.GetUserByID(r.Context(), userID)
if err == nil && user != nil {
ctx := auth.ContextWithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Invalid session — destroy it
_ = s.sessions.Destroy(r.Context())
}
}
// 3. Not authenticated
if isAPIRequest(r) {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
} else {
next := r.URL.Path
if r.URL.RawQuery != "" {
next += "?" + r.URL.RawQuery
}
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
}
})
}
// RequireRole returns middleware that rejects users below the given role.
// Role hierarchy: admin > editor > viewer.
func (s *Server) RequireRole(minimum string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if !auth.RoleSatisfies(user.Role, minimum) {
writeError(w, http.StatusForbidden, "forbidden",
"Insufficient permissions: requires "+minimum+" role")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWritable rejects requests with 503 when the server is in read-only mode.
func (s *Server) RequireWritable(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.serverState.IsReadOnly() {
writeError(w, http.StatusServiceUnavailable, "server_read_only",
"Server is in read-only mode")
return
}
next.ServeHTTP(w, r)
})
}
// RequireRunnerAuth extracts and validates a runner token from the
// Authorization header. On success, injects RunnerIdentity into context
// and updates the runner's heartbeat.
func (s *Server) RequireRunnerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" || !strings.HasPrefix(token, "silo_runner_") {
writeError(w, http.StatusUnauthorized, "unauthorized", "Runner token required")
return
}
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
runner, err := s.jobs.GetRunnerByToken(r.Context(), tokenHash)
if err != nil || runner == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid runner token")
return
}
// Update heartbeat on every authenticated request
_ = s.jobs.Heartbeat(r.Context(), runner.ID)
identity := &auth.RunnerIdentity{
ID: runner.ID,
Name: runner.Name,
Tags: runner.Tags,
}
ctx := auth.ContextWithRunner(r.Context(), identity)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireModule returns middleware that rejects requests with 404 when
// the named module is not enabled.
func (s *Server) RequireModule(id string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.modules.IsEnabled(id) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"module '` + id + `' is not enabled"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func isAPIRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/")
}