Files
oikos/internal/httpapi/approval_rules.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

163 lines
4.7 KiB
Go

package httpapi
import (
"context"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
)
// ─── Approval Rules (Policy) ───────────────────────────────────────────
func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, entity_type, action, risk_class, autonomy_level,
COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
version, updated_at
FROM approval_rules ORDER BY entity_type, action`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.ApprovalRule{}
for rows.Next() {
var rule gen.ApprovalRule
var scopeSlug string
if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
&rule.Version); err != nil {
return nil, err
}
if scopeSlug != "" {
rule.ScopeEntity = &scopeSlug
}
items = append(items, rule)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.ApprovalRule{}
}
return gen.ListApprovalRules200JSONResponse{Items: items}, nil
}
func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
}
return nil, err
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
&id, "POST", "/api/v1/policy/approval-rules", "",
nil,
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Return 202 pending approval (dual-control).
return gen.CreateApprovalRule202JSONResponse{}, nil
}
func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, 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
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `
UPDATE approval_rules
SET entity_type = COALESCE($2, entity_type),
action = COALESCE($3, action),
risk_class = COALESCE($4, risk_class),
autonomy_level = COALESCE($5, autonomy_level),
scope_entity = COALESCE($6, scope_entity),
version = version + 1,
updated_at = now()
WHERE id = $1`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
nil,
map[string]any{"action": req.Body.Action}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchApprovalRule202JSONResponse{}, nil
}