Files
oikos/internal/httpapi/server.go
dtoro 6b61495e3b phase 2 (part 5): MCP server with official Go SDK
- MCP server at /mcp using github.com/modelcontextprotocol/go-sdk v1.6.1
  with Streamable HTTP transport
- 9 tools implemented: get_entity, list_entities, get_relations,
  get_blast_radius, get_health_summary, get_audit_trail,
  search_knowledge, query_metrics, request_execution (policy-gated)
- All tools use the untyped ToolHandler pattern with raw JSON
  argument parsing
- Mounted on the same binary and port with bearer/OIDC auth
- SSE stream endpoint now handled by oapi-codegen strict handler

Phase 2 acceptance criteria:
- GET /entities?type=service  (part 1)
- MCP list_entities  (part 5)
- PATCH /entities with If-Match → 412  (part 3)
- Idempotency-Key replay  (part 3)
- SSE stream shows events  (part 4)
- Audit rows carry OIDC sub  (part 4)
- Spec-conformance tests  (CI setup, Phase 2 completing)

Remaining stubs: CreateEntityType, PatchEntityType, CreateCheck,
PatchCheck, RequestExecution, CancelExecution, ListApprovals,
DecideApproval, ListExecutions, GetExecution, ListPatterns,
PatchPattern, ListSkills, PatchSkill, etc. (Phase 3)
2026-07-07 12:27:36 +02:00

504 lines
14 KiB
Go

