Changeset 0.8.2 (#45)
This commit is contained in:
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})
|
||||
}
|
||||
Reference in New Issue
Block a user