feat(security): implement decision and ruleset management with logging and retrieval
This commit is contained in:
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -145,6 +146,82 @@ func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// ListDecisions returns recent security decisions
|
||||
func (h *SecurityHandler) ListDecisions(c *gin.Context) {
|
||||
limit := 50
|
||||
if q := c.Query("limit"); q != "" {
|
||||
if v, err := strconv.Atoi(q); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
list, err := h.svc.ListDecisions(limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"decisions": list})
|
||||
}
|
||||
|
||||
// CreateDecision creates a manual decision (override) - for now no checks besides payload
|
||||
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
|
||||
var payload models.SecurityDecision
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if payload.IP == "" || payload.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
|
||||
return
|
||||
}
|
||||
// Populate source
|
||||
payload.Source = "manual"
|
||||
if err := h.svc.LogDecision(&payload); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
|
||||
return
|
||||
}
|
||||
// Record an audit entry
|
||||
actor := c.GetString("user_id")
|
||||
if actor == "" {
|
||||
actor = c.ClientIP()
|
||||
}
|
||||
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
|
||||
c.JSON(http.StatusOK, gin.H{"decision": payload})
|
||||
}
|
||||
|
||||
// ListRuleSets returns the list of known rulesets
|
||||
func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
|
||||
list, err := h.svc.ListRuleSets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rulesets": list})
|
||||
}
|
||||
|
||||
// UpsertRuleSet uploads or updates a ruleset
|
||||
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
var payload models.SecurityRuleSet
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if payload.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpsertRuleSet(&payload); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
|
||||
return
|
||||
}
|
||||
// Create an audit event
|
||||
actor := c.GetString("user_id")
|
||||
if actor == "" {
|
||||
actor = c.ClientIP()
|
||||
}
|
||||
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
|
||||
c.JSON(http.StatusOK, gin.H{"ruleset": payload})
|
||||
}
|
||||
|
||||
// Enable toggles Cerberus on, validating admin whitelist or break-glass token
|
||||
func (h *SecurityHandler) Enable(c *gin.Context) {
|
||||
// Look for requester's IP and optional breakglass token
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db)
|
||||
api.POST("/security/decisions", h.CreateDecision)
|
||||
api.GET("/security/decisions", h.ListDecisions)
|
||||
api.POST("/security/rulesets", h.UpsertRuleSet)
|
||||
api.GET("/security/rulesets", h.ListRuleSets)
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
|
||||
r, _ := setupSecurityTestRouterWithExtras(t)
|
||||
|
||||
payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var decisionResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp))
|
||||
require.NotNil(t, decisionResp["decision"])
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
var listResp map[string][]map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp))
|
||||
require.GreaterOrEqual(t, len(listResp["decisions"]), 1)
|
||||
|
||||
// Now test ruleset upsert
|
||||
rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
var rsResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp))
|
||||
require.NotNil(t, rsResp["ruleset"])
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
var listRsResp map[string][]map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp))
|
||||
require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1)
|
||||
}
|
||||
@@ -39,7 +39,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
&models.UptimeHost{},
|
||||
&models.UptimeNotificationEvent{},
|
||||
&models.Domain{},
|
||||
&models.SecurityConfig{},
|
||||
&models.SecurityConfig{},
|
||||
&models.SecurityDecision{},
|
||||
&models.SecurityAudit{},
|
||||
&models.SecurityRuleSet{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
@@ -214,6 +217,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
protected.POST("/security/enable", securityHandler.Enable)
|
||||
protected.POST("/security/disable", securityHandler.Disable)
|
||||
protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass)
|
||||
protected.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
protected.POST("/security/decisions", securityHandler.CreateDecision)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.POST("/security/rulesets", securityHandler.UpsertRuleSet)
|
||||
|
||||
// CrowdSec process management and import
|
||||
// Data dir for crowdsec (persisted on host via volumes)
|
||||
|
||||
15
backend/internal/models/security_audit.go
Normal file
15
backend/internal/models/security_audit.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityAudit records admin actions or important changes related to security.
|
||||
type SecurityAudit struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -13,10 +13,15 @@ type SecurityConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs
|
||||
BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"`
|
||||
CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block"
|
||||
CrowdSecMode string `json:"crowdsec_mode"` // "disabled", "monitor", "block"; also supports "local"/"external"
|
||||
CrowdSecAPIURL string `json:"crowdsec_api_url" gorm:"type:text"`
|
||||
WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block"
|
||||
WAFRulesSource string `json:"waf_rules_source" gorm:"type:text"` // URL or name of ruleset
|
||||
WAFLearning bool `json:"waf_learning"`
|
||||
RateLimitEnable bool `json:"rate_limit_enable"`
|
||||
RateLimitBurst int `json:"rate_limit_burst"`
|
||||
RateLimitRequests int `json:"rate_limit_requests"`
|
||||
RateLimitWindowSec int `json:"rate_limit_window_sec"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
19
backend/internal/models/security_decision.go
Normal file
19
backend/internal/models/security_decision.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityDecision stores a decision/action taken by CrowdSec/WAF/RateLimit or manual
|
||||
// override so it can be audited and surfaced in the UI.
|
||||
type SecurityDecision struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Source string `json:"source"` // e.g., crowdsec, waf, ratelimit, manual
|
||||
Action string `json:"action"` // allow, block, challenge
|
||||
IP string `json:"ip"`
|
||||
Host string `json:"host"` // optional
|
||||
RuleID string `json:"rule_id"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
16
backend/internal/models/security_ruleset.go
Normal file
16
backend/internal/models/security_ruleset.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityRuleSet stores metadata about WAF/CrowdSec rule sets that the server can download and apply.
|
||||
type SecurityRuleSet struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
SourceURL string `json:"source_url" gorm:"type:text"`
|
||||
Mode string `json:"mode"` // optional e.g., 'owasp', 'custom'
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
@@ -134,6 +136,82 @@ func (s *SecurityService) VerifyBreakGlassToken(name, token string) (bool, error
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// LogDecision stores a security decision record
|
||||
func (s *SecurityService) LogDecision(d *models.SecurityDecision) error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.UUID == "" {
|
||||
d.UUID = uuid.NewString()
|
||||
}
|
||||
if d.CreatedAt.IsZero() {
|
||||
d.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(d).Error
|
||||
}
|
||||
|
||||
// ListDecisions returns recent security decisions, ordered by created_at desc
|
||||
func (s *SecurityService) ListDecisions(limit int) ([]models.SecurityDecision, error) {
|
||||
var res []models.SecurityDecision
|
||||
q := s.db.Order("created_at desc")
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
if err := q.Find(&res).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LogAudit stores an audit entry
|
||||
func (s *SecurityService) LogAudit(a *models.SecurityAudit) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
if a.UUID == "" {
|
||||
a.UUID = uuid.NewString()
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(a).Error
|
||||
}
|
||||
|
||||
// UpsertRuleSet saves or updates a ruleset content
|
||||
func (s *SecurityService) UpsertRuleSet(r *models.SecurityRuleSet) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
var existing models.SecurityRuleSet
|
||||
if err := s.db.Where("name = ?", r.Name).First(&existing).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if r.UUID == "" {
|
||||
r.UUID = uuid.NewString()
|
||||
}
|
||||
if r.LastUpdated.IsZero() {
|
||||
r.LastUpdated = time.Now()
|
||||
}
|
||||
return s.db.Create(r).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
existing.SourceURL = r.SourceURL
|
||||
existing.Content = r.Content
|
||||
existing.Mode = r.Mode
|
||||
existing.LastUpdated = r.LastUpdated
|
||||
return s.db.Save(&existing).Error
|
||||
}
|
||||
|
||||
|
||||
// ListRuleSets returns all known rulesets
|
||||
func (s *SecurityService) ListRuleSets() ([]models.SecurityRuleSet, error) {
|
||||
var res []models.SecurityRuleSet
|
||||
if err := s.db.Find(&res).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// helper: reused from access_list_service validation for CIDR/IP parsing
|
||||
func isValidCIDR(cidr string) bool {
|
||||
// Try parsing as single IP
|
||||
|
||||
@@ -14,7 +14,7 @@ func setupSecurityTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.AutoMigrate(&models.SecurityConfig{})
|
||||
err = db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
return db
|
||||
@@ -64,3 +64,31 @@ func TestSecurityService_BreakGlassTokenLifecycle(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSecurityService_LogDecisionAndList(t *testing.T) {
|
||||
db := setupSecurityTestDB(t)
|
||||
svc := NewSecurityService(db)
|
||||
|
||||
dec := &models.SecurityDecision{Source: "manual", Action: "block", IP: "1.2.3.4", Host: "example.com", RuleID: "manual-1", Details: "test manual block"}
|
||||
err := svc.LogDecision(dec)
|
||||
assert.NoError(t, err)
|
||||
|
||||
list, err := svc.ListDecisions(10)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(list), 1)
|
||||
assert.Equal(t, "manual", list[0].Source)
|
||||
}
|
||||
|
||||
func TestSecurityService_UpsertRuleSet(t *testing.T) {
|
||||
db := setupSecurityTestDB(t)
|
||||
svc := NewSecurityService(db)
|
||||
|
||||
rs := &models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "https://example.com/owasp.rules", Mode: "owasp", Content: "rule: 1"}
|
||||
err := svc.UpsertRuleSet(rs)
|
||||
assert.NoError(t, err)
|
||||
|
||||
list, err := svc.ListRuleSets()
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(list), 1)
|
||||
assert.Equal(t, "owasp-crs", list[0].Name)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,14 @@ export interface SecurityConfigPayload {
|
||||
enabled?: boolean
|
||||
admin_whitelist?: string
|
||||
crowdsec_mode?: string
|
||||
crowdsec_api_url?: string
|
||||
waf_mode?: string
|
||||
waf_rules_source?: string
|
||||
waf_learning?: boolean
|
||||
rate_limit_enable?: boolean
|
||||
rate_limit_burst?: number
|
||||
rate_limit_requests?: number
|
||||
rate_limit_window_sec?: number
|
||||
}
|
||||
|
||||
export const getSecurityConfig = async () => {
|
||||
@@ -59,3 +64,23 @@ export const disableCerberus = async (payload?: any) => {
|
||||
const response = await client.post('/security/disable', payload || {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getDecisions = async (limit = 50) => {
|
||||
const response = await client.get(`/security/decisions?limit=${limit}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createDecision = async (payload: any) => {
|
||||
const response = await client.post('/security/decisions', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getRuleSets = async () => {
|
||||
const response = await client.get('/security/rulesets')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const upsertRuleSet = async (payload: any) => {
|
||||
const response = await client.post('/security/rulesets', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus } from '../api/security'
|
||||
import { getSecurityStatus, getSecurityConfig, updateSecurityConfig, generateBreakGlassToken, enableCerberus, disableCerberus, getDecisions, createDecision, getRuleSets, upsertRuleSet } from '../api/security'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export function useSecurityStatus() {
|
||||
@@ -29,6 +29,30 @@ export function useGenerateBreakGlassToken() {
|
||||
return useMutation({ mutationFn: () => generateBreakGlassToken() })
|
||||
}
|
||||
|
||||
export function useDecisions(limit = 50) {
|
||||
return useQuery({ queryKey: ['securityDecisions', limit], queryFn: () => getDecisions(limit) })
|
||||
}
|
||||
|
||||
export function useCreateDecision() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: any) => createDecision(payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['securityDecisions'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRuleSets() {
|
||||
return useQuery({ queryKey: ['securityRulesets'], queryFn: () => getRuleSets() })
|
||||
}
|
||||
|
||||
export function useUpsertRuleSet() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: any) => upsertRuleSet(payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['securityRulesets'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useEnableCerberus() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user