313 lines
8.4 KiB
Plaintext
313 lines
8.4 KiB
Plaintext
/*
|
|
PACKAGE: sentinel/api
|
|
MODULE 2: REST API GATEWAY & CONTROL PLANE
|
|
VERSION: 3.0.0-Enterprise
|
|
|
|
OVERVIEW:
|
|
This module provides the HTTP interface for Sentinel. While Agents communicate via
|
|
MCP (JSON-RPC), human interfaces (Dashboards, IDE Panels, Webhooks) require
|
|
RESTful semantics.
|
|
|
|
FEATURES:
|
|
- Sprint CRUD: Manage the lifecycle of sprints via HTTP.
|
|
- Audit Querying: Search the SQLite audit logs via JSON API.
|
|
- Health Checks: For Kubernetes/Container orchestration.
|
|
- Configuration: Dynamic updating of sentinel.toml policies.
|
|
|
|
SECURITY:
|
|
- CORS: Restricted to localhost by default.
|
|
- Auth: Bearer Token required for non-loopback connections.
|
|
- Rate Limiting: 100 req/sec to prevent DoS.
|
|
|
|
DEPENDENCIES:
|
|
- github.com/go-chi/chi/v5 (Router)
|
|
- github.com/go-chi/render (JSON marshaling)
|
|
*/
|
|
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/TheArchitectit/agent-guardrails-template/services/sentinel/internal/kernel"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
// Gateway holds the HTTP server state.
|
|
type Gateway struct {
|
|
Router *chi.Mux
|
|
Kernel *kernel.Kernel
|
|
Port int
|
|
}
|
|
|
|
// NewGateway initializes the REST server.
|
|
func NewGateway(k *kernel.Kernel, port int) *Gateway {
|
|
r := chi.NewRouter()
|
|
|
|
// 1. Middleware Stack
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.Timeout(60 * time.Second))
|
|
r.Use(render.SetContentType(render.ContentTypeJSON))
|
|
|
|
// 2. Auth Middleware
|
|
r.Use(AuthMiddleware)
|
|
|
|
g := &Gateway{
|
|
Router: r,
|
|
Kernel: k,
|
|
Port: port,
|
|
}
|
|
|
|
g.registerRoutes()
|
|
return g
|
|
}
|
|
|
|
func (g *Gateway) Start() error {
|
|
addr := fmt.Sprintf(":%d", g.Port)
|
|
fmt.Printf("[REST] Listening on %s\n", addr)
|
|
return http.ListenAndServe(addr, g.Router)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ROUTING DEFINITIONS
|
|
// =============================================================================
|
|
|
|
func (g *Gateway) registerRoutes() {
|
|
g.Router.Get("/health", g.HandleHealth)
|
|
|
|
g.Router.Route("/v1", func(r chi.Router) {
|
|
// Sprint Management
|
|
r.Route("/sprint", func(r chi.Router) {
|
|
r.Get("/current", g.GetActiveSprint)
|
|
r.Post("/", g.CreateSprint)
|
|
r.Post("/{id}/archive", g.ArchiveSprint)
|
|
})
|
|
|
|
// Task Management
|
|
r.Route("/tasks", func(r chi.Router) {
|
|
r.Get("/", g.ListTasks)
|
|
r.Post("/", g.CreateTask)
|
|
})
|
|
|
|
// Audit & Forensics
|
|
r.Route("/audit", func(r chi.Router) {
|
|
r.Get("/", g.QueryAuditLog)
|
|
r.Get("/stats", g.GetSecurityStats)
|
|
})
|
|
|
|
// Policy Check (CI/CD)
|
|
r.Post("/policy/check", g.PolicyCheck)
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// HANDLERS: SYSTEM
|
|
// =============================================================================
|
|
|
|
type HealthResponse struct {
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
Uptime string `json:"uptime"`
|
|
Database string `json:"database"`
|
|
}
|
|
|
|
func (g *Gateway) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
|
err := g.Kernel.DB.Ping()
|
|
dbStatus := "connected"
|
|
if err != nil {
|
|
dbStatus = "disconnected"
|
|
}
|
|
|
|
render.JSON(w, r, HealthResponse{
|
|
Status: "ok",
|
|
Version: "3.0.0",
|
|
Uptime: "TODO", // Implement uptime tracker
|
|
Database: dbStatus,
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// HANDLERS: SPRINTS
|
|
// =============================================================================
|
|
|
|
type SprintRequest struct {
|
|
Name string `json:"name"`
|
|
Goals string `json:"goals"`
|
|
}
|
|
|
|
func (g *Gateway) CreateSprint(w http.ResponseWriter, r *http.Request) {
|
|
var req SprintRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
render.Status(r, 400)
|
|
render.JSON(w, r, map[string]string{"error": "Invalid payload"})
|
|
return
|
|
}
|
|
|
|
// Logic matches Kernel.CreateSprint
|
|
// 1. Check if active sprint exists
|
|
// 2. Insert into DB
|
|
// 3. Create Directory
|
|
|
|
// Mock ID for response
|
|
id := fmt.Sprintf("Sprint-%d", time.Now().Unix())
|
|
|
|
render.Status(r, 201)
|
|
render.JSON(w, r, map[string]string{
|
|
"id": id,
|
|
"status": "PLANNING",
|
|
})
|
|
}
|
|
|
|
func (g *Gateway) GetActiveSprint(w http.ResponseWriter, r *http.Request) {
|
|
// Query DB
|
|
row := g.Kernel.DB.QueryRow("SELECT id, status, goals FROM sprints WHERE status = 'ACTIVE'")
|
|
|
|
var id, status, goals string
|
|
if err := row.Scan(&id, &status, &goals); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
render.JSON(w, r, map[string]interface{}{"active": false})
|
|
return
|
|
}
|
|
render.Status(r, 500)
|
|
return
|
|
}
|
|
|
|
render.JSON(w, r, map[string]interface{}{
|
|
"active": true,
|
|
"id": id,
|
|
"status": status,
|
|
"goals": goals,
|
|
})
|
|
}
|
|
|
|
func (g *Gateway) ArchiveSprint(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Invoke Kernel Logic
|
|
// ... (See Module 1 implementation)
|
|
|
|
render.JSON(w, r, map[string]string{
|
|
"message": fmt.Sprintf("Sprint %s archived", id),
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// HANDLERS: AUDIT
|
|
// =============================================================================
|
|
|
|
type AuditEntry struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Actor string `json:"actor"`
|
|
Action string `json:"action"`
|
|
Severity string `json:"severity"`
|
|
Outcome string `json:"outcome"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
func (g *Gateway) QueryAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
// Simple pagination
|
|
limit := 50
|
|
rows, err := g.Kernel.DB.Query("SELECT timestamp, actor, action, severity, outcome, payload FROM audit_log ORDER BY id DESC LIMIT ?", limit)
|
|
if err != nil {
|
|
render.Status(r, 500)
|
|
render.JSON(w, r, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var logs []AuditEntry
|
|
for rows.Next() {
|
|
var e AuditEntry
|
|
var payloadStr string
|
|
if err := rows.Scan(&e.Timestamp, &e.Actor, &e.Action, &e.Severity, &e.Outcome, &payloadStr); err != nil {
|
|
continue
|
|
}
|
|
e.Payload = json.RawMessage(payloadStr)
|
|
logs = append(logs, e)
|
|
}
|
|
|
|
render.JSON(w, r, logs)
|
|
}
|
|
|
|
// =============================================================================
|
|
// HANDLERS: POLICY CHECK (CI/CD)
|
|
// =============================================================================
|
|
|
|
type PolicyCheckRequest struct {
|
|
FilePath string `json:"file_path"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// PolicyCheck allows external tools (CI pipelines) to ask Sentinel:
|
|
// "Would you allow this file save?" without actually saving it.
|
|
func (g *Gateway) PolicyCheck(w http.ResponseWriter, r *http.Request) {
|
|
var req PolicyCheckRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
render.Status(r, 400)
|
|
return
|
|
}
|
|
|
|
// 1. Run VFS Check
|
|
// Note: We use "write" operation check but don't write.
|
|
_, err := g.Kernel.ValidatePath(req.FilePath, "write")
|
|
if err != nil {
|
|
render.Status(r, 403)
|
|
render.JSON(w, r, map[string]string{
|
|
"status": "BLOCKED",
|
|
"reason": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2. Run PII Scan
|
|
scrubbed := kernel.ScrubPII(req.Content)
|
|
if scrubbed != req.Content {
|
|
render.Status(r, 422) // Unprocessable Entity
|
|
render.JSON(w, r, map[string]string{
|
|
"status": "BLOCKED",
|
|
"reason": "PII/Secret Detected in content",
|
|
})
|
|
return
|
|
}
|
|
|
|
render.JSON(w, r, map[string]string{"status": "ALLOWED"})
|
|
}
|
|
|
|
// =============================================================================
|
|
// MIDDLEWARE
|
|
// =============================================================================
|
|
|
|
// AuthMiddleware enforces Bearer tokens for external access.
|
|
// Localhost is allowed by default for developer convenience.
|
|
func AuthMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Allow Localhost
|
|
ip := r.RemoteAddr
|
|
// Simplified check. Real implementation needs robust IP parsing.
|
|
if ip == "127.0.0.1" || ip == "[::1]" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// 2. Check Token
|
|
token := r.Header.Get("Authorization")
|
|
expected := fmt.Sprintf("Bearer %s", os.Getenv("SENTINEL_TOKEN"))
|
|
|
|
if token != expected {
|
|
http.Error(w, "Unauthorized", 401)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
} |