277 lines
6.2 KiB
Go

package main
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
auditResultSuccess = "success"
auditResultFailed = "failed"
)
var sensitiveAuditMetadataTokens = []string{
"password",
"secret",
"token",
"credential",
"authorization",
"apikey",
"api_key",
"accesskey",
"access_key",
"secretkey",
"secret_key",
"email",
"userid",
"user_id",
"score",
"grade",
"progress",
}
func (a *app) auditAdminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if !hasRole(claims, "admin") {
writeError(w, http.StatusForbidden, "insufficient role for audit access")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listAdminAuditEvents(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
filter, limit, err := buildAuditQuery(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
cursor, err := a.db.Collection("audit_events").Find(
ctx,
filter,
NewFindOptions().SetSort(Sort{{Key: "timestamp", Value: -1}, {Key: "createdAt", Value: -1}}).SetLimit(int64(limit)),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list audit events")
return
}
defer cursor.Close(ctx)
var docs []Doc
if err := cursor.All(ctx, &docs); err != nil {
writeError(w, http.StatusInternalServerError, "failed to list audit events")
return
}
if docs == nil {
docs = []Doc{}
}
for idx := range docs {
normalizeDocument(docs[idx])
docs[idx] = redactAuditEventDocument(docs[idx])
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) getAdminAuditEvent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if strings.TrimSpace(id) == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
doc, err := a.getByID("audit_events", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load audit event")
return
}
writeJSON(w, http.StatusOK, redactAuditEventDocument(doc))
}
func buildAuditQuery(r *http.Request) (Doc, int, error) {
query := r.URL.Query()
filter := Doc{}
if action := strings.TrimSpace(query.Get("action")); action != "" {
filter["action"] = action
}
if resource := strings.TrimSpace(query.Get("resource")); resource != "" {
filter["resource"] = resource
}
if result := strings.TrimSpace(query.Get("result")); result != "" {
filter["result"] = result
}
if actor := strings.TrimSpace(query.Get("actor")); actor != "" {
filter["actor"] = actor
}
timeFilter := Doc{}
if sinceRaw := strings.TrimSpace(query.Get("since")); sinceRaw != "" {
since, err := parseAuditTime(sinceRaw)
if err != nil {
return nil, 0, errors.New("since must be RFC3339 or unix milliseconds")
}
timeFilter["$gte"] = since
}
if untilRaw := strings.TrimSpace(query.Get("until")); untilRaw != "" {
until, err := parseAuditTime(untilRaw)
if err != nil {
return nil, 0, errors.New("until must be RFC3339 or unix milliseconds")
}
timeFilter["$lte"] = until
}
if len(timeFilter) > 0 {
filter["timestamp"] = timeFilter
}
limit := 50
if limitRaw := strings.TrimSpace(query.Get("limit")); limitRaw != "" {
parsed, err := strconv.Atoi(limitRaw)
if err != nil || parsed <= 0 {
return nil, 0, errors.New("limit must be a positive integer")
}
if parsed > 200 {
parsed = 200
}
limit = parsed
}
return filter, limit, nil
}
func parseAuditTime(raw string) (time.Time, error) {
if millis, err := strconv.ParseInt(raw, 10, 64); err == nil {
return time.UnixMilli(millis).UTC(), nil
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, err
}
return parsed.UTC(), nil
}
func (a *app) recordAuditEvent(ctx context.Context, claims *accessClaims, action, resource, result string, metadata map[string]any) {
if a.db == nil {
return
}
action = strings.TrimSpace(action)
resource = strings.TrimSpace(resource)
result = strings.TrimSpace(result)
if action == "" || resource == "" || result == "" {
return
}
if metadata == nil {
metadata = map[string]any{}
}
doc := Doc{
"actor": resolveAuditActor(claims),
"action": action,
"resource": resource,
"result": result,
"timestamp": time.Now().UTC(),
"metadata": Doc(metadata),
}
_, _ = a.create("audit_events", doc)
}
func resolveAuditActor(claims *accessClaims) string {
if claims == nil {
return "system"
}
if value := strings.TrimSpace(claims.PreferredUsername); value != "" {
return value
}
if value := strings.TrimSpace(claims.Email); value != "" {
return value
}
if value := strings.TrimSpace(claims.Sub); value != "" {
return value
}
return "system"
}
func redactAuditEventDocument(doc Doc) Doc {
copy := Doc{}
for key, value := range doc {
if key == "metadata" {
copy[key] = redactAuditMetadata("metadata", value)
continue
}
copy[key] = value
}
return copy
}
func redactAuditMetadata(path string, raw any) any {
switch typed := raw.(type) {
case Doc:
out := Doc{}
for key, value := range typed {
nextPath := path + "." + key
if isSensitiveAuditKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactAuditMetadata(nextPath, value)
}
return out
case map[string]any:
out := map[string]any{}
for key, value := range typed {
nextPath := path + "." + key
if isSensitiveAuditKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactAuditMetadata(nextPath, value)
}
return out
case []any:
out := make([]any, len(typed))
for idx, value := range typed {
out[idx] = redactAuditMetadata(path, value)
}
return out
default:
return raw
}
}
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
for _, token := range sensitiveAuditMetadataTokens {
if strings.Contains(normalized, token) {
return true
}
}
return false
}