// Package httpapi implements the Oikos REST API. The contract is
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
// problem+json via domain sentinels.
package httpapi
import (
"context"
"crypto/rsa"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/golang-jwt/jwt/v5"
)
// contextKey for storing actor identity in request context.
type contextKey string
const actorKey contextKey = "oikos_actor"
// actor holds the resolved identity of the API caller.
type actor struct {
Type string // "operator", "agent", "system"
Label string // human-readable label
ID string // OIDC sub or static token identifier
TokenType string // "static" or "oidc"
}
// Server implements gen.StrictServerInterface over the DB layer.
type Server struct {
pool *db.Pool
cfg config.Config
sseBroker *sseBroker
sseSubs map[*sseSubscriber]struct{}
sseMu sync.Mutex
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
func NewHandler(pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
sseBroker: newSSEBroker(10000),
sseSubs: make(map[*sseSubscriber]struct{}),
}
// Start background SSE listener
go s.sseListener(context.Background())
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(requestLogger)
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
if err := pool.Ping(ctx); err != nil {
writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
ResponseErrorHandlerFunc: writeProblemFromErr,
})
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1",
BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
})
// Mount MCP at /mcp (plan R3-10)
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken))
// Mount SSE stream — handled by the strict handler's StreamEvents method
// via the OpenAPI-specified /api/v1/events/stream route.
return r
}
// combinedAuth tries OIDC JWT validation first (if configured), falls back to
// static bearer token validation, and opens the gate in dev mode when no
// credentials are configured.
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
// Cache JWKS for OIDC
var jwksURL string
var jwksCache []jwtVerificationKey
var jwksMu sync.RWMutex
if hasOIDC {
// Fetch JWKS URI from OIDC discovery
jwksURL = discoverJWKSURI(cfg.OIDCIssuer)
if jwksURL != "" {
keys, err := fetchJWKS(jwksURL)
if err != nil {
slog.Warn("oidc initial jwks fetch failed, will retry on demand", "error", err)
} else {
jwksCache = keys
}
}
}
var staticTokens [][]byte
if cfg.APIToken != "" {
staticTokens = append(staticTokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: "system",
Label: "dev:anonymous",
ID: "dev",
TokenType: "none",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ")
if !ok || raw == "" {
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing bearer token")
return
}
// Try OIDC first if configured
if hasOIDC {
jwksMu.RLock()
keys := jwksCache
jwksMu.RUnlock()
// If cache is empty, try to refresh
if len(keys) == 0 && jwksURL != "" {
if freshKeys, err := fetchJWKS(jwksURL); err == nil {
jwksMu.Lock()
jwksCache = freshKeys
keys = freshKeys
jwksMu.Unlock()
}
}
if act, err := validateOIDCToken(raw, cfg, keys); err == nil {
ctx := context.WithValue(r.Context(), actorKey, act)
next.ServeHTTP(w, r.WithContext(ctx))
return
} else {
slog.Debug("oidc validation failed", "error", err)
}
}
// Fall back to static tokens
if hasStatic {
for _, t := range staticTokens {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
label := "operator:api"
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
label = "agent:mcp"
}
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: label[:strings.IndexByte(label, ':')],
Label: label,
ID: raw[:8] + "...",
TokenType: "static",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"invalid or expired bearer token")
})
}
}
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid).
type jwtVerificationKey struct {
Kid string
Alg string
Key any // *rsa.PublicKey or []byte for HMAC
IsHMAC bool
}
// discoverJWKSURI fetches the OIDC discovery document and extracts the
// jwks_uri field.
func discoverJWKSURI(issuerURL string) string {
discURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Get(discURL)
if err != nil {
slog.Warn("oidc discovery failed", "url", discURL, "error", err)
return ""
}
defer resp.Body.Close()
var disc struct {
JWKSURI string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
slog.Warn("oidc discovery decode failed", "error", err)
return ""
}
if disc.JWKSURI == "" {
slog.Warn("oidc discovery missing jwks_uri")
return ""
}
return disc.JWKSURI
}
// fetchJWKS retrieves the JWK set from a URL and returns the parsed keys.
func fetchJWKS(jwksURL string) ([]jwtVerificationKey, error) {
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, fmt.Errorf("fetch jwks: %w", err)
}
defer resp.Body.Close()
var jwks struct {
Keys []json.RawMessage `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, fmt.Errorf("decode jwks: %w", err)
}
var keys []jwtVerificationKey
for _, raw := range jwks.Keys {
var header struct {
Kty string `json:"kty"`
Kid string `json:"kid,omitempty"`
Alg string `json:"alg,omitempty"`
Use string `json:"use,omitempty"`
}
if err := json.Unmarshal(raw, &header); err != nil {
continue
}
// Skip keys not intended for signature verification
if header.Use != "" && header.Use != "sig" {
continue
}
var key jwtVerificationKey
key.Kid = header.Kid
key.Alg = header.Alg
switch header.Kty {
case "RSA":
var rsaKey struct {
N string `json:"n"`
E string `json:"e"`
}
if err := json.Unmarshal(raw, &rsaKey); err != nil {
continue
}
pubKey, err := parseRSAPublicKey(rsaKey.N, rsaKey.E)
if err != nil {
slog.Debug("oidc parse rsa key failed", "kid", header.Kid, "error", err)
continue
}
key.Key = pubKey
case "oct":
// HMAC keys not expected for OIDC but handle gracefully
key.IsHMAC = true
default:
continue
}
if key.Key != nil || key.IsHMAC {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil, fmt.Errorf("no usable keys in jwks")
}
return keys, nil
}
// parseRSAPublicKey decodes a base64url-encoded RSA modulus and exponent into
// an *rsa.PublicKey.
func parseRSAPublicKey(nB64, eB64 string) (any, error) {
// Decode base64url modulus
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, fmt.Errorf("decode modulus: %w", err)
}
// Decode base64url exponent
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, fmt.Errorf("decode exponent: %w", err)
}
// Build RSA public key
n := new(big.Int).SetBytes(nBytes)
e := 0
for _, b := range eBytes {
e = (e << 8) | int(b)
}
return &rsa.PublicKey{N: n, E: e}, nil
}
// validateOIDCToken parses and validates a JWT Bearer token against the OIDC
// configuration. Returns the resolved actor on success.
func validateOIDCToken(rawToken string, cfg config.Config, keys []jwtVerificationKey) (actor, error) {
keyFunc := func(token *jwt.Token) (any, error) {
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("no kid in token header")
}
// Find matching key
for _, k := range keys {
// If kid is empty in JWK, try algorithm match
if k.Kid == kid || (k.Kid == "" && k.Alg == token.Header["alg"]) {
return k.Key, nil
}
}
// Fall back to any RSA key if no kid match (some providers don't set kid)
if !ok {
for _, k := range keys {
if k.Key != nil {
return k.Key, nil
}
}
}
return nil, fmt.Errorf("no matching key for kid: %s", kid)
}
token, err := jwt.Parse(rawToken, keyFunc,
jwt.WithIssuer(cfg.OIDCIssuer),
jwt.WithAudience(cfg.OIDCClientID),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
)
if err != nil {
return actor{}, fmt.Errorf("jwt validation: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return actor{}, fmt.Errorf("invalid claims")
}
sub, _ := claims.GetSubject()
if sub == "" {
// Try the Azure/Entra ID oid claim fallback
if oid, ok := claims["oid"].(string); ok {
sub = oid
}
}
preferredUsername, _ := claims["preferred_username"].(string)
email, _ := claims["email"].(string)
label := sub
if preferredUsername != "" {
label = preferredUsername
} else if email != "" {
label = email
}
return actor{
Type: "operator",
Label: label,
ID: sub,
TokenType: "oidc",
}, nil
}
// GetActor extracts the actor identity from the context. Returns nil if not
// set (should not happen for authenticated routes).
func GetActor(ctx context.Context) *actor {
a, ok := ctx.Value(actorKey).(actor)
if !ok {
return nil
}
return &a
}
// bearerAuth is the interim Phase 2 auth: a static bearer token
// (OIKOS_API_TOKEN / OIKOS_MCP_BEARER_TOKEN from Infisical in prod). In
// dev mode with no token configured, requests pass as the operator.
// Authentik OIDC JWT validation (operator/viewer scopes) lands later in
// Phase 2 — tracked in the plan's AuthN/AuthZ table.
func bearerAuth(cfg config.Config) func(http.Handler) http.Handler {
tokens := [][]byte{}
if cfg.APIToken != "" {
tokens = append(tokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
tokens = append(tokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && len(tokens) == 0
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ")
if ok {
for _, t := range tokens {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
next.ServeHTTP(w, r)
return
}
}
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing or invalid bearer token")
})
}
}
// Ensure sqlcgen is imported (used in sse.go but referenced here for build safety).
var _ = &sqlcgen.Queries{}
// requestLogger logs one line per request with method, path, status,
// duration, and the chi request id.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(pool, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("api listening", "addr", cfg.APIListen)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
slog.Info("api shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}