Production hardening: security, resilience, observability, and compliance

Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls

Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
       middleware expiry (5), validator (new)
This commit is contained in:
Trey T
2026-03-26 14:05:28 -05:00
parent 4abc57535e
commit b679f28e55
30 changed files with 2077 additions and 47 deletions

View File

@@ -0,0 +1,136 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupTestDB creates an in-memory SQLite database for health check tests
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
return db
}
func TestLiveCheck_Returns200(t *testing.T) {
e := echo.New()
e.GET("/api/health/live", liveCheck)
req := httptest.NewRequest(http.MethodGet, "/api/health/live", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "alive", resp["status"])
assert.Equal(t, Version, resp["version"])
assert.Contains(t, resp, "timestamp")
}
func TestReadinessCheck_HealthyDB_NilCache_Returns200(t *testing.T) {
db := setupTestDB(t)
deps := &Dependencies{
DB: db,
Cache: nil, // No Redis configured
}
e := echo.New()
e.GET("/api/health/", readinessCheck(deps))
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "healthy", resp["status"])
assert.Equal(t, Version, resp["version"])
checks := resp["checks"].(map[string]interface{})
assert.Equal(t, "ok", checks["postgres"])
assert.Equal(t, "not configured", checks["redis"])
}
func TestReadinessCheck_DBDown_Returns503(t *testing.T) {
// Open an in-memory SQLite DB, then close the underlying sql.DB to simulate failure
db := setupTestDB(t)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.Close()
deps := &Dependencies{
DB: db,
Cache: nil,
}
e := echo.New()
e.GET("/api/health/", readinessCheck(deps))
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
var resp map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "unhealthy", resp["status"])
checks := resp["checks"].(map[string]interface{})
assert.Contains(t, checks["postgres"], "ping failed")
}
func TestReadinessCheck_ResponseFormat(t *testing.T) {
db := setupTestDB(t)
deps := &Dependencies{
DB: db,
Cache: nil,
}
e := echo.New()
e.GET("/api/health/", readinessCheck(deps))
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
var resp map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
// Verify all expected fields are present
assert.Contains(t, resp, "status")
assert.Contains(t, resp, "version")
assert.Contains(t, resp, "checks")
assert.Contains(t, resp, "timestamp")
// Verify checks is a map with expected keys
checks, ok := resp["checks"].(map[string]interface{})
require.True(t, ok, "checks should be a map")
assert.Contains(t, checks, "postgres")
assert.Contains(t, checks, "redis")
}

View File

