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 }