This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/audit.go
2026-02-28 01:40:31 +00:00

220 lines
6.3 KiB
Go

package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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)
}
}
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua)
} else {
_, _ = 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)
}
}
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO audit_log (id, action, resource_type, resource_id, metadata)
VALUES (?, ?, ?, ?, ?)
`, uuid.New().String(), action, resourceType, resourceID, metaJSON)
} else {
_, _ = 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)
}
}
if database.IsSQLite() {
_, _ = database.DB.Exec(`
INSERT INTO audit_log (id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), actorID, action, resourceType, resourceID, metaJSON, ip, ua)
} else {
_, _ = 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 with ? placeholders, convert for Postgres later
where := "WHERE 1=1"
args := []interface{}{}
if action := c.Query("action"); action != "" {
where += " AND al.action = ?"
args = append(args, action)
}
if actorID := c.Query("actor_id"); actorID != "" {
where += " AND al.actor_id = ?"
args = append(args, actorID)
}
if rt := c.Query("resource_type"); rt != "" {
where += " AND al.resource_type = ?"
args = append(args, rt)
}
// Count
var total int
countArgs := make([]interface{}, len(args))
copy(countArgs, args)
countQ := convertPlaceholders(`SELECT COUNT(*) FROM audit_log al ` + where)
err := database.DB.QueryRow(countQ, countArgs...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
return
}
// Query — metadata column needs ::text on Postgres only
metadataCol := "COALESCE(al.metadata::text, '{}')"
if database.IsSQLite() {
metadataCol = "COALESCE(al.metadata, '{}')"
}
query := fmt.Sprintf(`
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
al.action, al.resource_type, al.resource_id,
%s, al.ip_address, al.created_at
FROM audit_log al
LEFT JOIN users u ON al.actor_id = u.id
%s
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?`, metadataCol, where)
args = append(args, perPage, offset)
query = convertPlaceholders(query)
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})
}