chore: remove Shoutrrr residue and dead notification legacy code
Remove all deprecated Shoutrrr integration artifacts and dead legacy fallback code from the notification subsystem. - Remove legacySendFunc field, ErrLegacyFallbackDisabled error, and legacyFallbackInvocationError() from notification service - Delete ShouldUseLegacyFallback() from notification router; simplify ShouldUseNotify() by removing now-dead providerEngine parameter - Remove EngineLegacy engine constant; EngineNotifyV1 is the sole engine - Remove legacy.fallback_enabled feature flag, retiredLegacyFallbackEnvAliases, and parseFlagBool/resolveRetiredLegacyFallback helpers from flags handler - Remove orphaned EmailRecipients field from NotificationConfig model - Delete feature_flags_coverage_v2_test.go (tested only the retired flag path) - Delete security_notifications_test.go.archived (stale archived file) - Move FIREFOX_E2E_FIXES_SUMMARY.md to docs/implementation/ - Remove root-level scan artifacts tracked in error; add gitignore patterns to prevent future tracking of trivy-report.json and related outputs - Update ARCHITECTURE.instructions.md: Notifications row Shoutrrr → Notify No functional changes to active notification dispatch or mail delivery.
This commit is contained in:
@@ -130,7 +130,7 @@ graph TB
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
| **Notifications** | Shoutrrr | Latest | Multi-platform alerts |
|
||||
| **Notifications** | Notify | Latest | Multi-platform alerts |
|
||||
| **Docker Client** | Docker SDK | Latest | Container discovery |
|
||||
| **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation |
|
||||
|
||||
@@ -1263,8 +1263,8 @@ docker exec charon /app/scripts/restore-backup.sh \
|
||||
- Future: Dynamic plugin loading for custom providers
|
||||
|
||||
2. **Notification Channels:**
|
||||
- Shoutrrr provides 40+ channels (Discord, Slack, Email, etc.)
|
||||
- Custom channels via Shoutrrr service URLs
|
||||
- Notify provides multi-platform channels (Discord, Slack, Gotify, etc.)
|
||||
- Provider-based configuration with per-channel feature flags
|
||||
|
||||
3. **Authentication Providers:**
|
||||
- Current: Local database authentication
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -78,6 +78,11 @@ backend/node_modules/
|
||||
backend/package.json
|
||||
backend/package-lock.json
|
||||
|
||||
# Root-level artifact files (non-documentation)
|
||||
FIREFOX_E2E_FIXES_SUMMARY.md
|
||||
verify-security-state-for-ui-tests
|
||||
categories.txt
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Databases
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -297,6 +302,7 @@ docs/plans/current_spec_notes.md
|
||||
tests/etc/passwd
|
||||
trivy-image-report.json
|
||||
trivy-fs-report.json
|
||||
trivy-report.json
|
||||
backend/# Tools Configuration.md
|
||||
docs/plans/requirements.md
|
||||
docs/plans/design.md
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -127,179 +126,3 @@ func TestBlocker3_SecurityProviderEventsFlagCanBeEnabled(t *testing.T) {
|
||||
assert.True(t, response["feature.notifications.security_provider_events.enabled"],
|
||||
"security_provider_events flag should be true when enabled in DB")
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue tests that attempting to set legacy fallback to true returns error code LEGACY_FALLBACK_REMOVED.
|
||||
func TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Attempt to set legacy fallback to true
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateFlags(c)
|
||||
|
||||
// Must return 400 with code LEGACY_FALLBACK_REMOVED
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "retired")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", response["code"])
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse tests that setting legacy fallback to false is allowed (forced false).
|
||||
func TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Set legacy fallback to false (should be accepted and forced)
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify in DB that it's false
|
||||
var setting models.Setting
|
||||
db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse tests that GET always returns false for legacy fallback.
|
||||
func TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Scenario 1: No DB entry
|
||||
t.Run("no_db_entry", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when no DB entry")
|
||||
})
|
||||
|
||||
// Scenario 2: DB entry says true (invalid, forced false)
|
||||
t.Run("db_entry_true", func(t *testing.T) {
|
||||
// Force a true value in DB (simulating legacy state)
|
||||
setting := models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "true",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}
|
||||
db.Create(&setting)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even when DB says true")
|
||||
|
||||
// Clean up
|
||||
db.Unscoped().Delete(&setting)
|
||||
})
|
||||
|
||||
// Scenario 3: DB entry says false
|
||||
t.Run("db_entry_false", func(t *testing.T) {
|
||||
setting := models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "false",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}
|
||||
db.Create(&setting)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when DB says false")
|
||||
|
||||
// Clean up
|
||||
db.Unscoped().Delete(&setting)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_InvalidEnvValue tests that invalid environment variable values are handled (lines 157-158)
|
||||
func TestLegacyFallbackRemoved_InvalidEnvValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Set invalid environment variable value
|
||||
t.Setenv("CHARON_NOTIFICATIONS_LEGACY_FALLBACK", "invalid-value")
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
// Lines 157-158: Should log warning for invalid env value and return hard-false
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even with invalid env value")
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestResolveRetiredLegacyFallback_InvalidPersistedValue covers lines 139-140
|
||||
func TestResolveRetiredLegacyFallback_InvalidPersistedValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Create setting with invalid value for retired fallback flag
|
||||
db.Create(&models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "invalid_value_not_bool",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
})
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should log warning and return false (lines 139-140)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
|
||||
// TestResolveRetiredLegacyFallback_InvalidEnvValue covers lines 149-150
|
||||
func TestResolveRetiredLegacyFallback_InvalidEnvValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Set invalid env var for retired fallback flag
|
||||
t.Setenv("CHARON_LEGACY_FALLBACK_ENABLED", "not_a_boolean")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should log warning and return false (lines 149-150)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
|
||||
// TestResolveRetiredLegacyFallback_DefaultFalse covers lines 157-158
|
||||
func TestResolveRetiredLegacyFallback_DefaultFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// No DB value, no env vars - should default to false
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should return false (lines 157-158)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
@@ -32,7 +32,6 @@ var defaultFlags = []string{
|
||||
"feature.notifications.service.discord.enabled",
|
||||
"feature.notifications.service.gotify.enabled",
|
||||
"feature.notifications.service.webhook.enabled",
|
||||
"feature.notifications.legacy.fallback_enabled",
|
||||
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
|
||||
}
|
||||
|
||||
@@ -44,15 +43,9 @@ var defaultFlagValues = map[string]bool{
|
||||
"feature.notifications.service.discord.enabled": false,
|
||||
"feature.notifications.service.gotify.enabled": false,
|
||||
"feature.notifications.service.webhook.enabled": false,
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
|
||||
}
|
||||
|
||||
var retiredLegacyFallbackEnvAliases = []string{
|
||||
"FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
"NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
}
|
||||
|
||||
// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
|
||||
// and falls back to environment variables if present.
|
||||
func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
@@ -86,11 +79,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
defaultVal = v
|
||||
}
|
||||
|
||||
if key == "feature.notifications.legacy.fallback_enabled" {
|
||||
result[key] = h.resolveRetiredLegacyFallback(settingsMap)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if flag exists in DB
|
||||
if s, exists := settingsMap[key]; exists {
|
||||
v := strings.ToLower(strings.TrimSpace(s.Value))
|
||||
@@ -131,40 +119,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func parseFlagBool(raw string) (bool, bool) {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch v {
|
||||
case "1", "true", "yes":
|
||||
return true, true
|
||||
case "0", "false", "no":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FeatureFlagsHandler) resolveRetiredLegacyFallback(settingsMap map[string]models.Setting) bool {
|
||||
const retiredKey = "feature.notifications.legacy.fallback_enabled"
|
||||
|
||||
if s, exists := settingsMap[retiredKey]; exists {
|
||||
if _, ok := parseFlagBool(s.Value); !ok {
|
||||
log.Printf("[WARN] Invalid persisted retired fallback flag value, forcing disabled: key=%s value=%q", retiredKey, s.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, alias := range retiredLegacyFallbackEnvAliases {
|
||||
if ev, ok := os.LookupEnv(alias); ok {
|
||||
if _, parsed := parseFlagBool(ev); !parsed {
|
||||
log.Printf("[WARN] Invalid environment retired fallback flag value, forcing disabled: key=%s value=%q", alias, ev)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
|
||||
func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
// Phase 0: Performance instrumentation
|
||||
@@ -180,14 +134,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if v, exists := payload["feature.notifications.legacy.fallback_enabled"]; exists && v {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "feature.notifications.legacy.fallback_enabled is retired and can only be false",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Transaction wrapping - all updates in single atomic transaction
|
||||
if err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range payload {
|
||||
@@ -203,10 +149,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
if k == "feature.notifications.legacy.fallback_enabled" {
|
||||
v = false
|
||||
}
|
||||
|
||||
s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
|
||||
if err := tx.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
|
||||
return err // Rollback on error
|
||||
|
||||
@@ -100,147 +100,6 @@ func TestFeatureFlags_EnvFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_DenyByDefault(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
|
||||
if err := db.Create(&models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "true",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed setting: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to remain false even when persisted/env are true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_EnvAliasResolvesFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to remain false for env alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_UpdateRejectsLegacyFallbackTrue(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
|
||||
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_UpdatePersistsLegacyFallbackFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
|
||||
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var s models.Setting
|
||||
if err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&s).Error; err != nil {
|
||||
t.Fatalf("expected setting persisted: %v", err)
|
||||
}
|
||||
if s.Value != "false" {
|
||||
t.Fatalf("expected persisted fallback value false, got %s", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// setupBenchmarkFlagsDB creates an in-memory SQLite database for feature flags benchmarks
|
||||
func setupBenchmarkFlagsDB(b *testing.B) *gorm.DB {
|
||||
b.Helper()
|
||||
@@ -428,32 +287,3 @@ func TestUpdateFlags_TransactionAtomic(t *testing.T) {
|
||||
t.Errorf("expected crowdsec.console_enrollment to be true, got %s", s3.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureFlags_InvalidRetiredEnvAlias covers lines 157-158 (invalid env var warning)
|
||||
func TestFeatureFlags_InvalidRetiredEnvAlias(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "invalid-value")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
// Should force disabled due to invalid value (lines 157-158)
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to be false for invalid env value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mockSecurityNotificationService implements the service interface for controlled testing.
|
||||
type mockSecurityNotificationService struct {
|
||||
getSettingsFunc func() (*models.NotificationConfig, error)
|
||||
updateSettingsFunc func(*models.NotificationConfig) error
|
||||
}
|
||||
|
||||
func (m *mockSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) {
|
||||
if m.getSettingsFunc != nil {
|
||||
return m.getSettingsFunc()
|
||||
}
|
||||
return &models.NotificationConfig{}, nil
|
||||
}
|
||||
|
||||
func (m *mockSecurityNotificationService) UpdateSettings(c *models.NotificationConfig) error {
|
||||
if m.updateSettingsFunc != nil {
|
||||
return m.updateSettingsFunc(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupSecNotifTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationConfig{}))
|
||||
return db
|
||||
}
|
||||
|
||||
// TestNewSecurityNotificationHandler verifies constructor returns non-nil handler.
|
||||
func TestNewSecurityNotificationHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupSecNotifTestDB(t)
|
||||
svc := services.NewSecurityNotificationService(db)
|
||||
handler := NewSecurityNotificationHandler(svc)
|
||||
|
||||
assert.NotNil(t, handler, "Handler should not be nil")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_GetSettings_Success tests successful settings retrieval.
|
||||
func TestSecurityNotificationHandler_GetSettings_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedConfig := &models.NotificationConfig{
|
||||
ID: "test-id",
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return expectedConfig, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody)
|
||||
|
||||
handler.GetSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var config models.NotificationConfig
|
||||
err := json.Unmarshal(w.Body.Bytes(), &config)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedConfig.ID, config.ID)
|
||||
assert.Equal(t, expectedConfig.Enabled, config.Enabled)
|
||||
assert.Equal(t, expectedConfig.MinLogLevel, config.MinLogLevel)
|
||||
assert.Equal(t, expectedConfig.WebhookURL, config.WebhookURL)
|
||||
assert.Equal(t, expectedConfig.NotifyWAFBlocks, config.NotifyWAFBlocks)
|
||||
assert.Equal(t, expectedConfig.NotifyACLDenies, config.NotifyACLDenies)
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_GetSettings_ServiceError tests service error handling.
|
||||
func TestSecurityNotificationHandler_GetSettings_ServiceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return nil, errors.New("database connection failed")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody)
|
||||
|
||||
handler.GetSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Failed to retrieve settings")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidJSON tests malformed JSON handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
malformedJSON := []byte(`{enabled: true, "min_log_level": "error"`)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(malformedJSON))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid request body")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel tests invalid log level rejection.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invalidLevels := []struct {
|
||||
name string
|
||||
level string
|
||||
}{
|
||||
{"trace", "trace"},
|
||||
{"critical", "critical"},
|
||||
{"fatal", "fatal"},
|
||||
{"unknown", "unknown"},
|
||||
}
|
||||
|
||||
for _, tc := range invalidLevels {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: tc.level,
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid min_log_level")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF tests SSRF protection.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ssrfURLs := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"AWS Metadata", "http://169.254.169.254/latest/meta-data/"},
|
||||
{"GCP Metadata", "http://metadata.google.internal/computeMetadata/v1/"},
|
||||
{"Azure Metadata", "http://169.254.169.254/metadata/instance"},
|
||||
{"Private IP 10.x", "http://10.0.0.1/admin"},
|
||||
{"Private IP 172.16.x", "http://172.16.0.1/config"},
|
||||
{"Private IP 192.168.x", "http://192.168.1.1/api"},
|
||||
{"Link-local", "http://169.254.1.1/"},
|
||||
}
|
||||
|
||||
for _, tc := range ssrfURLs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "error",
|
||||
WebhookURL: tc.url,
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid webhook URL")
|
||||
if help, ok := response["help"]; ok {
|
||||
assert.Contains(t, help, "private networks")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook tests private IP handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Note: localhost is allowed by WithAllowLocalhost() option
|
||||
localhostURLs := []string{
|
||||
"http://127.0.0.1/hook",
|
||||
"http://localhost/webhook",
|
||||
"http://[::1]/api",
|
||||
}
|
||||
|
||||
for _, url := range localhostURLs {
|
||||
t.Run(url, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: url,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
// Localhost should be allowed with AllowLocalhost option
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Localhost should be allowed: %s", url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_ServiceError tests database error handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_ServiceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return errors.New("database write failed")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "error",
|
||||
WebhookURL: "http://localhost:9090/webhook", // Use localhost
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Failed to update settings")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_Success tests successful settings update.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedConfig *models.NotificationConfig
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
capturedConfig = c
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "http://localhost:8080/security", // Use localhost which is allowed
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Settings updated successfully", response["message"])
|
||||
|
||||
// Verify the service was called with the correct config
|
||||
require.NotNil(t, capturedConfig)
|
||||
assert.Equal(t, config.Enabled, capturedConfig.Enabled)
|
||||
assert.Equal(t, config.MinLogLevel, capturedConfig.MinLogLevel)
|
||||
assert.Equal(t, config.WebhookURL, capturedConfig.WebhookURL)
|
||||
assert.Equal(t, config.NotifyWAFBlocks, capturedConfig.NotifyWAFBlocks)
|
||||
assert.Equal(t, config.NotifyACLDenies, capturedConfig.NotifyACLDenies)
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL tests empty webhook is valid.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "info",
|
||||
WebhookURL: "",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Settings updated successfully", response["message"])
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_RouteAliasGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedConfig := &models.NotificationConfig{
|
||||
ID: "alias-test-id",
|
||||
Enabled: true,
|
||||
MinLogLevel: "info",
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
}
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return expectedConfig, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/security/notifications/settings", handler.GetSettings)
|
||||
router.GET("/api/v1/notifications/settings/security", handler.GetSettings)
|
||||
|
||||
originalWriter := httptest.NewRecorder()
|
||||
originalRequest := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody)
|
||||
router.ServeHTTP(originalWriter, originalRequest)
|
||||
|
||||
aliasWriter := httptest.NewRecorder()
|
||||
aliasRequest := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody)
|
||||
router.ServeHTTP(aliasWriter, aliasRequest)
|
||||
|
||||
assert.Equal(t, http.StatusOK, originalWriter.Code)
|
||||
assert.Equal(t, originalWriter.Code, aliasWriter.Code)
|
||||
assert.Equal(t, originalWriter.Body.String(), aliasWriter.Body.String())
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_RouteAliasUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
legacyUpdates := 0
|
||||
canonicalUpdates := 0
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
if c.WebhookURL == "http://localhost:8080/security" {
|
||||
canonicalUpdates++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "http://localhost:8080/security",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
setAdminContext(c)
|
||||
c.Next()
|
||||
})
|
||||
router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings)
|
||||
router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings)
|
||||
|
||||
originalWriter := httptest.NewRecorder()
|
||||
originalRequest := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
originalRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(originalWriter, originalRequest)
|
||||
|
||||
aliasWriter := httptest.NewRecorder()
|
||||
aliasRequest := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
|
||||
aliasRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(aliasWriter, aliasRequest)
|
||||
|
||||
assert.Equal(t, http.StatusGone, originalWriter.Code)
|
||||
assert.Equal(t, "true", originalWriter.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", originalWriter.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
assert.Equal(t, http.StatusOK, aliasWriter.Code)
|
||||
assert.Equal(t, 0, legacyUpdates)
|
||||
assert.Equal(t, 1, canonicalUpdates)
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_DeprecatedRouteHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return &models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}, nil
|
||||
},
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
setAdminContext(c)
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/api/v1/security/notifications/settings", handler.DeprecatedGetSettings)
|
||||
router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings)
|
||||
router.GET("/api/v1/notifications/settings/security", handler.GetSettings)
|
||||
router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings)
|
||||
|
||||
legacyGet := httptest.NewRecorder()
|
||||
legacyGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody)
|
||||
router.ServeHTTP(legacyGet, legacyGetReq)
|
||||
require.Equal(t, http.StatusOK, legacyGet.Code)
|
||||
assert.Equal(t, "true", legacyGet.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyGet.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
canonicalGet := httptest.NewRecorder()
|
||||
canonicalGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody)
|
||||
router.ServeHTTP(canonicalGet, canonicalGetReq)
|
||||
require.Equal(t, http.StatusOK, canonicalGet.Code)
|
||||
assert.Empty(t, canonicalGet.Header().Get("X-Charon-Deprecated"))
|
||||
|
||||
body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"})
|
||||
require.NoError(t, err)
|
||||
|
||||
legacyPut := httptest.NewRecorder()
|
||||
legacyPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
legacyPutReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(legacyPut, legacyPutReq)
|
||||
require.Equal(t, http.StatusGone, legacyPut.Code)
|
||||
assert.Equal(t, "true", legacyPut.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyPut.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
var legacyBody map[string]string
|
||||
err = json.Unmarshal(legacyPut.Body.Bytes(), &legacyBody)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, legacyBody, 2)
|
||||
assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", legacyBody["error"])
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyBody["canonical_endpoint"])
|
||||
|
||||
canonicalPut := httptest.NewRecorder()
|
||||
canonicalPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
|
||||
canonicalPutReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(canonicalPut, canonicalPutReq)
|
||||
require.Equal(t, http.StatusOK, canonicalPut.Code)
|
||||
}
|
||||
|
||||
func TestNormalizeEmailRecipients(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: " ",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single valid",
|
||||
input: "admin@example.com",
|
||||
want: "admin@example.com",
|
||||
},
|
||||
{
|
||||
name: "multiple valid with spaces and blanks",
|
||||
input: " admin@example.com, , ops@example.com ,security@example.com ",
|
||||
want: "admin@example.com, ops@example.com, security@example.com",
|
||||
},
|
||||
{
|
||||
name: "duplicates and mixed case preserved",
|
||||
input: "Admin@Example.com, admin@example.com, Admin@Example.com",
|
||||
want: "Admin@Example.com, admin@example.com, Admin@Example.com",
|
||||
},
|
||||
{
|
||||
name: "invalid only",
|
||||
input: "not-an-email",
|
||||
wantErr: "invalid email recipients: not-an-email",
|
||||
},
|
||||
{
|
||||
name: "mixed invalid and valid",
|
||||
input: "admin@example.com, bad-address,ops@example.com",
|
||||
wantErr: "invalid email recipients: bad-address",
|
||||
},
|
||||
{
|
||||
name: "multiple invalids",
|
||||
input: "bad-address,also-bad",
|
||||
wantErr: "invalid email recipients: bad-address, also-bad",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeEmailRecipients(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields tests that all JSON fields are returned
|
||||
func TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"})
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.DeprecatedUpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusGone, w.Code)
|
||||
assert.Equal(t, "true", w.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", w.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both JSON fields are present with exact values
|
||||
assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", response["error"])
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", response["canonical_endpoint"])
|
||||
assert.Len(t, response, 2, "Should have exactly 2 fields in JSON response")
|
||||
}
|
||||
@@ -131,16 +131,6 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED)
|
||||
if req.Key == "feature.notifications.legacy.fallback_enabled" &&
|
||||
strings.EqualFold(strings.TrimSpace(req.Value), "true") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Legacy fallback has been removed and cannot be re-enabled",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "security.admin_whitelist" {
|
||||
if err := validateAdminWhitelist(req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)})
|
||||
@@ -279,12 +269,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
|
||||
if err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for key, value := range updates {
|
||||
// Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED)
|
||||
if key == "feature.notifications.legacy.fallback_enabled" &&
|
||||
strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||
return fmt.Errorf("legacy fallback has been removed and cannot be re-enabled")
|
||||
}
|
||||
|
||||
if key == "security.admin_whitelist" {
|
||||
if err := validateAdminWhitelist(value); err != nil {
|
||||
return fmt.Errorf("invalid admin_whitelist: %w", err)
|
||||
@@ -321,13 +305,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
if strings.Contains(err.Error(), "legacy fallback has been removed") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Legacy fallback has been removed and cannot be re-enabled",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrInvalidAdminCIDR) || strings.Contains(err.Error(), "invalid admin_whitelist") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
|
||||
return
|
||||
|
||||
@@ -516,81 +516,6 @@ func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T)
|
||||
assert.Equal(t, 1, mgr.calls)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_BlocksLegacyFallbackFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"true lowercase", "true"},
|
||||
{"true uppercase", "TRUE"},
|
||||
{"true mixed case", "True"},
|
||||
{"true with whitespace", " true "},
|
||||
{"true with tabs", "\ttrue\t"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"key": "feature.notifications.legacy.fallback_enabled",
|
||||
"value": tc.value,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, resp["error"], "Legacy fallback has been removed")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"])
|
||||
|
||||
// Verify flag was not saved to database
|
||||
var setting models.Setting
|
||||
err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.Error(t, err) // Should not exist
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_AllowsLegacyFallbackFlagFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
payload := map[string]string{
|
||||
"key": "feature.notifications.legacy.fallback_enabled",
|
||||
"value": "false",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify flag was saved to database with false value
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -774,98 +699,6 @@ func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T)
|
||||
assert.True(t, cfg.Enabled)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_BlocksLegacyFallbackFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{"nested true", map[string]any{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
{"flat key true", map[string]any{
|
||||
"feature.notifications.legacy.fallback_enabled": "true",
|
||||
}},
|
||||
{"nested string true", map[string]any{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(tc.payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, resp["error"], "Legacy fallback has been removed")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"])
|
||||
|
||||
// Verify flag was not saved to database
|
||||
var setting models.Setting
|
||||
err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.Error(t, err) // Should not exist
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_AllowsLegacyFallbackFlagFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify flag was saved to database with false value
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
@@ -18,7 +18,6 @@ type NotificationConfig struct {
|
||||
NotifyACLDenies bool `json:"security_acl_enabled"`
|
||||
NotifyRateLimitHits bool `json:"security_rate_limit_enabled"`
|
||||
NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"`
|
||||
EmailRecipients string `json:"email_recipients"`
|
||||
|
||||
// Legacy destination fields (compatibility, not stored in DB)
|
||||
DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package notifications
|
||||
import "context"
|
||||
|
||||
const (
|
||||
EngineLegacy = "legacy"
|
||||
EngineNotifyV1 = "notify_v1"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,21 +2,18 @@ package notifications
|
||||
|
||||
import "strings"
|
||||
|
||||
// NOTE: used only in tests
|
||||
type Router struct{}
|
||||
|
||||
func NewRouter() *Router {
|
||||
return &Router{}
|
||||
}
|
||||
|
||||
func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[string]bool) bool {
|
||||
func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) bool {
|
||||
if !flags[FlagNotifyEngineEnabled] {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.EqualFold(providerEngine, EngineLegacy) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(providerType) {
|
||||
case "discord":
|
||||
return flags[FlagDiscordServiceEnabled]
|
||||
@@ -28,10 +25,3 @@ func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) ShouldUseLegacyFallback(flags map[string]bool) bool {
|
||||
// Hard-disabled: Legacy fallback has been permanently removed.
|
||||
// This function exists only for interface compatibility and always returns false.
|
||||
_ = flags // Explicitly ignore flags to prevent accidental re-introduction
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,37 +10,15 @@ func TestRouter_ShouldUseNotify(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing for discord when enabled")
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineLegacy, flags) {
|
||||
t.Fatalf("expected legacy engine to stay on legacy path")
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("telegram", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("telegram", flags) {
|
||||
t.Fatalf("expected unsupported service to remain legacy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ShouldUseLegacyFallback(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{}) {
|
||||
t.Fatalf("expected fallback disabled by default")
|
||||
}
|
||||
|
||||
// Note: FlagLegacyFallbackEnabled constant has been removed as part of hard-disable
|
||||
// Using string literal for test completeness
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": false}) {
|
||||
t.Fatalf("expected fallback disabled when flag is false")
|
||||
}
|
||||
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": true}) {
|
||||
t.Fatalf("expected fallback disabled even when flag is true (hard-disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouter_ShouldUseNotify_EngineDisabled covers lines 13-14
|
||||
func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) {
|
||||
router := NewRouter()
|
||||
@@ -50,7 +28,7 @@ func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: true,
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing disabled when FlagNotifyEngineEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -64,7 +42,7 @@ func TestRouter_ShouldUseNotify_DiscordServiceFlag(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: false,
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing disabled for discord when FlagDiscordServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -79,14 +57,14 @@ func TestRouter_ShouldUseNotify_GotifyServiceFlag(t *testing.T) {
|
||||
FlagGotifyServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("gotify", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("gotify", flags) {
|
||||
t.Fatalf("expected notify routing enabled for gotify when FlagGotifyServiceEnabled is true")
|
||||
}
|
||||
|
||||
// Test with gotify disabled
|
||||
flags[FlagGotifyServiceEnabled] = false
|
||||
|
||||
if router.ShouldUseNotify("gotify", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("gotify", flags) {
|
||||
t.Fatalf("expected notify routing disabled for gotify when FlagGotifyServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -99,12 +77,12 @@ func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) {
|
||||
FlagWebhookServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("webhook", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("webhook", flags) {
|
||||
t.Fatalf("expected notify routing enabled for webhook when FlagWebhookServiceEnabled is true")
|
||||
}
|
||||
|
||||
flags[FlagWebhookServiceEnabled] = false
|
||||
if router.ShouldUseNotify("webhook", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("webhook", flags) {
|
||||
t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/notifications"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -170,41 +169,6 @@ func TestDiscordOnly_SendViaProvidersFiltersNonDiscord(t *testing.T) {
|
||||
_ = originalDispatch // Suppress unused warning
|
||||
}
|
||||
|
||||
// TestNoFallbackPath_RouterAlwaysReturnsFalse tests that the router never enables legacy fallback.
|
||||
func TestNoFallbackPath_RouterAlwaysReturnsFalse(t *testing.T) {
|
||||
// Import router to test actual routing behavior
|
||||
router := notifications.NewRouter()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
flags map[string]bool
|
||||
}{
|
||||
{"no_flags", map[string]bool{}},
|
||||
{"fallback_false", map[string]bool{"feature.notifications.legacy.fallback_enabled": false}},
|
||||
{"fallback_true", map[string]bool{"feature.notifications.legacy.fallback_enabled": true}},
|
||||
{"all_enabled", map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
"feature.notifications.engine.notify_v1.enabled": true,
|
||||
"feature.notifications.service.discord.enabled": true,
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Concrete assertion: Router always returns false regardless of flag state
|
||||
shouldFallback := router.ShouldUseLegacyFallback(tc.flags)
|
||||
assert.False(t, shouldFallback,
|
||||
"Router must return false for all flag combinations - legacy fallback is permanently disabled")
|
||||
|
||||
// Proof: Even when flag is explicitly true, router returns false
|
||||
if tc.flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
assert.False(t, shouldFallback,
|
||||
"Router ignores legacy fallback flag and always returns false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks tests that the service has no legacy dispatch hooks.
|
||||
func TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -56,12 +55,6 @@ func normalizeURL(serviceType, rawURL string) string {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
var ErrLegacyFallbackDisabled = errors.New("legacy fallback is retired and disabled")
|
||||
|
||||
func legacyFallbackInvocationError(providerType string) error {
|
||||
return fmt.Errorf("%w: provider type %q is not supported by notify-only runtime", ErrLegacyFallbackDisabled, providerType)
|
||||
}
|
||||
|
||||
func validateDiscordWebhookURL(rawURL string) error {
|
||||
parsedURL, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -232,8 +225,7 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
|
||||
}
|
||||
go func(p models.NotificationProvider) {
|
||||
if !supportsJSONTemplates(p.Type) {
|
||||
err := legacyFallbackInvocationError(p.Type)
|
||||
logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Notify-only runtime blocked legacy fallback invocation")
|
||||
logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,12 +236,6 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title
|
||||
}
|
||||
}
|
||||
|
||||
// legacySendFunc is a test hook for outbound sends.
|
||||
// In notify-only mode this path is retired and always fails closed.
|
||||
var legacySendFunc = func(_ string, _ string) error {
|
||||
return ErrLegacyFallbackDisabled
|
||||
}
|
||||
|
||||
// webhookDoRequestFunc is a test hook for outbound JSON webhook requests.
|
||||
// In production it defaults to (*http.Client).Do.
|
||||
var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
@@ -464,7 +450,7 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider)
|
||||
}
|
||||
|
||||
if !supportsJSONTemplates(providerType) {
|
||||
return legacyFallbackInvocationError(providerType)
|
||||
return fmt.Errorf("provider type %q does not support JSON templates", providerType)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
|
||||
@@ -425,35 +425,6 @@ func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) {
|
||||
assert.Equal(t, "discord://xyz@456", got2)
|
||||
}
|
||||
|
||||
func TestSendExternal_SkipsInvalidHTTPDestination(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
|
||||
|
||||
// Provider with invalid HTTP destination should be skipped before send.
|
||||
require.NoError(t, db.Create(&models.NotificationProvider{
|
||||
Name: "bad",
|
||||
Type: "telegram", // unsupported by notify-only runtime
|
||||
URL: "http://example..com/webhook",
|
||||
Enabled: true,
|
||||
}).Error)
|
||||
|
||||
var called atomic.Bool
|
||||
orig := legacySendFunc
|
||||
defer func() { legacySendFunc = orig }()
|
||||
legacySendFunc = func(_ string, _ string) error {
|
||||
called.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
svc := NewNotificationService(db)
|
||||
svc.SendExternal(context.Background(), "test", "t", "m", nil)
|
||||
|
||||
// Give goroutine a moment; the send should be skipped.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
assert.False(t, called.Load())
|
||||
}
|
||||
|
||||
func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -315,33 +315,6 @@ func TestNotificationService_SendExternal_Filtered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationService_SendExternal_NotifyOnlyBlocksLegacyFallback(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Legacy Telegram",
|
||||
Type: "telegram",
|
||||
URL: "telegram://token@id",
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
}
|
||||
_ = svc.CreateProvider(&provider)
|
||||
|
||||
var called atomic.Bool
|
||||
originalFunc := legacySendFunc
|
||||
legacySendFunc = func(url, msg string) error {
|
||||
called.Store(true)
|
||||
return nil
|
||||
}
|
||||
defer func() { legacySendFunc = originalFunc }()
|
||||
|
||||
svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assert.False(t, called.Load(), "legacy fallback path must not execute")
|
||||
}
|
||||
|
||||
func TestNormalizeURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1697,114 +1670,6 @@ func TestIsValidRedirectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendExternal_UnsupportedProviderFailsClosed(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
var called atomic.Bool
|
||||
originalFunc := legacySendFunc
|
||||
legacySendFunc = func(url, msg string) error {
|
||||
called.Store(true)
|
||||
return nil
|
||||
}
|
||||
defer func() { legacySendFunc = originalFunc }()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "legacy-test",
|
||||
Type: "telegram",
|
||||
URL: "telegram://token@telegram?chats=123",
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
Template: "",
|
||||
}
|
||||
require.NoError(t, db.Create(&provider).Error)
|
||||
|
||||
svc.SendExternal(context.Background(), "proxy_host", "Test Title", "Test Message", nil)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
assert.False(t, called.Load(), "legacy fallback must remain blocked")
|
||||
}
|
||||
|
||||
func TestSendExternal_UnsupportedProviderSkipsFallbackEvenWhenHTTPURL(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
var called atomic.Bool
|
||||
originalFunc := legacySendFunc
|
||||
legacySendFunc = func(url, msg string) error {
|
||||
called.Store(true)
|
||||
return nil
|
||||
}
|
||||
defer func() { legacySendFunc = originalFunc }()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "http-legacy",
|
||||
Type: "pushover",
|
||||
URL: "http://127.0.0.1:8080/webhook",
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
Template: "",
|
||||
}
|
||||
require.NoError(t, db.Create(&provider).Error)
|
||||
|
||||
svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
assert.False(t, called.Load(), "legacy fallback must remain blocked for HTTP URL")
|
||||
}
|
||||
|
||||
func TestSendExternal_UnsupportedProviderPrivateIPStillNoFallback(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
var called atomic.Bool
|
||||
originalFunc := legacySendFunc
|
||||
legacySendFunc = func(url, msg string) error {
|
||||
called.Store(true)
|
||||
return nil
|
||||
}
|
||||
defer func() { legacySendFunc = originalFunc }()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "private-ip",
|
||||
Type: "pushover",
|
||||
URL: "http://10.0.0.1:8080/webhook",
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
Template: "",
|
||||
}
|
||||
require.NoError(t, db.Create(&provider).Error)
|
||||
|
||||
svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
assert.False(t, called.Load(), "legacy fallback must remain blocked for private IP")
|
||||
}
|
||||
|
||||
func TestLegacyFallbackInvocationError(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Test non-supported providers are rejected
|
||||
err := svc.TestProvider(models.NotificationProvider{
|
||||
Type: "telegram",
|
||||
URL: "telegram://token@telegram?chats=1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported provider type")
|
||||
}
|
||||
|
||||
func TestLegacyFallbackInvocationError_DirectHelperAndHook(t *testing.T) {
|
||||
err := legacyFallbackInvocationError("telegram")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "legacy fallback is retired and disabled")
|
||||
assert.Contains(t, err.Error(), "provider type \"telegram\"")
|
||||
|
||||
hookErr := legacySendFunc("ignored", "ignored")
|
||||
require.Error(t, hookErr)
|
||||
assert.ErrorIs(t, hookErr, ErrLegacyFallbackDisabled)
|
||||
}
|
||||
|
||||
func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) {
|
||||
eventCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -38,7 +38,6 @@ func (s *SecurityNotificationService) GetSettings() (*models.NotificationConfig,
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
NotifyRateLimitHits: true,
|
||||
EmailRecipients: "",
|
||||
}, nil
|
||||
}
|
||||
return &config, err
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
actions
|
||||
ci
|
||||
security
|
||||
testing
|
||||
981
docs/plans/archive/user-management-consolidation-spec.md
Normal file
981
docs/plans/archive/user-management-consolidation-spec.md
Normal file
@@ -0,0 +1,981 @@
|
||||
# User Management Consolidation & Privilege Tier System
|
||||
|
||||
Date: 2026-06-25
|
||||
Owner: Planning Agent
|
||||
Status: Proposed
|
||||
Severity: Medium (UX consolidation + feature enhancement)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### 1.1 Overview
|
||||
|
||||
Charon currently manages users through **two separate interfaces**:
|
||||
|
||||
1. **Admin Account** page at `/settings/account` — self-service profile management (name, email, password, API key, certificate email)
|
||||
2. **Account Management** page at `/settings/account-management` — admin-only user CRUD (list, invite, delete, update roles/permissions)
|
||||
|
||||
This split is confusing and redundant. A logged-in admin must navigate to two different places to manage their own account versus managing other users. The system also lacks a formal privilege tier model — the `Role` field on the `User` model is a raw string defaulting to `"user"`, with only `"admin"` and `"user"` exposed in the frontend selector (the model comment mentions `"viewer"` but it is entirely unused).
|
||||
|
||||
### 1.2 Objectives
|
||||
|
||||
1. **Consolidate** user management into a single **"Users"** page that lists ALL users, including the admin.
|
||||
2. **Introduce a formal privilege tier system** with three tiers: **Admin**, **User**, and **Pass-through**.
|
||||
3. **Self-service profile editing** occurs inline or via a detail drawer/modal on the consolidated page — no separate "Admin Account" page.
|
||||
4. **Remove** the `/settings/account` route and its sidebar/tab navigation entry entirely.
|
||||
5. **Update navigation** to a single "Users" entry under Settings (or as a top-level item).
|
||||
6. **Write E2E Playwright tests** for the consolidated page before any implementation changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Current Architecture: Backend
|
||||
|
||||
#### 2.1.1 User Model — `backend/internal/models/user.go`
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Email string `json:"email" gorm:"uniqueIndex"`
|
||||
APIKey string `json:"-" gorm:"uniqueIndex"`
|
||||
PasswordHash string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
FailedLoginAttempts int `json:"-" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"-"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
SessionVersion uint `json:"-" gorm:"default:0"`
|
||||
// Invite system fields ...
|
||||
PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"`
|
||||
PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
**Key observations:**
|
||||
|
||||
| Aspect | Current State | Issue |
|
||||
|--------|--------------|-------|
|
||||
| `Role` field | Raw string, default `"user"` | No Go-level enum/const; `"viewer"` mentioned in comment but unused anywhere |
|
||||
| Permission logic | `CanAccessHost()` — admins bypass all checks | Sound; needs extension for pass-through tier |
|
||||
| JSON serialization | `ID` is `json:"-"`, `APIKey` is `json:"-"` | Correctly hidden from API responses |
|
||||
| Password hashing | bcrypt via `SetPassword()` / `CheckPassword()` | Solid |
|
||||
|
||||
#### 2.1.2 Auth Service — `backend/internal/services/auth_service.go`
|
||||
|
||||
- `Register()`: First user becomes admin (`role = "admin"`)
|
||||
- `Login()`: Account lockout after 5 failed attempts; sets `LastLogin`
|
||||
- `GenerateToken()`: JWT with claims `{UserID, Role, SessionVersion}`, 24h expiry
|
||||
- `AuthenticateToken()`: Validates JWT + checks `Enabled` + matches `SessionVersion`
|
||||
- `InvalidateSessions()`: Increments `SessionVersion` to revoke all existing tokens
|
||||
|
||||
**Impact of role changes:** The `Role` string is embedded in the JWT. When a user's role is changed, their existing JWT still carries the old role until it expires or `SessionVersion` is bumped. The `InvalidateSessions()` method exists for this purpose, **but it is NOT currently called in `UpdateUser()`**. This is a security gap — a user demoted from admin to passthrough retains their admin JWT for up to 24 hours. Task 2.6 addresses this.
|
||||
|
||||
#### 2.1.3 Auth Middleware — `backend/internal/api/middleware/auth.go`
|
||||
|
||||
```go
|
||||
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc
|
||||
func RequireRole(role string) gin.HandlerFunc
|
||||
```
|
||||
|
||||
- `AuthMiddleware`: Extracts token from header/cookie/query, sets `userID` and `role` in Gin context.
|
||||
- `RequireRole`: Checks `role` against a **single** required role string, always allows `"admin"` as fallback. Currently used only for SMTP settings.
|
||||
|
||||
**Gap:** Most admin-only endpoints in `user_handler.go` do inline `role != "admin"` checks via `requireAdmin(c)` in `permission_helpers.go` rather than using middleware. This works but is inconsistent.
|
||||
|
||||
#### 2.1.4 User Handler — `backend/internal/api/handlers/user_handler.go`
|
||||
|
||||
Two disjoint groups of endpoints:
|
||||
|
||||
| Group | Endpoints | Purpose | Auth |
|
||||
|-------|-----------|---------|------|
|
||||
| **Self-service** | `GET /user/profile`, `POST /user/profile`, `POST /user/api-key` | Current user edits own profile/API key | Any authenticated user |
|
||||
| **Admin CRUD** | `GET /users`, `POST /users`, `POST /users/invite`, `GET /users/:id`, `PUT /users/:id`, `DELETE /users/:id`, `PUT /users/:id/permissions`, `POST /users/:id/resend-invite`, `POST /users/preview-invite-url` | Admin manages all users | Admin only (inline check) |
|
||||
|
||||
**Key handler behaviors:**
|
||||
|
||||
- `ListUsers()`: Returns all users with safe fields (excludes password hash, API key). Admin only.
|
||||
- `UpdateUser()`: Can change `name`, `email`, `password`, `role`, `enabled`. **Does NOT call `InvalidateSessions()` on role change** — a demoted user retains their old-role JWT for up to 24 hours. This is a security gap that must be fixed in this spec (see Task 2.6).
|
||||
- `DeleteUser()`: Prevents self-deletion.
|
||||
- `GetProfile()`: Returns `{id, email, name, role, has_api_key, api_key_masked}` for the calling user.
|
||||
- `UpdateProfile()`: Requires `current_password` verification when email changes.
|
||||
- `RegenerateAPIKey()`: Generates new UUID-based API key for the calling user.
|
||||
|
||||
#### 2.1.5 Route Registration — `backend/internal/api/routes/routes.go`
|
||||
|
||||
```
|
||||
Public:
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/register
|
||||
GET /api/v1/auth/verify (forward auth for Caddy)
|
||||
GET /api/v1/auth/status
|
||||
GET /api/v1/setup/status
|
||||
POST /api/v1/setup
|
||||
GET /api/v1/invite/validate
|
||||
POST /api/v1/invite/accept
|
||||
|
||||
Protected (authMiddleware):
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/refresh
|
||||
GET /api/v1/auth/me
|
||||
POST /api/v1/auth/change-password
|
||||
GET /api/v1/user/profile ← Self-service group
|
||||
POST /api/v1/user/profile ←
|
||||
POST /api/v1/user/api-key ←
|
||||
GET /api/v1/users ← Admin CRUD group
|
||||
POST /api/v1/users ←
|
||||
POST /api/v1/users/invite ←
|
||||
POST /api/v1/users/preview-invite-url ←
|
||||
GET /api/v1/users/:id ←
|
||||
PUT /api/v1/users/:id ←
|
||||
DELETE /api/v1/users/:id ←
|
||||
PUT /api/v1/users/:id/permissions ←
|
||||
POST /api/v1/users/:id/resend-invite ←
|
||||
```
|
||||
|
||||
#### 2.1.6 Forward Auth — `backend/internal/api/handlers/auth_handler.go`
|
||||
|
||||
The `Verify()` handler at `GET /api/v1/auth/verify` is the critical integration point for the pass-through tier:
|
||||
|
||||
1. Extracts token from cookie/header/query
|
||||
2. Authenticates user via `AuthenticateToken()`
|
||||
3. Reads `X-Forwarded-Host` from Caddy
|
||||
4. Checks `user.CanAccessHost(hostID)` against the permission model
|
||||
5. Returns `200 OK` with `X-Auth-User` headers or `401`/`403`
|
||||
|
||||
**Pass-through users will use this exact path** — they log in to Charon only to establish a session, then Caddy's `forward_auth` directive validates their access to proxied services. They never need to see the Charon management UI.
|
||||
|
||||
### 2.2 Current Architecture: Frontend
|
||||
|
||||
#### 2.2.1 Router — `frontend/src/App.tsx`
|
||||
|
||||
```tsx
|
||||
// Three routes serve user management:
|
||||
<Route path="users" element={<UsersPage />} /> // top-level
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route path="account" element={<Account />} /> // self-service
|
||||
<Route path="account-management" element={<UsersPage />} /> // admin CRUD
|
||||
</Route>
|
||||
```
|
||||
|
||||
The `UsersPage` component is mounted at **two** routes: `/users` and `/settings/account-management`. The `Account` component is only at `/settings/account`.
|
||||
|
||||
#### 2.2.2 Settings Page — `frontend/src/pages/Settings.tsx`
|
||||
|
||||
Tab bar with 4 items: `System`, `Notifications`, `SMTP`, `Account`. Note that **Account Management is NOT in the tab bar** — it is only reachable via the sidebar. The `Account` tab links to `/settings/account` (the self-service profile page).
|
||||
|
||||
#### 2.2.3 Sidebar Navigation — `frontend/src/components/Layout.tsx`
|
||||
|
||||
Under the Settings section, two entries:
|
||||
|
||||
```tsx
|
||||
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
|
||||
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
|
||||
```
|
||||
|
||||
#### 2.2.4 Account Page — `frontend/src/pages/Account.tsx`
|
||||
|
||||
Four cards:
|
||||
1. **Profile** — name, email (with password verification for email changes)
|
||||
2. **Certificate Email** — toggle between account email and custom email for ACME
|
||||
3. **Password Change** — old/new/confirm password form
|
||||
4. **API Key** — masked display + regenerate button
|
||||
|
||||
Uses `api/user.ts` (singular) — endpoints: `GET /user/profile`, `POST /user/profile`, `POST /user/api-key`.
|
||||
|
||||
#### 2.2.5 Users Page — `frontend/src/pages/UsersPage.tsx`
|
||||
|
||||
~900 lines. Contains:
|
||||
- `InviteModal` — email, role select (`user`/`admin`), permission mode, host selection, URL preview
|
||||
- `PermissionsModal` — permission mode + host checkboxes per user
|
||||
- `UsersPage` (default export) — table listing all users with columns: User (name/email), Role (badge), Status (active/pending/expired), Permissions (whitelist/blacklist), Enabled (toggle), Actions (resend/permissions/delete)
|
||||
|
||||
**Current limitations:**
|
||||
- Admin users cannot be disabled (switch is `disabled` when `role === 'admin'`)
|
||||
- Admin users cannot be deleted (delete button is `disabled` when `role === 'admin'`)
|
||||
- No permission editing for admins (gear icon hidden when `role !== 'admin'`)
|
||||
- Role selector in `InviteModal` only has `user` and `admin` options
|
||||
- No inline profile editing — admins can't edit their own name/email/password from this page
|
||||
- The admin's own row appears in the list but has limited interactivity
|
||||
|
||||
#### 2.2.6 API Client Files
|
||||
|
||||
**`frontend/src/api/user.ts`** (singular — self-service):
|
||||
|
||||
```tsx
|
||||
interface UserProfile { id, email, name, role, has_api_key, api_key_masked }
|
||||
getProfile() → GET /user/profile
|
||||
updateProfile(data) → POST /user/profile
|
||||
regenerateApiKey() → POST /user/api-key
|
||||
```
|
||||
|
||||
**`frontend/src/api/users.ts`** (plural — admin CRUD):
|
||||
|
||||
```tsx
|
||||
interface User { id, uuid, email, name, role, enabled, last_login, invite_status, ... }
|
||||
type PermissionMode = 'allow_all' | 'deny_all'
|
||||
listUsers() → GET /users
|
||||
getUser(id) → GET /users/:id
|
||||
createUser(data) → POST /users
|
||||
inviteUser(data) → POST /users/invite
|
||||
updateUser(id, data) → PUT /users/:id
|
||||
deleteUser(id) → DELETE /users/:id
|
||||
updateUserPermissions(id, d) → PUT /users/:id/permissions
|
||||
validateInvite(token) → GET /invite/validate
|
||||
acceptInvite(data) → POST /invite/accept
|
||||
previewInviteURL(email) → POST /users/preview-invite-url
|
||||
resendInvite(id) → POST /users/:id/resend-invite
|
||||
```
|
||||
|
||||
#### 2.2.7 Auth Context — `frontend/src/context/AuthContextValue.ts`
|
||||
|
||||
```tsx
|
||||
interface User { user_id: number; role: string; name?: string; email?: string; }
|
||||
interface AuthContextType { user, login, logout, changePassword, isAuthenticated, isLoading }
|
||||
```
|
||||
|
||||
The `AuthProvider` in `AuthContext.tsx` fetches `/api/v1/auth/me` on mount and stores the user. Auto-logout after 15 minutes of inactivity.
|
||||
|
||||
#### 2.2.8 Route Protection — `frontend/src/components/RequireAuth.tsx`
|
||||
|
||||
Checks `isAuthenticated`, localStorage token, and `user !== null`. Redirects to `/login` if any check fails. **No role-based route protection exists** — all authenticated users see the same navigation and routes.
|
||||
|
||||
#### 2.2.9 Translation Keys — `frontend/src/locales/en/translation.json`
|
||||
|
||||
Relevant keys:
|
||||
```json
|
||||
"navigation.adminAccount": "Admin Account",
|
||||
"navigation.accountManagement": "Account Management",
|
||||
"users.roleUser": "User",
|
||||
"users.roleAdmin": "Admin"
|
||||
```
|
||||
|
||||
No keys exist for `"roleViewer"` or `"rolePassthrough"`.
|
||||
|
||||
### 2.3 Current Architecture: E2E Tests
|
||||
|
||||
**No Playwright E2E tests exist** for user management or account pages. The `tests/` directory contains specs for DNS providers, modal dropdowns, and debug utilities only.
|
||||
|
||||
### 2.4 Configuration Files Review
|
||||
|
||||
| File | Relevance | Action Needed |
|
||||
|------|-----------|---------------|
|
||||
| `.gitignore` | May need entries for new test artifacts | Review during implementation |
|
||||
| `codecov.yml` | Coverage thresholds may need adjustment | Review if new files change coverage ratios |
|
||||
| `.dockerignore` | No impact expected | No changes |
|
||||
| `Dockerfile` | No impact expected | No changes |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
|
||||
### 3.1 Privilege Tier System
|
||||
|
||||
#### 3.1.1 Tier Definitions
|
||||
|
||||
| Tier | Value | Charon UI Access | Forward Auth (Proxy) Access | Can Manage Others |
|
||||
|------|-------|------------------|---------------------------|-------------------|
|
||||
| **Admin** | `"admin"` | Full access to all pages and settings | All hosts (bypasses permission checks) | Yes — full CRUD on all users |
|
||||
| **User** | `"user"` | Access to Charon UI except admin-only pages (user management, system settings) | Based on `permission_mode` + `permitted_hosts` | No |
|
||||
| **Pass-through** | `"passthrough"` | Login page only — redirected away from all management pages after login | Based on `permission_mode` + `permitted_hosts` | No |
|
||||
|
||||
#### 3.1.2 Behavioral Details
|
||||
|
||||
**Admin:**
|
||||
- Unchanged from current behavior.
|
||||
- Can edit any user's role, permissions, name, email, enabled state.
|
||||
- Can edit their own profile inline on the consolidated Users page.
|
||||
- Cannot delete themselves. Cannot disable themselves.
|
||||
|
||||
**User:**
|
||||
- Can log in to the Charon management UI.
|
||||
- Can view proxy hosts, certificates, DNS, uptime, and other read-oriented pages.
|
||||
- Cannot access: User management, System settings, SMTP settings, Encryption, Plugins.
|
||||
- Can edit their own profile (name, email, password, API key) via self-service section on the Users page or a dedicated "My Account" modal.
|
||||
- Forward auth access is governed by `permission_mode` + `permitted_hosts`.
|
||||
|
||||
**Pass-through:**
|
||||
- Can log in to Charon (to obtain a session cookie/JWT).
|
||||
- Immediately after login, is redirected to `/passthrough` landing page — **no access to the management UI**.
|
||||
- The session cookie is used by Caddy's `forward_auth` to grant access to proxied services based on `permission_mode` + `permitted_hosts`.
|
||||
- Can access **only**: `/auth/logout`, `/auth/refresh`, `/auth/me`, `/auth/change-password`.
|
||||
- **Cannot access**: `/user/profile`, `/user/api-key`, or any management API endpoints.
|
||||
- Cannot access any Charon management UI pages.
|
||||
|
||||
#### 3.1.3 Backend Role Constants
|
||||
|
||||
Add typed constants to `backend/internal/models/user.go`:
|
||||
|
||||
```go
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleUser UserRole = "user"
|
||||
RolePassthrough UserRole = "passthrough"
|
||||
)
|
||||
|
||||
func (r UserRole) IsValid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser, RolePassthrough:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
Change `User.Role` from `string` to `UserRole` (which is still a `string` alias, so GORM and JSON work identically — zero migration friction).
|
||||
|
||||
### 3.2 Database Schema Changes
|
||||
|
||||
#### 3.2.1 User Table
|
||||
|
||||
**No schema migration needed.** The `Role` column is already a `TEXT` field storing string values. Changing the Go type from `string` to `UserRole` (a `string` alias) requires no ALTER TABLE. New role values (`"passthrough"`) are immediately storable.
|
||||
|
||||
#### 3.2.2 Data Migration
|
||||
|
||||
A one-time migration function should:
|
||||
1. Scan for any users with `role = "viewer"` and update them to `role = "passthrough"` (in case any were manually created via the unused viewer path).
|
||||
2. Validate all existing roles are in the allowed set.
|
||||
|
||||
This runs in the `AutoMigrate` block in `routes.go`.
|
||||
|
||||
The migration must be in a clearly named function (e.g., `migrateViewerToPassthrough()`) and must log when it runs and how many records it affected:
|
||||
|
||||
```go
|
||||
func migrateViewerToPassthrough(db *gorm.DB) {
|
||||
result := db.Model(&User{}).Where("role = ?", "viewer").Update("role", string(RolePassthrough))
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("[migration] Updated %d users from 'viewer' to 'passthrough' role", result.RowsAffected)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API Changes
|
||||
|
||||
#### 3.3.1 Consolidated Self-Service Endpoints
|
||||
|
||||
Merge the current `/user/profile` and `/user/api-key` endpoints into the existing `/users/:id` group by allowing users to edit their own record:
|
||||
|
||||
| Current Endpoint | Action | Proposed |
|
||||
|-----------------|--------|----------|
|
||||
| `GET /user/profile` | Get own profile | **Keep** (convenience alias) |
|
||||
| `POST /user/profile` | Update own profile | **Keep** (convenience alias) |
|
||||
| `POST /user/api-key` | Regenerate API key | **Keep** (convenience alias) |
|
||||
| `PUT /users/:id` | Admin updates any user | **Extend**: allow authenticated user to update their own record (name, email with password verification) |
|
||||
|
||||
The self-service endpoints (`/user/*`) remain as convenient aliases that internally resolve to the caller's own user ID and delegate to the same service logic. No breaking API changes.
|
||||
|
||||
#### 3.3.2 Role Validation
|
||||
|
||||
The `UpdateUser()` and `CreateUser()` handlers must validate the `role` field against `UserRole.IsValid()`. The invite endpoint `InviteUser()` must accept `"passthrough"` as a valid role.
|
||||
|
||||
#### 3.3.3 Pass-through Route Protection
|
||||
|
||||
Add middleware to block pass-through users from accessing management endpoints:
|
||||
|
||||
```go
|
||||
func RequireManagementAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "pass-through users cannot access management features",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply this middleware by restructuring the protected routes into two sub-groups:
|
||||
|
||||
**Exempt routes** (authMiddleware only — no management check):
|
||||
```
|
||||
POST /auth/logout
|
||||
POST /auth/refresh
|
||||
GET /auth/me
|
||||
POST /auth/change-password
|
||||
GET /auth/accessible-hosts
|
||||
GET /auth/check-host/:hostId
|
||||
GET /user/profile
|
||||
POST /user/profile
|
||||
POST /user/api-key
|
||||
```
|
||||
|
||||
**Management routes** (authMiddleware + `RequireManagementAccess`):
|
||||
```
|
||||
GET /users
|
||||
POST /users
|
||||
POST /users/invite
|
||||
POST /users/preview-invite-url
|
||||
GET /users/:id
|
||||
PUT /users/:id
|
||||
DELETE /users/:id
|
||||
PUT /users/:id/permissions
|
||||
POST /users/:id/resend-invite
|
||||
All /backup/* routes
|
||||
All /logs/* routes
|
||||
All /settings/* routes
|
||||
All /system/* routes
|
||||
All /security/* routes
|
||||
All /features/* routes
|
||||
All /proxy/* routes
|
||||
All /certificates/* routes
|
||||
All /dns/* routes
|
||||
All /uptime/* routes
|
||||
All /plugins/* routes
|
||||
```
|
||||
|
||||
The route split in `routes.go` should look like:
|
||||
|
||||
```go
|
||||
// Self-service + auth routes — accessible to all authenticated users (including passthrough)
|
||||
exempt := protected.Group("")
|
||||
{
|
||||
exempt.POST("/auth/logout", ...)
|
||||
exempt.POST("/auth/refresh", ...)
|
||||
exempt.GET("/auth/me", ...)
|
||||
exempt.POST("/auth/change-password", ...)
|
||||
exempt.GET("/auth/accessible-hosts", ...)
|
||||
exempt.GET("/auth/check-host/:hostId", ...)
|
||||
exempt.GET("/user/profile", ...)
|
||||
exempt.POST("/user/profile", ...)
|
||||
exempt.POST("/user/api-key", ...)
|
||||
}
|
||||
|
||||
// Management routes — blocked for passthrough users
|
||||
management := protected.Group("", middleware.RequireManagementAccess())
|
||||
{
|
||||
// All other routes registered here
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.4 New Endpoint: Pass-through Landing
|
||||
|
||||
No new backend endpoint needed. The forward auth flow (`GET /auth/verify`) already works for pass-through users. The minimal landing page is purely a frontend concern.
|
||||
|
||||
### 3.4 Frontend Changes
|
||||
|
||||
#### 3.4.1 Consolidated Users Page
|
||||
|
||||
Transform `UsersPage.tsx` into the single source of truth for all user management:
|
||||
|
||||
**New capabilities:**
|
||||
1. **Admin's own row is fully interactive** — edit name/email/password, regenerate API key, view cert email settings.
|
||||
2. **"My Profile" section** at the top (or accessible via a button) for the currently logged-in user to manage their own account, replacing the `Account.tsx` page entirely.
|
||||
3. **Role selector** includes three options: `Admin`, `User`, `Pass-through`.
|
||||
4. **Permission controls** are shown for `User` and `Pass-through` roles (hidden for `Admin`).
|
||||
5. **Inline editing** or a detail drawer for editing individual users.
|
||||
|
||||
**Components to modify:**
|
||||
|
||||
| Component | File | Change |
|
||||
|-----------|------|--------|
|
||||
| `InviteModal` | `UsersPage.tsx` | Add `"passthrough"` to role `<select>`. Show permission controls for both `user` and `passthrough`. |
|
||||
| `PermissionsModal` | `UsersPage.tsx` | No functional changes — already works for any non-admin role. |
|
||||
| `UsersPage` (table) | `UsersPage.tsx` | Add role badge color for `passthrough`. Remove `disabled` from admin row's enable/delete where appropriate. Add "Edit" action for all users. Add "My Profile" card or section. |
|
||||
| New: `UserDetailModal` | `UsersPage.tsx` or separate file | Modal/drawer for editing a user's full details (including password reset, API key regen for self). Replaces the Account page's four cards. |
|
||||
|
||||
#### 3.4.2 Remove Account Page
|
||||
|
||||
| Action | File | Detail |
|
||||
|--------|------|--------|
|
||||
| Delete | `frontend/src/pages/Account.tsx` | Remove entirely |
|
||||
| Delete | `frontend/src/api/user.ts` | Remove (merge any unique types into `users.ts`) |
|
||||
| Remove route | `frontend/src/App.tsx` | Remove `<Route path="account" element={<Account />} />` |
|
||||
| Remove nav entry | `frontend/src/components/Layout.tsx` | Remove `{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' }` |
|
||||
| Remove tab | `frontend/src/pages/Settings.tsx` | Remove `{ path: '/settings/account', label: t('settings.account'), icon: User }` |
|
||||
| Remove translation keys | `frontend/src/locales/en/translation.json` | Remove `navigation.adminAccount`, update `navigation.accountManagement` → `navigation.users` |
|
||||
|
||||
#### 3.4.3 Navigation Update
|
||||
|
||||
**Sidebar** (Layout.tsx): Under Settings, consolidate to a single entry:
|
||||
|
||||
```tsx
|
||||
{ name: t('navigation.users'), path: '/settings/users', icon: '👥' }
|
||||
```
|
||||
|
||||
**Settings tab bar** (Settings.tsx): Add "Users" as a new tab, remove "Account":
|
||||
|
||||
```tsx
|
||||
{ path: '/settings/users', label: t('navigation.users'), icon: Users }
|
||||
```
|
||||
|
||||
**Router** (App.tsx): Update routes:
|
||||
|
||||
```tsx
|
||||
{/* Pass-through landing — accessible to all authenticated users */}
|
||||
<Route path="passthrough" element={<RequireAuth><PassthroughLanding /></RequireAuth>} />
|
||||
|
||||
<Route path="settings" element={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
Add redirects for old paths:
|
||||
```tsx
|
||||
<Route path="settings/account" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="settings/account-management" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
```
|
||||
|
||||
#### 3.4.4 Role-Based Route Protection
|
||||
|
||||
Create a new `RequireRole` wrapper component:
|
||||
|
||||
```tsx
|
||||
// frontend/src/components/RequireRole.tsx
|
||||
const RequireRole: React.FC<{ allowed: string[]; children: React.ReactNode }> = ({ allowed, children }) => {
|
||||
const { user } = useAuth();
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (!allowed.includes(user.role)) {
|
||||
// Role-aware redirect: pass-through users go to their landing page,
|
||||
// other unauthorized roles go to dashboard
|
||||
const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/';
|
||||
return <Navigate to={redirectTarget} replace />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
```
|
||||
|
||||
Wrap admin-only routes (user management, system settings) with `<RequireRole allowed={['admin']}>`.
|
||||
|
||||
For pass-through users, redirect all management routes to a minimal landing page:
|
||||
|
||||
```tsx
|
||||
// frontend/src/pages/PassthroughLanding.tsx
|
||||
// Minimal page: "You are logged in. Your session grants access to authorized services."
|
||||
// Shows: user name, logout button, change password link, optionally a list of accessible hosts.
|
||||
// Route: /passthrough (must be registered in App.tsx router)
|
||||
//
|
||||
// Accessibility requirements (WCAG 2.2 AA):
|
||||
// - Use semantic HTML: <main>, <h1>, <nav>
|
||||
// - Logout button must be keyboard focusable
|
||||
// - Announce page purpose via <title> and <h1>
|
||||
// - Sufficient color contrast on all text (4.5:1 minimum)
|
||||
```
|
||||
|
||||
#### 3.4.5 Auth Context Update
|
||||
|
||||
Update the `User` interface in `AuthContextValue.ts`:
|
||||
|
||||
```tsx
|
||||
export interface User {
|
||||
user_id: number;
|
||||
role: 'admin' | 'user' | 'passthrough';
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.6 API Types Consolidation
|
||||
|
||||
Merge `frontend/src/api/user.ts` into `frontend/src/api/users.ts`:
|
||||
|
||||
- Move `UserProfile` interface into `users.ts` (or inline it).
|
||||
- Keep `getProfile()`, `updateProfile()`, `regenerateApiKey()` functions in `users.ts`.
|
||||
- Delete `user.ts`.
|
||||
|
||||
Update the `User` role type:
|
||||
|
||||
```tsx
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
```
|
||||
|
||||
#### 3.4.7 Translation Keys
|
||||
|
||||
Add to `frontend/src/locales/en/translation.json`:
|
||||
|
||||
```json
|
||||
"users.rolePassthrough": "Pass-through",
|
||||
"users.rolePassthroughDescription": "Can access proxied services only — no Charon management access",
|
||||
"users.roleUserDescription": "Can view Charon management UI with limited permissions",
|
||||
"users.roleAdminDescription": "Full access to all Charon features and settings",
|
||||
"users.myProfile": "My Profile",
|
||||
"users.editUser": "Edit User",
|
||||
"users.changePassword": "Change Password",
|
||||
"users.apiKey": "API Key",
|
||||
"users.regenerateApiKey": "Regenerate API Key",
|
||||
"navigation.users": "Users",
|
||||
"passthrough.title": "Welcome to Charon",
|
||||
"passthrough.description": "Your session grants access to authorized services through the reverse proxy.",
|
||||
"passthrough.noAccessToManagement": "You do not have access to the management interface."
|
||||
```
|
||||
|
||||
Remove:
|
||||
```json
|
||||
"navigation.adminAccount" → delete
|
||||
```
|
||||
|
||||
Rename:
|
||||
```json
|
||||
"navigation.accountManagement" → "navigation.users"
|
||||
```
|
||||
|
||||
### 3.5 Forward Auth & Pass-through Integration
|
||||
|
||||
The existing `Verify()` handler in `auth_handler.go` already handles pass-through users correctly:
|
||||
|
||||
1. User logs in → gets JWT with `role: "passthrough"`.
|
||||
2. User's browser sends cookie with each request to proxied services.
|
||||
3. Caddy's `forward_auth` calls `GET /api/v1/auth/verify`.
|
||||
4. `Verify()` authenticates the token, checks `user.CanAccessHost(hostID)`.
|
||||
5. Pass-through users have `permission_mode` + `permitted_hosts` just like regular users.
|
||||
|
||||
**No changes needed to the forward auth flow.** The `CanAccessHost()` method already handles non-admin roles correctly.
|
||||
|
||||
**Note on X-Forwarded-Groups header:** The `Verify()` handler currently sets `X-Auth-User` headers for upstream services. It does NOT expose the user's role via `X-Forwarded-Groups` or similar headers. For this iteration, the pass-through role is **not exposed** to upstream services — they only see the authenticated user identity. If upstream services need to differentiate pass-through from regular users, a future enhancement can add `X-Auth-Role` headers.
|
||||
|
||||
### 3.6 Error Handling & Edge Cases
|
||||
|
||||
| Scenario | Expected Behavior | Status |
|
||||
|----------|-------------------|--------|
|
||||
| Admin demotes themselves | Prevent — return 400 "Cannot change your own role" | **Needs implementation** |
|
||||
| Admin disables themselves | Prevent — return 400 "Cannot disable your own account" | **Needs implementation** |
|
||||
| Last admin is demoted | Prevent — return 400 "At least one admin must exist". **Must use a DB transaction** around the count-check-and-update to prevent race conditions (see section 3.6.1). | **Needs implementation** |
|
||||
| Concurrent demotion of last two admins | DB transaction serializes the check — second request fails atomically | **Needs implementation** |
|
||||
| Pass-through user accesses `/api/v1/users` | Return 403 "pass-through users cannot access management features" | **Needs implementation** |
|
||||
| Pass-through user accesses Charon UI routes | Frontend redirects to `/passthrough` landing page (NOT `/` which would loop) | **Needs implementation** |
|
||||
| User with active session has role changed | `InvalidateSessions(user.ID)` **must be called** in `UpdateUser()` when role changes — **not currently implemented** (see Task 2.6) | **Needs implementation** |
|
||||
| Non-admin edits own record via `PUT /users/:id` | Handler **must reject** changes to `role`, `enabled`, or `permissions` fields — only `name`, `email` (with password verification), and `password` are self-editable (see Task 2.11) | **Needs implementation** |
|
||||
| Invite with `role: "passthrough"` | Valid — creates pending pass-through user | **Needs validation** |
|
||||
| Pass-through user changes own password | Allowed via `/auth/change-password` (exempt from management middleware) | Already works |
|
||||
|
||||
#### 3.6.1 Last-Admin Race Condition Protection
|
||||
|
||||
Two concurrent demotion requests could both pass the admin count check before either commits. SQLite serializes writes, but the spec mandates a transaction for correctness by design:
|
||||
|
||||
```go
|
||||
func (h *UserHandler) updateUserRole(tx *gorm.DB, userID uint, newRole models.UserRole, currentUser uint) error {
|
||||
// Must run inside a transaction to prevent race conditions
|
||||
return tx.Transaction(func(inner *gorm.DB) error {
|
||||
// 1. Prevent self-demotion
|
||||
if userID == currentUser {
|
||||
return fmt.Errorf("cannot change your own role")
|
||||
}
|
||||
|
||||
// 2. If demoting from admin, check admin count atomically
|
||||
var user models.User
|
||||
if err := inner.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
if err := inner.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("at least one admin must exist")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update role
|
||||
if err := inner.Model(&user).Update("role", string(newRole)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
After the transaction succeeds, the handler calls `authService.InvalidateSessions(userID)` to revoke existing JWTs.
|
||||
|
||||
### 3.6.2 User Tier Permission Matrix
|
||||
|
||||
Complete access matrix for all protected route groups by role:
|
||||
|
||||
| Route Group | Admin | User | Pass-through |
|
||||
|-------------|-------|------|--------------|
|
||||
| `/auth/logout`, `/auth/refresh`, `/auth/me` | Full | Full | Full |
|
||||
| `/auth/change-password` | Full | Full | Full |
|
||||
| `/auth/accessible-hosts`, `/auth/check-host/:hostId` | Full | Full | Full |
|
||||
| `/user/profile` (GET, POST) | Full | Full | **Blocked** |
|
||||
| `/user/api-key` (POST) | Full | Full | **Blocked** |
|
||||
| `/users` (list, create, invite) | Full | **Blocked** | **Blocked** |
|
||||
| `/users/:id` (get, update, delete) | Full | **Blocked** (except self-edit of own name/email/password) | **Blocked** |
|
||||
| `/users/:id/permissions` | Full | **Blocked** | **Blocked** |
|
||||
| `/proxy/*` (all proxy host routes) | Full | **Read-only** | **Blocked** |
|
||||
| `/certificates/*` | Full | **Read-only** | **Blocked** |
|
||||
| `/dns/*` | Full | **Read-only** | **Blocked** |
|
||||
| `/uptime/*` | Full | **Read-only** | **Blocked** |
|
||||
| `/settings/*` (system, SMTP, encryption) | Full | **Blocked** | **Blocked** |
|
||||
| `/backup/*` | Full | **Blocked** | **Blocked** |
|
||||
| `/logs/*` | Full | **Read-only** | **Blocked** |
|
||||
| `/security/*` | Full | **Blocked** | **Blocked** |
|
||||
| `/features/*` | Full | **Blocked** | **Blocked** |
|
||||
| `/plugins/*` | Full | **Blocked** | **Blocked** |
|
||||
| `/system/*` | Full | **Blocked** | **Blocked** |
|
||||
|
||||
**Enforcement strategy:** Pass-through blocking is handled by `RequireManagementAccess` middleware. User-tier read-only vs blocked distinctions are enforced by existing inline `requireAdmin()` checks, which will be consolidated to use `UserRole` constants. A future PR may extract these into dedicated middleware, but for this iteration the inline checks remain.
|
||||
|
||||
#### 3.6.3 Pass-through Self-Service Scope
|
||||
|
||||
Pass-through users have the **most restricted** self-service access. They can access:
|
||||
- `/auth/logout` — end their session
|
||||
- `/auth/refresh` — refresh their JWT
|
||||
- `/auth/me` — view their own identity
|
||||
- `/auth/change-password` — change their own password
|
||||
|
||||
Pass-through users **cannot** access:
|
||||
- `/user/profile` — no profile editing (name/email changes are not applicable)
|
||||
- `/user/api-key` — no API key management
|
||||
|
||||
This means the exempt route group must further restrict `/user/*` endpoints for pass-through users. Implementation: the `/user/profile` and `/user/api-key` endpoints remain in the exempt group (accessible to admin and user roles) but add an inline check that rejects pass-through callers:
|
||||
|
||||
```go
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "pass-through users cannot access profile management"})
|
||||
return
|
||||
}
|
||||
// ... existing logic
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 Data Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐ Login ┌─────────┐ JWT Cookie ┌───────────┐
|
||||
│ Browser │──────────────▶│ Charon │──────────────────▶│ Caddy │
|
||||
│ (User) │ │ Backend │ │ Proxy │
|
||||
└──────┬──────┘ └────┬────┘ └─────┬─────┘
|
||||
│ │ │
|
||||
│ Admin/User Role │ │
|
||||
│ ───────────────▶ │ │
|
||||
│ Charon UI pages │ │
|
||||
│ │ │
|
||||
│ Pass-through Role │ forward_auth │
|
||||
│ ───────────────▶ │◀──────────────────────────────┤
|
||||
│ Landing page only │ GET /auth/verify │
|
||||
│ │ → 200 OK (access granted) │
|
||||
│ │ → 403 (access denied) │
|
||||
│ │ │
|
||||
│◀────────────────────────┤◀──────────────────────────────┤
|
||||
│ Proxied service │ Proxy pass to upstream │
|
||||
│ response │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: E2E Tests (Write-First)
|
||||
|
||||
Write Playwright tests for the CURRENT behavior so we have a regression baseline. Then update them for the new design.
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1.1 | Write E2E tests for current Account page (`/settings/account`) | `tests/account.spec.ts` |
|
||||
| 1.2 | Write E2E tests for current Users page (`/settings/account-management`) | `tests/users.spec.ts` |
|
||||
| 1.3 | Write E2E tests for invite flow (invite → accept → login) | `tests/user-invite.spec.ts` |
|
||||
| 1.4 | Update tests to match the consolidated design (new routes, new role options, pass-through behavior) | All above files |
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
|
||||
| Task | Description | Files | Dependencies |
|
||||
|------|-------------|-------|-------------|
|
||||
| 2.1 | Add `UserRole` type with constants and `IsValid()` method | `backend/internal/models/user.go` | None |
|
||||
| 2.2 | Change `User.Role` field type from `string` to `UserRole` | `backend/internal/models/user.go` | 2.1 |
|
||||
| 2.3 | Add data migration for `"viewer"` → `"passthrough"` | `backend/internal/api/routes/routes.go` (AutoMigrate block) | 2.1 |
|
||||
| 2.4 | Add `RequireManagementAccess()` middleware | `backend/internal/api/middleware/auth.go` | 2.1 |
|
||||
| 2.5 | Apply middleware to protected routes, exempt self-service + auth endpoints | `backend/internal/api/routes/routes.go` | 2.4 |
|
||||
| 2.6 | **[HIGH SEVERITY]** Update `UpdateUser()`: (a) validate role against `UserRole.IsValid()`, (b) **call `authService.InvalidateSessions(user.ID)` when the `role` field changes**, (c) validate role in `CreateUser()` and `InviteUser()` | `backend/internal/api/handlers/user_handler.go` | 2.1 |
|
||||
| 2.7 | Add self-demotion and last-admin protection to `UpdateUser()` using a **DB transaction** around the admin count check-and-update (see section 3.6.1 for reference implementation) | `backend/internal/api/handlers/user_handler.go` | 2.1 |
|
||||
| 2.8 | Update `RequireRole()` middleware — note: current signature is `func RequireRole(role string)` (single argument, not variadic). Update to accept `UserRole` type: `func RequireRole(role models.UserRole)` | `backend/internal/api/middleware/auth.go` | 2.1 |
|
||||
| 2.9 | Update all inline `role != "admin"` checks to use `UserRole` constants. **Including `CanAccessHost()` in `user.go`** which currently hardcodes `"admin"` string — update to use `models.RoleAdmin`. | `backend/internal/api/handlers/permission_helpers.go`, `user_handler.go`, `backend/internal/models/user.go` | 2.1 |
|
||||
| 2.10 | Update existing Go unit tests for role changes | `backend/internal/models/user_test.go`, handler tests | 2.1–2.9 |
|
||||
| 2.11 | **[PRIVILEGE ESCALATION GUARD]** Add field-level protection in `UpdateUser()`: when the caller is NOT an admin editing another user's record (i.e., a non-admin editing their own record via `PUT /users/:id`), the handler **must reject** any attempt to modify `role`, `enabled`, or permission fields. Only `name`, `email` (with password verification), and `password` are self-editable. | `backend/internal/api/handlers/user_handler.go` | 2.6 |
|
||||
|
||||
### Phase 3: Frontend Implementation
|
||||
|
||||
| Task | Description | Files | Dependencies |
|
||||
|------|-------------|-------|-------------|
|
||||
| 3.1 | Update `User` role type in `api/users.ts` to include `'passthrough'` | `frontend/src/api/users.ts` | None |
|
||||
| 3.2 | Merge `api/user.ts` functions into `api/users.ts` | `frontend/src/api/users.ts`, delete `frontend/src/api/user.ts` | 3.1 |
|
||||
| 3.3 | Update `AuthContextValue.ts` `User` interface role type | `frontend/src/context/AuthContextValue.ts` | None |
|
||||
| 3.4 | Create `RequireRole` component | `frontend/src/components/RequireRole.tsx` | 3.3 |
|
||||
| 3.5 | Create `PassthroughLanding` page | `frontend/src/pages/PassthroughLanding.tsx` | 3.3 |
|
||||
| 3.6 | Add `UserDetailModal` component to `UsersPage.tsx` (inline profile editing, password, API key) | `frontend/src/pages/UsersPage.tsx` | 3.1, 3.2 |
|
||||
| 3.7 | Add "My Profile" section/card to `UsersPage` | `frontend/src/pages/UsersPage.tsx` | 3.6 |
|
||||
| 3.8 | Update `InviteModal` — add `'passthrough'` to role select with descriptions | `frontend/src/pages/UsersPage.tsx` | 3.1 |
|
||||
| 3.9 | Update user table — pass-through badge, edit actions, admin row interactivity | `frontend/src/pages/UsersPage.tsx` | 3.1 |
|
||||
| 3.10 | Delete `Account.tsx` page | Delete `frontend/src/pages/Account.tsx` | 3.6, 3.7 |
|
||||
| 3.11 | Update routes in `App.tsx` — remove old paths, add redirects, add pass-through routing | `frontend/src/App.tsx` | 3.4, 3.5, 3.10 |
|
||||
| 3.12 | Update sidebar navigation in `Layout.tsx` — single "Users" entry | `frontend/src/components/Layout.tsx` | 3.11 |
|
||||
| 3.13 | Update Settings tab bar in `Settings.tsx` — replace "Account" with "Users" | `frontend/src/pages/Settings.tsx` | 3.11 |
|
||||
| 3.14 | Add role-based navigation hiding (pass-through sees minimal nav, user sees limited nav) | `frontend/src/components/Layout.tsx` | 3.3, 3.4 |
|
||||
| 3.15 | Add translation keys for pass-through role, descriptions, landing page | `frontend/src/locales/en/translation.json` (and other locale files) | None |
|
||||
| 3.16 | Update frontend unit tests (`Account.test.tsx`, `Settings.test.tsx`, `useAuth.test.tsx`) | Various `__tests__/` files | 3.10–3.14 |
|
||||
| 3.17 | **Audit all locale directories** for stale keys. Remove `navigation.adminAccount` and any other orphaned keys from ALL locales (not just English). Verify all new keys (`users.rolePassthrough`, `passthrough.*`, etc.) are added to every locale file with at least the English fallback. | All files in `frontend/src/locales/*/translation.json` | 3.15 |
|
||||
| 3.18 | **Accessibility requirements** for new components: (a) `PassthroughLanding` — semantic landmarks (`<main>`, `<h1>`), sufficient contrast, keyboard-operable logout button. (b) `UserDetailModal` — focus trap, Escape to close, `aria-modal="true"`, `role="dialog"`, `aria-labelledby` pointing to modal title heading. (c) `RequireRole` redirect must not cause focus loss — ensure redirected page receives focus on its `<h1>` or `<main>`. | `PassthroughLanding.tsx`, `UsersPage.tsx`, `RequireRole.tsx` | 3.4, 3.5, 3.6 |
|
||||
|
||||
### Phase 4: Integration & Validation
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 4.1 | Run updated E2E tests against consolidated page |
|
||||
| 4.2 | Test pass-through login flow end-to-end (login → redirect → forward auth working) |
|
||||
| 4.3 | Test role change flows (admin → user, user → passthrough, passthrough → admin) |
|
||||
| 4.4 | Test last-admin protection (cannot demote or delete the last admin) |
|
||||
| 4.5 | Test invite flow with all three roles |
|
||||
| 4.6 | Verify forward auth still works correctly for all roles |
|
||||
| 4.7 | Run full backend test suite |
|
||||
| 4.8 | Run full frontend test suite |
|
||||
| 4.9 | Generate coverage reports |
|
||||
|
||||
### Phase 5: Documentation & Cleanup
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 5.1 | Update `docs/features.md` with privilege tier documentation |
|
||||
| 5.2 | Update `CHANGELOG.md` |
|
||||
| 5.3 | Remove unused translation keys |
|
||||
| 5.4 | Review `.gitignore` and `codecov.yml` for any needed updates |
|
||||
| 5.5 | Archive this plan to `docs/plans/` with completion status |
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
### 5.1 Functional Requirements
|
||||
|
||||
| ID | Requirement (EARS) | Verification |
|
||||
|----|-------------------|-------------|
|
||||
| F1 | WHEN an admin navigates to `/settings/users`, THE SYSTEM SHALL display a table listing ALL users including the admin's own account. | E2E test |
|
||||
| F2 | WHEN an admin clicks "Edit" on any user row, THE SYSTEM SHALL open a detail modal showing name, email, role, permissions, enabled state, and (for self) password change and API key management. | E2E test |
|
||||
| F3 | WHEN an admin invites a user, THE SYSTEM SHALL offer three role options: Admin, User, and Pass-through. | E2E test |
|
||||
| F4 | WHEN a pass-through user logs in, THE SYSTEM SHALL redirect them to a minimal landing page and block access to all management routes. | E2E test |
|
||||
| F5 | WHEN a pass-through user's browser sends a request through Caddy with a valid session, THE SYSTEM SHALL evaluate `CanAccessHost()` and return 200 or 403 via the forward auth endpoint. | Integration test |
|
||||
| F6 | WHEN an admin attempts to demote themselves, THE SYSTEM SHALL reject the request with a 400 error. | Backend unit test |
|
||||
| F7 | WHEN an admin attempts to demote the last remaining admin, THE SYSTEM SHALL reject the request with a 400 error. | Backend unit test |
|
||||
| F8 | WHEN a user navigates to `/settings/account`, THE SYSTEM SHALL redirect to `/settings/users`. | E2E test |
|
||||
| F9 | WHEN a user (non-admin) is logged in, THE SYSTEM SHALL hide admin-only navigation items (user management, system settings). | E2E test |
|
||||
| F10 | THE SYSTEM SHALL provide self-service profile management (name, email, password, API key) accessible to all non-passthrough roles from the Users page. | E2E test |
|
||||
|
||||
### 5.2 Non-Functional Requirements
|
||||
|
||||
| ID | Requirement | Verification |
|
||||
|----|------------|-------------|
|
||||
| NF1 | THE SYSTEM SHALL maintain backward compatibility — existing JWTs with `role: "user"` or `role: "admin"` continue working without user action. | Manual test |
|
||||
| NF2 | THE SYSTEM SHALL require no database migration — the `UserRole` type alias does not alter the SQLite schema. | AutoMigrate verification |
|
||||
| NF3 | All new UI components SHALL meet WCAG 2.2 Level AA accessibility standards. | Lighthouse/manual audit |
|
||||
|
||||
---
|
||||
|
||||
## 6. PR Slicing Strategy
|
||||
|
||||
### 6.1 Decision: Multiple PRs
|
||||
|
||||
**Trigger reasons:**
|
||||
- Cross-domain changes (backend model + middleware + frontend pages + navigation + tests)
|
||||
- High review complexity if combined (~30+ files changed)
|
||||
- Independent validation gates per slice reduce risk
|
||||
- Backend changes can be deployed and verified before frontend changes land
|
||||
|
||||
### 6.2 PR Slices
|
||||
|
||||
#### PR-1: Backend Role System & Middleware
|
||||
|
||||
**Scope:** Introduce `UserRole` type, role validation, `RequireManagementAccess` middleware, last-admin protection, data migration.
|
||||
|
||||
**Files:**
|
||||
- `backend/internal/models/user.go` — `UserRole` type, constants, `IsValid()`
|
||||
- `backend/internal/api/middleware/auth.go` — `RequireManagementAccess()` middleware
|
||||
- `backend/internal/api/routes/routes.go` — apply middleware, data migration in AutoMigrate
|
||||
- `backend/internal/api/handlers/user_handler.go` — role validation, self-demotion protection, last-admin check
|
||||
- `backend/internal/api/handlers/permission_helpers.go` — use `UserRole` constants
|
||||
- `backend/internal/models/user_test.go` — tests for new role logic
|
||||
- Handler test files — updated for new validation
|
||||
|
||||
**Dependencies:** None (standalone backend change).
|
||||
|
||||
**Validation gate:** All Go unit tests pass. Manual verification: `POST /users/invite` with `role: "passthrough"` succeeds; pass-through user cannot access `GET /users`.
|
||||
|
||||
**Rollback:** Revert PR. No schema changes to undo.
|
||||
|
||||
#### PR-2a: Frontend Structural Changes
|
||||
|
||||
**Scope:** File merges, deletions, route restructuring, navigation consolidation. No new behavioral components.
|
||||
|
||||
**Files:**
|
||||
- `frontend/src/pages/Account.tsx` — **deleted**
|
||||
- `frontend/src/api/user.ts` — **deleted** (merged into `users.ts`)
|
||||
- `frontend/src/api/users.ts` — merged functions + updated types (add `'passthrough'` to role type)
|
||||
- `frontend/src/App.tsx` — updated routes + redirects for old paths
|
||||
- `frontend/src/components/Layout.tsx` — single "Users" nav entry, role-based nav hiding
|
||||
- `frontend/src/pages/Settings.tsx` — replace "Account" tab with "Users"
|
||||
- `frontend/src/context/AuthContextValue.ts` — updated User role type
|
||||
- `frontend/src/locales/*/translation.json` — key renames, removals, additions across ALL locales
|
||||
- Frontend unit test files — updated for removed components
|
||||
|
||||
**Dependencies:** PR-1 must be merged first (backend must accept `"passthrough"` role).
|
||||
|
||||
**Validation gate:** Frontend unit tests pass. Old routes redirect correctly. Navigation shows single "Users" entry. No regressions in existing functionality.
|
||||
|
||||
**Rollback:** Revert PR. Old routes and Account page restored.
|
||||
|
||||
#### PR-2b: Frontend Behavioral Changes
|
||||
|
||||
**Scope:** New components and behavioral features — detail modal, profile section, pass-through landing, RequireRole guard.
|
||||
|
||||
**Files:**
|
||||
- `frontend/src/components/RequireRole.tsx` — **new** (with role-aware redirect logic)
|
||||
- `frontend/src/pages/PassthroughLanding.tsx` — **new** (routed at `/passthrough`)
|
||||
- `frontend/src/pages/UsersPage.tsx` — add `UserDetailModal`, "My Profile" section, pass-through badge, role selector with descriptions
|
||||
- `frontend/src/App.tsx` — add `/passthrough` route, wrap admin routes with `RequireRole`
|
||||
- Frontend unit test files — tests for new components
|
||||
|
||||
**Dependencies:** PR-2a must be merged first.
|
||||
|
||||
**Validation gate:** Frontend unit tests pass. Pass-through user redirected to `/passthrough` (not `/`). Admin-only routes blocked for non-admin roles. Detail modal opens/closes correctly with focus trap.
|
||||
|
||||
**Rollback:** Revert PR. Structural changes from PR-2a remain intact.
|
||||
|
||||
#### PR-3: E2E Tests
|
||||
|
||||
**Scope:** Playwright E2E tests for the consolidated user management flow.
|
||||
|
||||
**Files:**
|
||||
- `tests/users.spec.ts` — **new**: user list, invite flow, role assignment, permissions, inline editing
|
||||
- `tests/user-invite.spec.ts` — **new**: invite → accept → login flow for all roles
|
||||
- `tests/passthrough.spec.ts` — **new**: pass-through login → landing → no management access
|
||||
|
||||
**Dependencies:** PR-1, PR-2a, and PR-2b must be merged.
|
||||
|
||||
**Validation gate:** All E2E tests pass on Firefox, Chromium, and WebKit.
|
||||
|
||||
**Rollback:** Revert PR (test-only, no production impact).
|
||||
|
||||
### 6.3 Contingency Notes
|
||||
|
||||
- If PR-1 causes unexpected JWT issues in production, the `UserRole` type is a string alias — reverting just means removing the type and going back to raw strings. No data loss.
|
||||
- PR-2 is **committed to a split** into PR-2a and PR-2b (see section 6.2 for details).
|
||||
- The top-level `/users` route currently duplicates `/settings/account-management`. The redirect in PR-2a handles this, but if external tools link to `/users`, the redirect ensures continuity.
|
||||
|
||||
---
|
||||
|
||||
## 7. Complexity Estimates
|
||||
|
||||
| Component | Estimate | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| PR-1: Backend role system | **Medium** | New type + middleware + validation + transaction-based protection + session invalidation. Well-scoped. ~10 files. |
|
||||
| PR-2a: Frontend structural | **Medium** | File merges/deletes, route restructuring, nav consolidation. ~10 files. |
|
||||
| PR-2b: Frontend behavioral | **Medium** | New components (RequireRole, PassthroughLanding, UserDetailModal), role-aware routing. ~8 files. |
|
||||
| PR-3: E2E tests | **Medium** | 3 new test files, requires running Docker environment. |
|
||||
| **Total** | **Large** | Touches backend models, middleware, handlers, frontend pages, components, navigation, API client, translations, and tests. Split across 4 PRs for safer review. |
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user