Files
oikos/internal/httpapi/approvals.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
2026-08-16 12:29:59 +02:00

149 lines
4.4 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
// ─── Approvals ─────────────────────────────────────────────────────────
func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
var status *string
if req.Params.Status != nil {
st := string(*req.Params.Status)
status = &st
}
var kind *string
if req.Params.Kind != nil {
k := string(*req.Params.Kind)
kind = &k
}
rows, err := s.pool.Query(ctx, `
SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
a.status, a.expires_at, a.decided_at, a.decided_by::text,
a.created_at, e.slug
FROM approvals a
JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
WHERE ($1::text IS NULL OR a.status = $1)
AND ($2::text IS NULL OR a.kind = $2)
AND ($3::text IS NULL OR e.slug > $3)
ORDER BY e.slug
LIMIT $4`,
status, kind, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Approval{}
for rows.Next() {
var a gen.Approval
var payloadBytes []byte
var decidedBy *string
if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
&a.CreatedAt, &a.Slug); err != nil {
return nil, err
}
a.DecidedBy = decidedBy
var payload map[string]any
if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
a.Payload = &payload
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Approval{}
}
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}
// DecideApproval is the thin presenter over ApprovalService.Decide: it maps
// the request onto the service command, then dispatches the SSH work for an
// approved execution in a background goroutine. The HMAC/window/execution
// un-gating logic lives in the service + repository (Phase 9 convergence).
func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, 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
}
_, actor := actorInfo(ctx)
var token string
if req.Body.Token != nil {
token = *req.Body.Token
}
result, err := s.approvalSvc.Decide(ctx, app.ApprovalDecideCmd{
ApprovalID: domain.UUID(id.String()),
Token: token,
Decision: string(req.Body.Decision),
Actor: actor,
})
if err != nil {
return nil, err
}
// Approve: dispatch the SSH work the repo just un-gated, in the
// background so the HTTP response is not blocked by the execution.
if result.Resume != nil {
res := result.Resume
safego.Go("httpapi:executeApprovedAction", func() {
s.executeApprovedAction(context.Background(), s.pool, uuid.MustParse(string(res.ExecutionID)), res.TargetSlug, res.Action)
})
slog.Info("httpapi: approved execution queued",
"execution_id", res.ExecutionID, "target", res.TargetSlug, "action", res.Action)
}
return gen.DecideApproval200JSONResponse(approvalToGenApproval(result.Approval)), nil
}
// approvalToGenApproval maps the domain approval onto the wire shape. The
// subject slug is not carried on the domain type, so it is left empty on the
// decide path.
func approvalToGenApproval(a domain.Approval) gen.Approval {
app := gen.Approval{
Id: uuid.MustParse(string(a.EntityID)),
Action: a.Action,
RiskClass: a.RiskClass,
Kind: gen.ApprovalKind(a.Kind),
Status: gen.ApprovalStatus(a.Status),
ExpiresAt: a.ExpiresAt,
DecidedAt: a.DecidedAt,
CreatedAt: a.CreatedAt,
}
if a.DecidedBy != "" {
u := uuid.MustParse(string(a.DecidedBy))
str := u.String()
app.DecidedBy = &str
}
if len(a.Payload) > 0 {
payload := map[string]any(a.Payload)
app.Payload = &payload
}
return app
}