eda033ff9b
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').
With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
9.8 KiB
Go
267 lines
9.8 KiB
Go
// Package rest implements the HTTP/JSON transport for the notifier
|
|
// service: request routing, handlers, authentication and CORS middleware,
|
|
// and API key management endpoints.
|
|
package rest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/igodwin/notifier/internal/auth"
|
|
"github.com/igodwin/notifier/internal/domain"
|
|
"github.com/igodwin/notifier/internal/logging"
|
|
)
|
|
|
|
// CORSConfig contains CORS middleware configuration
|
|
type CORSConfig struct {
|
|
// AllowedOrigins is a whitelist of allowed origins (e.g., ["https://example.com", "https://app.example.com"])
|
|
// Wildcards are NOT supported for security reasons
|
|
AllowedOrigins []string
|
|
|
|
// AllowedMethods is a list of allowed HTTP methods (e.g., ["GET", "POST", "OPTIONS", "DELETE"])
|
|
AllowedMethods []string
|
|
|
|
// AllowedHeaders is a list of allowed HTTP headers (e.g., ["Content-Type", "Authorization"])
|
|
AllowedHeaders []string
|
|
|
|
// AllowCredentials indicates whether credentials (cookies, authorization headers) are allowed
|
|
// Note: When true, AllowedOrigins must NOT contain wildcards
|
|
AllowCredentials bool
|
|
|
|
// MaxAge is the duration in seconds that browsers can cache preflight responses
|
|
MaxAge int
|
|
}
|
|
|
|
// DefaultCORSConfig returns a secure default CORS configuration
|
|
// By default, no origins are allowed - you must explicitly configure allowed origins
|
|
func DefaultCORSConfig() *CORSConfig {
|
|
return &CORSConfig{
|
|
AllowedOrigins: []string{}, // Empty by default - must be explicitly configured
|
|
AllowedMethods: []string{"GET", "POST", "OPTIONS", "DELETE"},
|
|
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
|
AllowCredentials: false,
|
|
MaxAge: 3600, // 1 hour
|
|
}
|
|
}
|
|
|
|
// ReadinessCheck reports whether a named dependency is ready. Implementations
|
|
// should be cheap; they run on every /readyz request.
|
|
type ReadinessCheck func(ctx context.Context) error
|
|
|
|
// RouterOptions configures the REST router.
|
|
type RouterOptions struct {
|
|
Service domain.NotificationService
|
|
Logger *logging.Logger
|
|
AuthStore *auth.APIKeyStore // nil disables authentication
|
|
KeyStore *auth.HybridKeyStore // nil disables key-management routes
|
|
CORS *CORSConfig // nil disables CORS headers entirely
|
|
// Readiness maps a component name (e.g. "queue", "database") to its check.
|
|
Readiness map[string]ReadinessCheck
|
|
// Instrument, when set, wraps the router for request instrumentation
|
|
// (e.g. Prometheus HTTP metrics).
|
|
Instrument func(http.Handler) http.Handler
|
|
}
|
|
|
|
// NewRouter creates a new HTTP router with all routes configured
|
|
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
|
return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger})
|
|
}
|
|
|
|
// NewRouterWithAuth creates a new HTTP router with optional authentication
|
|
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *mux.Router {
|
|
return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger, AuthStore: authStore})
|
|
}
|
|
|
|
// NewRouterWithAuthAndKeyStore creates a new HTTP router with authentication and key management
|
|
func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, keyStore *auth.HybridKeyStore) *mux.Router {
|
|
return NewRouterWithOptions(RouterOptions{Service: service, Logger: logger, AuthStore: authStore, KeyStore: keyStore})
|
|
}
|
|
|
|
// NewRouterWithOptions creates the HTTP router from RouterOptions.
|
|
func NewRouterWithOptions(opts RouterOptions) *mux.Router {
|
|
handler := NewHandler(opts.Service, opts.Logger)
|
|
router := mux.NewRouter()
|
|
|
|
// API v1 routes
|
|
v1 := router.PathPrefix("/api/v1").Subrouter()
|
|
|
|
// Apply authentication middleware if auth store is provided
|
|
if opts.AuthStore != nil {
|
|
authMiddleware := auth.NewRESTAuthMiddleware(opts.AuthStore, opts.Logger)
|
|
v1.Use(authMiddleware.Middleware)
|
|
}
|
|
|
|
// Notification routes
|
|
v1.HandleFunc("/notifications", handler.SendNotification).Methods(http.MethodPost)
|
|
v1.HandleFunc("/notifications/batch", handler.SendBatchNotifications).Methods(http.MethodPost)
|
|
v1.HandleFunc("/notifications", handler.ListNotifications).Methods(http.MethodGet)
|
|
v1.HandleFunc("/notifications/{id}", handler.GetNotification).Methods(http.MethodGet)
|
|
v1.HandleFunc("/notifications/{id}", handler.CancelNotification).Methods(http.MethodDelete)
|
|
v1.HandleFunc("/notifications/{id}/retry", handler.RetryNotification).Methods(http.MethodPost)
|
|
|
|
// Stats route
|
|
v1.HandleFunc("/stats", handler.GetStats).Methods(http.MethodGet)
|
|
|
|
// Notifiers route
|
|
v1.HandleFunc("/notifiers", handler.GetNotifiers).Methods(http.MethodGet)
|
|
|
|
// Key management routes (requires auth and keystore)
|
|
if opts.AuthStore != nil && opts.KeyStore != nil {
|
|
keyHandler := NewKeyManagementHandler(opts.KeyStore, opts.Logger)
|
|
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
|
|
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
|
|
v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete)
|
|
v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
|
|
v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
|
|
}
|
|
|
|
// Liveness and readiness routes (no auth required). /health stays a pure
|
|
// liveness signal; /readyz fails when a dependency is unavailable.
|
|
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
|
router.HandleFunc("/readyz", readinessHandler(opts.Readiness)).Methods(http.MethodGet)
|
|
|
|
// Middleware - CORS (when configured), logging, and request size limits
|
|
if opts.CORS != nil && len(opts.CORS.AllowedOrigins) > 0 {
|
|
router.Use(newCORSMiddleware(opts.CORS))
|
|
}
|
|
if opts.Instrument != nil {
|
|
router.Use(mux.MiddlewareFunc(opts.Instrument))
|
|
}
|
|
router.Use(loggingMiddleware)
|
|
v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies
|
|
|
|
return router
|
|
}
|
|
|
|
// LivenessHandler returns a minimal liveness handler for dedicated health
|
|
// listeners (the REST router serves the same signal at /health).
|
|
func LivenessHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Headers are already written, so an encode error can only be
|
|
// dropped on the floor here.
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "healthy",
|
|
"service": "notifier",
|
|
"time": time.Now().UTC(),
|
|
})
|
|
})
|
|
}
|
|
|
|
// ReadinessHandler exposes the readiness checks for dedicated health listeners.
|
|
func ReadinessHandler(checks map[string]ReadinessCheck) http.Handler {
|
|
return readinessHandler(checks)
|
|
}
|
|
|
|
// readinessHandler runs each dependency check and reports 503 if any fail.
|
|
func readinessHandler(checks map[string]ReadinessCheck) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
status := http.StatusOK
|
|
components := make(map[string]string, len(checks))
|
|
for name, check := range checks {
|
|
if err := check(ctx); err != nil {
|
|
status = http.StatusServiceUnavailable
|
|
components[name] = "unavailable"
|
|
} else {
|
|
components[name] = "ok"
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
ready := status == http.StatusOK
|
|
// Headers are already written, so an encode error can only be
|
|
// dropped on the floor here.
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"ready": ready,
|
|
"components": components,
|
|
})
|
|
}
|
|
}
|
|
|
|
// maxBodySizeMiddleware limits the size of incoming request bodies to prevent DoS.
|
|
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// loggingMiddleware logs incoming requests
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// You can add structured logging here
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// newCORSMiddleware creates a CORS middleware with origin whitelist validation
|
|
func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
|
|
// Check if the origin is in the allowed list
|
|
allowed := false
|
|
for _, allowedOrigin := range config.AllowedOrigins {
|
|
if origin == allowedOrigin {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Only set CORS headers if the origin is allowed
|
|
if allowed {
|
|
// Set the exact origin (never use wildcard)
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
|
|
// Set allowed methods
|
|
if len(config.AllowedMethods) > 0 {
|
|
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
|
}
|
|
|
|
// Set allowed headers
|
|
if len(config.AllowedHeaders) > 0 {
|
|
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
|
}
|
|
|
|
// Set credentials header if enabled
|
|
if config.AllowCredentials {
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
|
|
// Set max age for preflight caching
|
|
if config.MaxAge > 0 {
|
|
w.Header().Set("Access-Control-Max-Age", strconv.FormatInt(int64(config.MaxAge), 10))
|
|
}
|
|
}
|
|
|
|
// Handle preflight OPTIONS requests: succeed only for allowed
|
|
// origins; disallowed cross-origin preflights get 403 with no
|
|
// CORS headers so browsers block the actual request.
|
|
if r.Method == http.MethodOptions && origin != "" {
|
|
if allowed {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
} else {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|