rdev/internal/handlers/audit.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

226 lines
6.5 KiB
Go

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))
}