@@ -1,6 +1,7 @@
package router
import (
"context"
"errors"
"fmt"
"net/http"
@@ -62,11 +63,12 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.)
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, // 1 year in seconds
ReferrerPolicy: "strict-origin-when-cross-origin",
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, // 1 year in seconds
ReferrerPolicy: "strict-origin-when-cross-origin",
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
}))
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
Limit: "1M", // 1MB default for JSON payloads
@@ -93,6 +95,14 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
e.Use(corsMiddleware(cfg))
e.Use(i18n.Middleware())
// Gzip compression (skip media endpoints since they serve binary files)
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5,
Skipper: func(c echo.Context) bool {
return strings.HasPrefix(c.Request().URL.Path, "/api/media/")
},
}))
// Monitoring metrics middleware (if monitoring is enabled)
if deps.MonitoringService != nil {
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
@@ -114,8 +124,9 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
})
}
// Health check endpoint (no auth required)
e.GET("/api/health/", healthCheck)
// Health check endpoints (no auth required)
e.GET("/api/health/", readinessCheck(deps))
e.GET("/api/health/live", liveCheck)
// Initialize onboarding email service for tracking handler
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
@@ -172,17 +183,21 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
subscriptionWebhookHandler.SetStripeService(stripeService)
// Initialize middleware
authMiddleware := custommiddleware.NewAuthMiddleware(deps.DB, deps.Cache)
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg)
// Initialize Apple auth service
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
// Initialize audit service for security event logging
auditService := services.NewAuditService(deps.DB)
// Initialize handlers
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
authHandler.SetAppleAuthService(appleAuthService)
authHandler.SetGoogleAuthService(googleAuthService)
authHandler.SetStorageService(deps.StorageService)
authHandler.SetAuditService(auditService)
userHandler := handlers.NewUserHandler(userService)
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
@@ -201,6 +216,11 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
}
// Prometheus metrics endpoint (no auth required, for scraping)
if deps.MonitoringService != nil {
e.GET("/metrics", prometheusMetrics(deps.MonitoringService))
}
// Set up admin routes with monitoring handler (if available)
var monitoringHandler *monitoring.Handler
if deps.MonitoringService != nil {
@@ -295,16 +315,126 @@ func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
})
}
// healthCheck returns API health status
func healthCheck(c echo.Context) error {
// liveCheck returns a simple 200 for Kubernetes liveness probes
func liveCheck(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "healthy",
"status": "alive",
"version": Version,
"framework": "Echo",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// readinessCheck returns 200 if PostgreSQL and Redis are reachable, 503 otherwise.
// This is used by Kubernetes readiness probes and load balancers.
func readinessCheck(deps *Dependencies) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
status := "healthy"
httpStatus := http.StatusOK
checks := make(map[string]string)
// Check PostgreSQL
sqlDB, err := deps.DB.DB()
if err != nil {
checks["postgres"] = fmt.Sprintf("failed to get sql.DB: %v", err)
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
} else if err := sqlDB.PingContext(ctx); err != nil {
checks["postgres"] = fmt.Sprintf("ping failed: %v", err)
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
} else {
checks["postgres"] = "ok"
}
// Check Redis (if cache service is available)
if deps.Cache != nil {
if err := deps.Cache.Client().Ping(ctx).Err(); err != nil {
checks["redis"] = fmt.Sprintf("ping failed: %v", err)
status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
} else {
checks["redis"] = "ok"
}
} else {
checks["redis"] = "not configured"
}
return c.JSON(httpStatus, map[string]interface{}{
"status": status,
"version": Version,
"checks": checks,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
}
// prometheusMetrics returns an Echo handler that outputs metrics in Prometheus text format.
// It uses the existing monitoring service's HTTP stats collector to avoid adding external dependencies.
func prometheusMetrics(monSvc *monitoring.Service) echo.HandlerFunc {
return func(c echo.Context) error {
httpCollector := monSvc.HTTPCollector()
if httpCollector == nil {
return c.String(http.StatusOK, "# No HTTP metrics available (collector not initialized)\n")
}
stats := httpCollector.GetStats()
var b strings.Builder
// Request count by method+path+status
b.WriteString("# HELP http_requests_total Total number of HTTP requests.\n")
b.WriteString("# TYPE http_requests_total counter\n")
for statusCode, count := range stats.ByStatusCode {
fmt.Fprintf(&b, "http_requests_total{status_code=\"%d\"} %d\n", statusCode, count)
}
// Per-endpoint request count
b.WriteString("# HELP http_endpoint_requests_total Total requests per endpoint.\n")
b.WriteString("# TYPE http_endpoint_requests_total counter\n")
for endpoint, epStats := range stats.ByEndpoint {
// endpoint is "METHOD /path"
parts := strings.SplitN(endpoint, " ", 2)
method := endpoint
path := ""
if len(parts) == 2 {
method = parts[0]
path = parts[1]
}
fmt.Fprintf(&b, "http_endpoint_requests_total{method=\"%s\",path=\"%s\"} %d\n", method, path, epStats.Count)
}
// Request duration (avg latency as a gauge, since we don't have raw histogram buckets)
b.WriteString("# HELP http_request_duration_ms Average request duration in milliseconds per endpoint.\n")
b.WriteString("# TYPE http_request_duration_ms gauge\n")
for endpoint, epStats := range stats.ByEndpoint {
parts := strings.SplitN(endpoint, " ", 2)
method := endpoint
path := ""
if len(parts) == 2 {
method = parts[0]
path = parts[1]
}
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"avg\"} %.2f\n", method, path, epStats.AvgLatencyMs)
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"p95\"} %.2f\n", method, path, epStats.P95LatencyMs)
}
// Error rate
b.WriteString("# HELP http_error_rate Overall error rate (4xx+5xx / total).\n")
b.WriteString("# TYPE http_error_rate gauge\n")
fmt.Fprintf(&b, "http_error_rate %.4f\n", stats.ErrorRate)
// Requests per minute
b.WriteString("# HELP http_requests_per_minute Current request rate.\n")
b.WriteString("# TYPE http_requests_per_minute gauge\n")
fmt.Fprintf(&b, "http_requests_per_minute %.2f\n", stats.RequestsPerMinute)
c.Response().Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
return c.String(http.StatusOK, b.String())
}
}
// setupPublicAuthRoutes configures public authentication routes with
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing.
// Rate limiters are disabled in debug mode to allow UI test suites to run
@@ -342,6 +472,7 @@ func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler
auth := api.Group("/auth")
{
auth.POST("/logout/", authHandler.Logout)
auth.POST("/refresh/", authHandler.RefreshToken)
auth.GET("/me/", authHandler.CurrentUser)
auth.PUT("/profile/", authHandler.UpdateProfile)
auth.PATCH("/profile/", authHandler.UpdateProfile)