277 lines
6.2 KiB
Go
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
|
|
}
|