phase 2 (part 4): SSE stream via io.Pipe, OIDC JWT auth middleware
- SSE stream: GET /events/stream using io.Pipe to bridge the SSE goroutine to the response body. Replay from Last-Event-ID via in-memory broker with DB fallback. LISTEN/NOTIFY fan-out to all subscribers. Heartbeat every 15s. Bounded channels. - OIDC JWT auth: validates Bearer tokens against Authentik/OIDC issuer via JWKS discovery + key caching. Extracts sub/email into context actor. Falls back to static bearer tokens. Dev mode (no OIDC + no tokens) = open. - Config: OIDCIssuer, OIDCClientID env vars - SSE + OIDC infrastructure complete, build passes, all tests pass Remaining: MCP server, conformance tests, wire audit middleware
This commit is contained in:
@@ -2,14 +2,18 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -746,3 +750,312 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
|
||||
}
|
||||
return gen.QueryAudit200JSONResponse{Items: items}, rows.Err()
|
||||
}
|
||||
|
||||
// ─── Entity mutations ──────────────────────────────────────────────
|
||||
|
||||
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Check idempotency if a key was provided.
|
||||
actor := "operator"
|
||||
var bodyHash string
|
||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||
key := *req.Params.IdempotencyKey
|
||||
q := sqlcgen.New(s.pool)
|
||||
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{
|
||||
Actor: actor,
|
||||
Key: key,
|
||||
})
|
||||
if err == nil {
|
||||
// Verify the request body hasn't changed.
|
||||
bodyJSON, _ := json.Marshal(req.Body)
|
||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
||||
if cached.RequestHash != bodyHash {
|
||||
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
||||
}
|
||||
// Replay the cached response.
|
||||
if cached.ResponseCode != nil && *cached.ResponseCode == 201 {
|
||||
var entity gen.Entity
|
||||
if len(cached.ResponseBody) > 0 {
|
||||
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal cached response: %w", err)
|
||||
}
|
||||
}
|
||||
return gen.CreateEntity201JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
// Forward cached error response.
|
||||
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
|
||||
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"},
|
||||
StatusCode: int(*cached.ResponseCode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
slug := req.Body.Slug
|
||||
if slug == "" {
|
||||
slug = req.Body.Type + ":" + req.Body.Name
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
q := sqlcgen.New(tx)
|
||||
|
||||
// Validate type exists and is NOT abstract.
|
||||
var isAbstract bool
|
||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if isAbstract {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
|
||||
}
|
||||
|
||||
// Get default state from lifecycle.
|
||||
var defaultState *string
|
||||
var lcDefault string
|
||||
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil {
|
||||
defaultState = &lcDefault
|
||||
}
|
||||
|
||||
state := req.Body.State
|
||||
if state == nil && defaultState != nil {
|
||||
state = defaultState
|
||||
}
|
||||
|
||||
var attrsJSON []byte
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
// Insert the entity.
|
||||
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||
ID: id,
|
||||
Slug: slug,
|
||||
Type: req.Body.Type,
|
||||
Name: req.Body.Name,
|
||||
State: state,
|
||||
Attributes: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
// Duplicate slug.
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert sqlcgen.Entity → gen.Entity.
|
||||
entity := sqlcEntityToGen(inserted)
|
||||
|
||||
// Cache idempotent response.
|
||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||
respBody, _ := json.Marshal(entity)
|
||||
code := int32(201)
|
||||
if bodyHash == "" {
|
||||
bodyJSON, _ := json.Marshal(req.Body)
|
||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
||||
}
|
||||
if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
|
||||
Actor: actor,
|
||||
Key: *req.Params.IdempotencyKey,
|
||||
RequestHash: bodyHash,
|
||||
ResponseCode: &code,
|
||||
ResponseBody: respBody,
|
||||
}); putErr != nil {
|
||||
return nil, putErr
|
||||
}
|
||||
}
|
||||
|
||||
// Audit.
|
||||
entityID := inserted.ID
|
||||
if auditErr := observability.Audit(ctx, q, "operator", actor, "create",
|
||||
&entityID, "POST", "/api/v1/entities", "",
|
||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if eventErr := observability.Event(ctx, q, "entity.created", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil {
|
||||
return nil, eventErr
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.CreateEntity201JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse If-Match header (quoted version string).
|
||||
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
|
||||
expectedVersion, err := strconv.Atoi(ifMatch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Get current entity for version check + lifecycle validation.
|
||||
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int(current.Version) != expectedVersion {
|
||||
return nil, fmt.Errorf("%w: expected version %d, current version %d",
|
||||
domain.ErrConflict, expectedVersion, current.Version)
|
||||
}
|
||||
|
||||
// Validate lifecycle transition if state is being changed.
|
||||
if req.Body.State != nil && *req.Body.State != "" {
|
||||
// Get lifecycle def for the entity's type.
|
||||
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
// No lifecycle defined — any state is allowed.
|
||||
_ = lc
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Check the transition is valid.
|
||||
var transitions map[string][]string
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||
}
|
||||
|
||||
fromState := ""
|
||||
if current.State != nil {
|
||||
fromState = *current.State
|
||||
}
|
||||
toState := *req.Body.State
|
||||
|
||||
if allowed, ok := transitions[fromState]; ok {
|
||||
found := false
|
||||
for _, s := range allowed {
|
||||
if s == toState {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
||||
}
|
||||
} else if fromState != "" {
|
||||
// No transitions defined from current state.
|
||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH,
|
||||
// but we handle it if the generated code ever adds it).
|
||||
// For now, no idempotency check on PATCH.
|
||||
|
||||
// Marshal attributes if provided.
|
||||
var attrsJSON []byte
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
// Perform the update via sqlcgen.
|
||||
q := sqlcgen.New(tx)
|
||||
updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
||||
Name: req.Body.Name,
|
||||
State: req.Body.State,
|
||||
Attributes: attrsJSON,
|
||||
SetMaintenance: req.Body.MaintenanceUntil != nil,
|
||||
MaintenanceUntil: req.Body.MaintenanceUntil,
|
||||
ID: id,
|
||||
Version: int32(expectedVersion),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
// Version mismatch or entity not found.
|
||||
return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entity := sqlcEntityToGen(updated)
|
||||
|
||||
// Audit.
|
||||
if auditErr := observability.Audit(ctx, q, "operator", "operator", "patch",
|
||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if eventErr := observability.Event(ctx, q, "entity.updated", &id,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil {
|
||||
return nil, eventErr
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.PatchEntity200JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity.
|
||||
func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
||||
out := gen.Entity{
|
||||
Id: e.ID,
|
||||
Slug: e.Slug,
|
||||
Type: e.Type,
|
||||
Name: e.Name,
|
||||
State: e.State,
|
||||
Version: int(e.Version),
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
if e.MaintenanceUntil != nil {
|
||||
out.MaintenanceUntil = e.MaintenanceUntil
|
||||
}
|
||||
if len(e.Attributes) > 0 {
|
||||
var attrs map[string]any
|
||||
if json.Unmarshal(e.Attributes, &attrs) == nil && len(attrs) > 0 {
|
||||
out.Attributes = &attrs
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user