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>
226 lines
6.5 KiB
Go
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))
|
|
}
|