Files
Charon/backend/internal/api/handlers/emergency_handler.go
GitHub Actions 8eb1cf0104 fix(tests): use correct endpoint in break glass recovery test
The break glass recovery test was calling GET /api/v1/config which
doesn't exist (only PATCH is supported). Changed to use
GET /api/v1/security/config and updated the response body accessor
from body.security?.admin_whitelist to body.config?.admin_whitelist.

Also switched to Playwright's toBeOK() assertion for better error
messages on failure.
2026-02-03 14:06:46 +00:00

472 lines
15 KiB
Go

package handlers
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
const (
// EmergencyTokenEnvVar is the environment variable name for the emergency token
EmergencyTokenEnvVar = "CHARON_EMERGENCY_TOKEN"
// EmergencyTokenHeader is the HTTP header name for the emergency token
EmergencyTokenHeader = "X-Emergency-Token"
// MinTokenLength is the minimum required length for the emergency token
MinTokenLength = 32
)
// EmergencyHandler handles emergency security reset operations
type EmergencyHandler struct {
db *gorm.DB
securityService *services.SecurityService
tokenService *services.EmergencyTokenService
caddyManager CaddyConfigManager
cerberus CacheInvalidator
}
// NewEmergencyHandler creates a new EmergencyHandler
func NewEmergencyHandler(db *gorm.DB) *EmergencyHandler {
return &EmergencyHandler{
db: db,
securityService: services.NewSecurityService(db),
tokenService: services.NewEmergencyTokenService(db),
}
}
// NewEmergencyHandlerWithDeps creates a new EmergencyHandler with optional cache invalidation and config reload.
func NewEmergencyHandlerWithDeps(db *gorm.DB, caddyManager CaddyConfigManager, cerberus CacheInvalidator) *EmergencyHandler {
return &EmergencyHandler{
db: db,
securityService: services.NewSecurityService(db),
tokenService: services.NewEmergencyTokenService(db),
caddyManager: caddyManager,
cerberus: cerberus,
}
}
// NewEmergencyTokenHandler creates a handler for emergency token management endpoints
// This is an alias for NewEmergencyHandler, provided for semantic clarity in route registration
func NewEmergencyTokenHandler(tokenService *services.EmergencyTokenService) *EmergencyHandler {
return &EmergencyHandler{
db: tokenService.DB(),
securityService: nil, // Not needed for token management endpoints
tokenService: tokenService,
}
}
// Close shuts down the handler's resources (e.g., SecurityService).
func (h *EmergencyHandler) Close() {
if h.securityService != nil {
h.securityService.Close()
}
}
// SecurityReset disables all security modules for emergency lockout recovery.
// This endpoint works in conjunction with the EmergencyBypass middleware which
// validates the token and IP restrictions, then sets the emergency_bypass flag.
//
// Security measures:
// - EmergencyBypass middleware validates token and IP (timing-safe comparison)
// - All attempts (success and failure) are logged to audit trail with timestamp and IP
func (h *EmergencyHandler) SecurityReset(c *gin.Context) {
clientIP := util.CanonicalizeIPForSecurity(c.ClientIP())
startTime := time.Now()
// Check if request has been pre-validated by EmergencyBypass middleware
bypassActive, exists := c.Get("emergency_bypass")
if exists && bypassActive.(bool) {
// Request already validated by middleware - proceed directly to reset
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_via_middleware",
}).Debug("Emergency reset validated by middleware")
// Proceed with security reset
h.performSecurityReset(c, clientIP, startTime)
return
}
// Fallback: Legacy direct token validation (deprecated - use middleware)
// This path is kept for backward compatibility but will be removed in future versions
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_legacy_path",
}).Debug("Emergency reset using legacy direct validation")
// Check if emergency token is configured
configuredToken := os.Getenv(EmergencyTokenEnvVar)
if configuredToken == "" {
h.logEnhancedAudit(clientIP, "emergency_reset_not_configured", "Emergency token not configured", false, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_not_configured",
}).Warn("Emergency reset attempted but token not configured")
c.JSON(http.StatusNotImplemented, gin.H{
"error": "not configured",
"message": "Emergency reset is not configured. Set CHARON_EMERGENCY_TOKEN environment variable.",
})
return
}
// Validate token length
if len(configuredToken) < MinTokenLength {
h.logEnhancedAudit(clientIP, "emergency_reset_invalid_config", "Configured token too short", false, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_invalid_config",
}).Error("Emergency token configured but too short")
c.JSON(http.StatusNotImplemented, gin.H{
"error": "not configured",
"message": "Emergency token is configured but does not meet minimum length requirements.",
})
return
}
// Get token from header
providedToken := c.GetHeader(EmergencyTokenHeader)
if providedToken == "" {
h.logEnhancedAudit(clientIP, "emergency_reset_missing_token", "No token provided in header", false, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_missing_token",
}).Warn("Emergency reset attempted without token")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Emergency token required in X-Emergency-Token header.",
})
return
}
// Validate token using service (checks database first, then env var)
_, err := h.tokenService.Validate(providedToken)
if err != nil {
h.logEnhancedAudit(clientIP, "emergency_reset_invalid_token", fmt.Sprintf("Token validation failed: %v", err), false, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_invalid_token",
"error": err.Error(),
}).Warn("Emergency reset attempted with invalid token")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Invalid or expired emergency token.",
})
return
}
// Token is valid - disable all security modules
h.performSecurityReset(c, clientIP, startTime)
}
// performSecurityReset executes the actual security module disable operation
func (h *EmergencyHandler) performSecurityReset(c *gin.Context, clientIP string, startTime time.Time) {
disabledModules, err := h.disableAllSecurityModules()
if err != nil {
h.logEnhancedAudit(clientIP, "emergency_reset_failed", fmt.Sprintf("Failed to disable modules: %v", err), false, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_failed",
"error": err.Error(),
}).Error("Emergency reset failed to disable security modules")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "internal error",
"message": "Failed to disable security modules. Check server logs.",
})
return
}
h.syncSecurityState(c.Request.Context())
// Log successful reset
h.logEnhancedAudit(clientIP, "emergency_reset_success", fmt.Sprintf("Disabled modules: %v", disabledModules), true, time.Since(startTime))
log.WithFields(log.Fields{
"ip": clientIP,
"action": "emergency_reset_success",
"disabled_modules": disabledModules,
"duration_ms": time.Since(startTime).Milliseconds(),
}).Warn("EMERGENCY SECURITY RESET: All security modules disabled")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "All security modules have been disabled. Please reconfigure security settings.",
"disabled_modules": disabledModules,
})
}
// disableAllSecurityModules disables ACL, WAF, Rate Limit, and CrowdSec modules
// while keeping the Cerberus framework enabled for break glass testing.
func (h *EmergencyHandler) disableAllSecurityModules() ([]string, error) {
disabledModules := []string{}
// Settings to disable - NOTE: We keep feature.cerberus.enabled = true
// so E2E tests can validate break glass functionality.
// Only individual security modules are disabled for clean test state.
securitySettings := map[string]string{
// Feature framework stays ENABLED (removed from this map)
// "feature.cerberus.enabled": "false", ← BUG FIX: Keep framework enabled
// "security.cerberus.enabled": "false", ← BUG FIX: Keep framework enabled
// Individual security modules disabled for clean slate
"security.acl.enabled": "false",
"security.waf.enabled": "false",
"security.rate_limit.enabled": "false",
"security.crowdsec.enabled": "false",
"security.crowdsec.mode": "disabled",
}
// Disable each module via settings
for key, value := range securitySettings {
setting := models.Setting{
Key: key,
Value: value,
Category: "security",
Type: "bool",
}
if err := h.db.Where(models.Setting{Key: key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
return disabledModules, fmt.Errorf("failed to disable %s: %w", key, err)
}
disabledModules = append(disabledModules, key)
}
// Also update the SecurityConfig record if it exists
var securityConfig models.SecurityConfig
if err := h.db.Where("name = ?", "default").First(&securityConfig).Error; err == nil {
securityConfig.Enabled = false
securityConfig.WAFMode = "disabled"
securityConfig.RateLimitMode = "disabled"
securityConfig.RateLimitEnable = false
securityConfig.CrowdSecMode = "disabled"
if err := h.db.Save(&securityConfig).Error; err != nil {
log.WithError(err).Warn("Failed to update SecurityConfig record during emergency reset")
}
}
return disabledModules, nil
}
// logAudit logs an emergency action to the security audit trail
func (h *EmergencyHandler) logAudit(actor, action, details string) {
if h.securityService == nil {
return
}
audit := &models.SecurityAudit{
Actor: actor,
Action: action,
Details: details,
}
if err := h.securityService.LogAudit(audit); err != nil {
log.WithError(err).Error("Failed to log emergency audit event")
}
}
// logEnhancedAudit logs an emergency action with enhanced metadata (timestamp, result, duration)
func (h *EmergencyHandler) logEnhancedAudit(actor, action, details string, success bool, duration time.Duration) {
if h.securityService == nil {
return
}
result := "failure"
if success {
result = "success"
}
enhancedDetails := fmt.Sprintf("%s | result=%s | duration=%dms | timestamp=%s",
details,
result,
duration.Milliseconds(),
time.Now().UTC().Format(time.RFC3339))
audit := &models.SecurityAudit{
Actor: actor,
Action: action,
Details: enhancedDetails,
}
if err := h.securityService.LogAudit(audit); err != nil {
log.WithError(err).Error("Failed to log emergency audit event")
}
}
func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
if h.cerberus != nil {
h.cerberus.InvalidateCache()
}
if h.caddyManager == nil {
return
}
applyCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := h.caddyManager.ApplyConfig(applyCtx); err != nil {
log.WithError(err).Warn("Failed to reload Caddy config after emergency reset")
}
}
// GenerateToken generates a new emergency token with expiration policy
// POST /api/v1/emergency/token/generate
// Requires admin authentication
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// Get user ID from context
userID, _ := c.Get("userID")
var userIDPtr *uint
if id, ok := userID.(uint); ok {
userIDPtr = &id
}
// Parse request body
type GenerateTokenRequest struct {
ExpirationDays int `json:"expiration_days"` // 0 = never, 30/60/90 = preset, 1-365 = custom
}
var req GenerateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate expiration days
if req.ExpirationDays < 0 || req.ExpirationDays > 365 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Expiration days must be between 0 and 365"})
return
}
// Generate token
response, err := h.tokenService.Generate(services.GenerateRequest{
ExpirationDays: req.ExpirationDays,
UserID: userIDPtr,
})
if err != nil {
log.WithError(err).Error("Failed to generate emergency token")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Audit log
clientIP := util.CanonicalizeIPForSecurity(c.ClientIP())
h.logAudit(clientIP, "emergency_token_generated", fmt.Sprintf("Policy: %s, Expires: %v", response.ExpirationPolicy, response.ExpiresAt))
c.JSON(http.StatusOK, response)
}
// GetTokenStatus returns token metadata (not the token itself)
// GET /api/v1/emergency/token/status
// Requires admin authentication
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
status, err := h.tokenService.GetStatus()
if err != nil {
log.WithError(err).Error("Failed to get token status")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get token status"})
return
}
c.JSON(http.StatusOK, status)
}
// RevokeToken revokes the current emergency token
// DELETE /api/v1/emergency/token
// Requires admin authentication
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
if err := h.tokenService.Revoke(); err != nil {
log.WithError(err).Error("Failed to revoke emergency token")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit log
clientIP := util.CanonicalizeIPForSecurity(c.ClientIP())
h.logAudit(clientIP, "emergency_token_revoked", "Token revoked by admin")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Emergency token revoked",
})
}
// UpdateTokenExpiration updates the expiration policy for the current token
// PATCH /api/v1/emergency/token/expiration
// Requires admin authentication
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// Parse request body
type UpdateExpirationRequest struct {
ExpirationDays int `json:"expiration_days"` // 0 = never, 30/60/90 = preset, 1-365 = custom
}
var req UpdateExpirationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate expiration days
if req.ExpirationDays < 0 || req.ExpirationDays > 365 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Expiration days must be between 0 and 365"})
return
}
// Update expiration
expiresAt, err := h.tokenService.UpdateExpiration(req.ExpirationDays)
if err != nil {
log.WithError(err).Error("Failed to update token expiration")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit log
clientIP := util.CanonicalizeIPForSecurity(c.ClientIP())
h.logAudit(clientIP, "emergency_token_expiration_updated", fmt.Sprintf("New expiration: %v", expiresAt))
c.JSON(http.StatusOK, gin.H{
"success": true,
"new_expires_at": expiresAt,
})
}