internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into 15 per-resource files: - actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget, executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate) - checks.go, classifications.go, executions.go, approvals.go, patterns.go, skills.go, approval_rules.go, autonomy.go, risk_classes.go, relationships.go, entity_types.go, metrics.go, agent_activity.go, helpers.go — one file per resource domain, each with its own imports. internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations) refactored to a registry pattern: - internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33 tool definitions. Handler logic moved verbatim — no changes to tool names, descriptions, schemas, or behavior. - server.go: newServer is now 9 lines (iterate registry, AddTool each). -699 lines. No function logic, names, or signatures changed. go vet, build, and all tests pass (httpapi, mcp, db, policy).
161 lines
4.6 KiB
Go
161 lines
4.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// ─── 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", "",
|
|
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, "",
|
|
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
|
|
}
|