- 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.
149 lines
4.4 KiB
Go
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
|
|
}
|