Changeset 0.8.2 (#45)
This commit is contained in:
37
server/database/migrations/017_audit_log.sql
Normal file
37
server/database/migrations/017_audit_log.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- ==========================================
|
||||
-- Migration 017: Audit Log
|
||||
-- ==========================================
|
||||
-- Immutable append-only log of all mutating actions.
|
||||
-- Required for enterprise compliance (SOC2, FedRAMP, HIPAA).
|
||||
-- ==========================================
|
||||
|
||||
-- Drop stale table if left from a prior partial run
|
||||
DROP TABLE IF EXISTS audit_log CASCADE;
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(100) NOT NULL, -- e.g. 'user.create', 'team.add_member'
|
||||
resource_type VARCHAR(50) NOT NULL, -- e.g. 'user', 'team', 'preset', 'channel'
|
||||
resource_id VARCHAR(255), -- UUID or identifier of affected resource
|
||||
metadata JSONB DEFAULT '{}'::jsonb, -- action-specific details
|
||||
ip_address VARCHAR(45), -- IPv4 or IPv6
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Time-range queries (admin viewer, compliance exports)
|
||||
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
|
||||
|
||||
-- Filter by actor
|
||||
CREATE INDEX idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
|
||||
|
||||
-- Filter by resource
|
||||
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
|
||||
|
||||
-- Filter by action
|
||||
CREATE INDEX idx_audit_log_action ON audit_log(action);
|
||||
|
||||
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
|
||||
COMMENT ON COLUMN audit_log.action IS 'Dotted action name: resource.verb (e.g. user.create, team.add_member)';
|
||||
COMMENT ON COLUMN audit_log.metadata IS 'Action-specific context: old/new values, affected fields, etc.';
|
||||
@@ -189,6 +189,9 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
AuditLog(c, "user.create", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "email": req.Email, "role": req.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Reset Password (admin) ──────────────────
|
||||
@@ -258,6 +261,7 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "role updated", "role": req.Role})
|
||||
AuditLog(c, "user.role_change", "user", targetID, map[string]interface{}{"role": req.Role})
|
||||
}
|
||||
|
||||
// ── Toggle User Active ──────────────────────
|
||||
@@ -316,6 +320,13 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
|
||||
"is_active": req.IsActive,
|
||||
"teams_assigned": teamsAssigned,
|
||||
})
|
||||
action := "user.activate"
|
||||
if !req.IsActive {
|
||||
action = "user.deactivate"
|
||||
}
|
||||
AuditLog(c, action, "user", targetID, map[string]interface{}{
|
||||
"is_active": req.IsActive, "teams_assigned": teamsAssigned,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Delete User ─────────────────────────────
|
||||
@@ -341,6 +352,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||
AuditLog(c, "user.delete", "user", targetID, nil)
|
||||
}
|
||||
|
||||
// ── Public Settings (for any authenticated user) ──
|
||||
|
||||
192
server/handlers/audit.go
Normal file
192
server/handlers/audit.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// AuditLog inserts an audit entry. Called from mutating handlers.
|
||||
// Non-blocking: errors are logged but don't break the caller.
|
||||
func AuditLog(c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) {
|
||||
if database.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
actorID := getUserID(c)
|
||||
if actorID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
|
||||
metaJSON := "{}"
|
||||
if metadata != nil {
|
||||
if b, err := json.Marshal(metadata); err == nil {
|
||||
metaJSON = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
}
|
||||
|
||||
// AuditLogAnon inserts an audit entry without a gin context (e.g. system actions).
|
||||
func AuditLogAnon(action, resourceType, resourceID string, metadata map[string]interface{}) {
|
||||
if database.DB == nil {
|
||||
return
|
||||
}
|
||||
metaJSON := "{}"
|
||||
if metadata != nil {
|
||||
if b, err := json.Marshal(metadata); err == nil {
|
||||
metaJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (action, resource_type, resource_id, metadata)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`, action, resourceType, resourceID, metaJSON)
|
||||
}
|
||||
|
||||
// AuditLogWithActor inserts an audit entry with an explicit actor ID (e.g. pre-auth flows).
|
||||
func AuditLogWithActor(actorID string, c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) {
|
||||
if database.DB == nil || actorID == "" {
|
||||
return
|
||||
}
|
||||
ip := c.ClientIP()
|
||||
ua := c.GetHeader("User-Agent")
|
||||
metaJSON := "{}"
|
||||
if metadata != nil {
|
||||
if b, err := json.Marshal(metadata); err == nil {
|
||||
metaJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
`, actorID, action, resourceType, resourceID, metaJSON, ip, ua)
|
||||
}
|
||||
|
||||
// ── Admin Audit Viewer ──────────────────────
|
||||
|
||||
type auditEntry struct {
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
Metadata string `json:"metadata"`
|
||||
IPAddress *string `json:"ip_address"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAuditLog returns paginated audit entries with optional filters.
|
||||
// GET /api/v1/admin/audit?page=1&per_page=50&action=user.create&actor_id=...&resource_type=...
|
||||
func (h *AdminHandler) ListAuditLog(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// Build filter clauses
|
||||
where := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
if action := c.Query("action"); action != "" {
|
||||
where += " AND al.action = $" + strconv.Itoa(argN)
|
||||
args = append(args, action)
|
||||
argN++
|
||||
}
|
||||
if actorID := c.Query("actor_id"); actorID != "" {
|
||||
where += " AND al.actor_id = $" + strconv.Itoa(argN)
|
||||
args = append(args, actorID)
|
||||
argN++
|
||||
}
|
||||
if rt := c.Query("resource_type"); rt != "" {
|
||||
where += " AND al.resource_type = $" + strconv.Itoa(argN)
|
||||
args = append(args, rt)
|
||||
argN++
|
||||
}
|
||||
|
||||
// Count
|
||||
var total int
|
||||
countArgs := make([]interface{}, len(args))
|
||||
copy(countArgs, args)
|
||||
err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query
|
||||
query := `
|
||||
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||
al.action, al.resource_type, al.resource_id,
|
||||
COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.actor_id = u.id
|
||||
` + where + `
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]auditEntry, 0)
|
||||
for rows.Next() {
|
||||
var e auditEntry
|
||||
var actorName sql.NullString
|
||||
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
||||
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if actorName.Valid {
|
||||
e.ActorName = &actorName.String
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// ListAuditActions returns distinct action names for filter dropdowns.
|
||||
// GET /api/v1/admin/audit/actions
|
||||
func (h *AdminHandler) ListAuditActions(c *gin.Context) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT DISTINCT action FROM audit_log ORDER BY action ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var actions []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if rows.Scan(&a) == nil {
|
||||
actions = append(actions, a)
|
||||
}
|
||||
}
|
||||
if actions == nil {
|
||||
actions = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
||||
}
|
||||
@@ -153,6 +153,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
"message": "Account created and pending admin approval",
|
||||
"pending": true,
|
||||
})
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -164,6 +167,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": false,
|
||||
})
|
||||
}
|
||||
|
||||
// IsRegistrationEnabled checks the global_settings table.
|
||||
@@ -307,6 +313,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
|
||||
}
|
||||
|
||||
// ── Refresh ─────────────────────────────────
|
||||
|
||||
@@ -373,6 +373,9 @@ func (h *PresetHandler) CreateAdminPreset(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
|
||||
"name": req.Name, "scope": "global", "base_model": req.BaseModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAdminPreset updates any preset (admin can edit global and personal).
|
||||
@@ -613,6 +616,9 @@ func (h *PresetHandler) CreateTeamPreset(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
|
||||
"name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeamPreset deletes a team-scoped preset.
|
||||
@@ -634,4 +640,7 @@ func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{
|
||||
"scope": "team", "team_id": teamID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name})
|
||||
AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name})
|
||||
}
|
||||
|
||||
// ── Admin: Get Team ─────────────────────────
|
||||
@@ -246,6 +247,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "team.update", "team", teamID, nil)
|
||||
}
|
||||
|
||||
// ── Admin: Delete Team ──────────────────────
|
||||
@@ -264,6 +266,7 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "team.delete", "team", teamID, nil)
|
||||
}
|
||||
|
||||
// ── Members: List ───────────────────────────
|
||||
@@ -351,6 +354,9 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
AuditLog(c, "team.add_member", "team", getTeamID(c), map[string]interface{}{
|
||||
"user_id": req.UserID, "role": req.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Members: Update Role ────────────────────
|
||||
@@ -377,6 +383,9 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "team.update_member", "team", getTeamID(c), map[string]interface{}{
|
||||
"member_id": memberID, "role": req.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Members: Remove ─────────────────────────
|
||||
@@ -395,6 +404,9 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "team.remove_member", "team", getTeamID(c), map[string]interface{}{
|
||||
"member_id": memberID,
|
||||
})
|
||||
}
|
||||
|
||||
// ── User: My Teams ──────────────────────────
|
||||
|
||||
@@ -232,6 +232,10 @@ func main() {
|
||||
// Teams (admin)
|
||||
teamAdm := handlers.NewTeamHandler()
|
||||
admin.GET("/teams", teamAdm.ListTeams)
|
||||
|
||||
// Audit log
|
||||
admin.GET("/audit", adm.ListAuditLog)
|
||||
admin.GET("/audit/actions", adm.ListAuditActions)
|
||||
admin.POST("/teams", teamAdm.CreateTeam)
|
||||
admin.GET("/teams/:id", teamAdm.GetTeam)
|
||||
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
|
||||
|
||||
Reference in New Issue
Block a user