package handlers import ( "errors" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/pkg/api" ) // AuditHandler handles audit log endpoints. type AuditHandler struct { auditLogger port.AuditLogger } // NewAuditHandler creates a new audit handler. func NewAuditHandler(auditLogger port.AuditLogger) *AuditHandler { return &AuditHandler{auditLogger: auditLogger} } // Mount registers the audit routes. func (h *AuditHandler) Mount(r api.Router) { r.Route("/audit-log", func(r chi.Router) { // All audit endpoints require authentication with audit:read scope r.With(auth.RequireScope(auth.ScopeAuditRead, auth.ScopeAdmin)).Get("/", h.List) r.With(auth.RequireScope(auth.ScopeAuditRead, auth.ScopeAdmin)).Get("/{command_id}", h.Get) }) } // AuditLogResponse is the JSON response for an audit log entry. type AuditLogResponse struct { ID string `json:"id"` APIKeyID string `json:"api_key_id"` CommandID string `json:"command_id"` ProjectID string `json:"project_id"` CommandType string `json:"command_type"` Args string `json:"args,omitempty"` ClientIP string `json:"client_ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` StartedAt string `json:"started_at"` CompletedAt *string `json:"completed_at,omitempty"` ExitCode *int `json:"exit_code,omitempty"` DurationMs *int64 `json:"duration_ms,omitempty"` Status string `json:"status"` ErrorMessage string `json:"error_message,omitempty"` OutputSizeBytes int64 `json:"output_size_bytes"` CreatedAt string `json:"created_at"` } // auditLogToResponse converts an AuditLogEntry to a JSON response. func auditLogToResponse(entry *domain.AuditLogEntry) AuditLogResponse { resp := AuditLogResponse{ ID: entry.ID, APIKeyID: entry.APIKeyID, CommandID: entry.CommandID, ProjectID: entry.ProjectID, CommandType: string(entry.CommandType), Args: entry.Args, ClientIP: entry.ClientIP, UserAgent: entry.UserAgent, StartedAt: entry.StartedAt.Format(time.RFC3339), Status: string(entry.Status), ErrorMessage: entry.ErrorMessage, OutputSizeBytes: entry.OutputSizeBytes, CreatedAt: entry.CreatedAt.Format(time.RFC3339), } if entry.CompletedAt != nil { s := entry.CompletedAt.Format(time.RFC3339) resp.CompletedAt = &s } if entry.ExitCode != nil { resp.ExitCode = entry.ExitCode } if entry.DurationMs != nil { resp.DurationMs = entry.DurationMs } return resp } // ListAuditLogResponse is the JSON response for listing audit logs. type ListAuditLogResponse struct { Entries []AuditLogResponse `json:"entries"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // List returns audit log entries with optional filters. // GET /audit-log // Query parameters: // - project: filter by project ID // - api_key: filter by API key ID // - command_type: filter by command type (claude, shell, git) // - status: filter by status (running, success, error, cancelled) // - start: filter by start time (RFC3339 format) // - end: filter by end time (RFC3339 format) // - limit: maximum number of entries (default 100, max 1000) // - offset: number of entries to skip (for pagination) func (h *AuditHandler) List(w http.ResponseWriter, r *http.Request) { filters := domain.DefaultAuditFilters() // Parse project filter if project := r.URL.Query().Get("project"); project != "" { filters.ProjectID = project } // Parse api_key filter if apiKey := r.URL.Query().Get("api_key"); apiKey != "" { filters.APIKeyID = apiKey } // Parse command_type filter if cmdType := r.URL.Query().Get("command_type"); cmdType != "" { ct := domain.CommandType(cmdType) switch ct { case domain.CommandTypeClaude, domain.CommandTypeShell, domain.CommandTypeGit: filters.CommandType = ct default: api.WriteBadRequest(w, r, "invalid command_type: must be claude, shell, or git") return } } // Parse status filter if status := r.URL.Query().Get("status"); status != "" { s := domain.AuditStatus(status) if !s.IsValid() { api.WriteBadRequest(w, r, "invalid status: must be running, success, error, or cancelled") return } filters.Status = s } // Parse start time filter if startStr := r.URL.Query().Get("start"); startStr != "" { start, err := time.Parse(time.RFC3339, startStr) if err != nil { api.WriteBadRequest(w, r, "invalid start time: must be RFC3339 format") return } filters.StartTime = &start } // Parse end time filter if endStr := r.URL.Query().Get("end"); endStr != "" { end, err := time.Parse(time.RFC3339, endStr) if err != nil { api.WriteBadRequest(w, r, "invalid end time: must be RFC3339 format") return } filters.EndTime = &end } // Parse limit if limitStr := r.URL.Query().Get("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 1 { api.WriteBadRequest(w, r, "invalid limit: must be a positive integer") return } if limit > 1000 { limit = 1000 // Cap at 1000 } filters.Limit = limit } // Parse offset if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { offset, err := strconv.Atoi(offsetStr) if err != nil || offset < 0 { api.WriteBadRequest(w, r, "invalid offset: must be a non-negative integer") return } filters.Offset = offset } entries, err := h.auditLogger.List(r.Context(), filters) if err != nil { api.WriteInternalError(w, r, "Failed to list audit logs") return } resp := make([]AuditLogResponse, len(entries)) for i, entry := range entries { resp[i] = auditLogToResponse(&entry) } api.WriteSuccess(w, r, ListAuditLogResponse{ Entries: resp, Total: len(resp), Limit: filters.Limit, Offset: filters.Offset, }) } // Get returns a single audit log entry by command ID. // GET /audit-log/{command_id} func (h *AuditHandler) Get(w http.ResponseWriter, r *http.Request) { commandID := chi.URLParam(r, "command_id") if commandID == "" { api.WriteBadRequest(w, r, "command_id is required") return } entry, err := h.auditLogger.Get(r.Context(), commandID) if err != nil { if errors.Is(err, domain.ErrAuditNotFound) { api.WriteNotFound(w, r, "audit log entry not found") return } api.WriteInternalError(w, r, "Failed to get audit log entry") return } api.WriteSuccess(w, r, auditLogToResponse(entry)) }