From 3bc577df1dc8e4914896eeb2c2ec0ddc9d8212f1 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Mon, 10 Aug 2026 02:49:14 -0700 Subject: [PATCH] feat: derive approval reasons at read time and add opt-in auto-approve rules Pending approvals created without a stored reason now render a human-readable sentence derived from the reviewed plan (agent upgrades, linemeta metadata syncs, nft/netpolicy/selfdns/proxycore/tunnel/wireguard applies). Stored rows are never migrated; derivation happens in the view mapping so every reader sees a consistent reason. Auto-approve is an operator opt-in via LATTICE_APPROVAL_AUTO_RULES (default off = fully manual). Fresh pending submissions flow through the new submitApproval chokepoint; the first matching rule approves (and optionally queues) through the exact same plan-hash-bound decision path as the manual endpoint, crediting policy: as the actor, with a per-rule daily cap and approval.auto_approve / approval.auto_skip audit events. --- README.md | 9 + cmd/lattice-server/main.go | 3 + internal/server/server.go | 132 ++++--- internal/server/server_agent_update.go | 3 +- internal/server/server_approval_policy.go | 180 ++++++++++ .../server/server_approval_policy_test.go | 327 ++++++++++++++++++ internal/server/server_approval_reason.go | 132 +++++++ .../server/server_approval_reason_test.go | 179 ++++++++++ internal/server/server_dns.go | 3 +- internal/server/server_group_policy.go | 3 +- internal/server/server_netguard.go | 3 +- internal/server/server_netpolicy.go | 3 +- internal/server/server_proxy.go | 3 +- internal/server/server_views.go | 4 +- 14 files changed, 933 insertions(+), 51 deletions(-) create mode 100644 internal/server/server_approval_policy.go create mode 100644 internal/server/server_approval_policy_test.go create mode 100644 internal/server/server_approval_reason.go create mode 100644 internal/server/server_approval_reason_test.go diff --git a/README.md b/README.md index a90db22..57bbb17 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,15 @@ Use the compose file and deployment guide in the umbrella repository: TOTP; all other session-backed APIs return the stable `mfa_required` error until enrollment is complete. Bearer PAT automation is not an interactive session and is not gated by this policy. +- Approvals are manual by default. `LATTICE_APPROVAL_AUTO_RULES` (or + `-approval-auto-rules`) accepts a JSON array of opt-in auto-approve rules for + trusted writers, e.g. + `[{"name":"linemeta-fleet","writer":"lattice-server","plugin":"singbox-linemeta","action_prefix":"apply-metadata","queue":true,"daily_cap":100}]`. + The first matching rule wins; `queue` selects approve-and-queue over + approve-only, `daily_cap` bounds auto-approvals per UTC day (0 = uncapped), + and automated decisions are audited as `approval.auto_approve` / + `approval.auto_skip`. Malformed JSON is logged and ignored — the server + starts fully manual. - **Passkeys (WebAuthn).** Operators can register passkeys (platform authenticators such as Apple Passwords / iCloud Keychain, or roaming security keys) and sign in with them. Verification uses diff --git a/cmd/lattice-server/main.go b/cmd/lattice-server/main.go index 4188a63..746859b 100644 --- a/cmd/lattice-server/main.go +++ b/cmd/lattice-server/main.go @@ -59,6 +59,7 @@ func main() { var auditHeadWebhookToken string var auditHeadInterval time.Duration var taskExecDisabled bool + var approvalAutoRules string var printVersion bool flag.StringVar(&listen, "listen", env("LATTICE_LISTEN", "127.0.0.1:8088"), "listen address") flag.StringVar(&dataPath, "data", env("LATTICE_DATA", defaultDataPath()), "state file path") @@ -85,6 +86,7 @@ func main() { flag.StringVar(&auditHeadWebhookToken, "audit-head-webhook-token", env("LATTICE_AUDIT_HEAD_WEBHOOK_TOKEN", ""), "bearer token for -audit-head-webhook-url") flag.DurationVar(&auditHeadInterval, "audit-head-interval", envDuration("LATTICE_AUDIT_HEAD_INTERVAL", 15*time.Minute), "audit head webhook shipping interval") flag.BoolVar(&taskExecDisabled, "task-exec-disabled", env("LATTICE_TASK_EXEC_DISABLED", "") == "1", "server-side fleet kill switch: block new task queueing and agent task leases") + flag.StringVar(&approvalAutoRules, "approval-auto-rules", env("LATTICE_APPROVAL_AUTO_RULES", ""), "JSON array of approval auto-approve rules (empty keeps approvals fully manual)") flag.BoolVar(&printVersion, "version", false, "print lattice-server version and exit") flag.Parse() if printVersion { @@ -189,6 +191,7 @@ func main() { Interval: auditHeadInterval, }, TaskExecutionDisabled: taskExecDisabled, + ApprovalAutoRules: approvalAutoRules, Logger: log.Default(), }) if err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 0bb64e9..ab49b97 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -118,6 +118,10 @@ type Options struct { // tasks are not queued and agents receive no task leases. Already leased // task results are still accepted so in-flight work can report terminal state. TaskExecutionDisabled bool + // ApprovalAutoRules is the operator's JSON-encoded auto-approve policy list + // (LATTICE_APPROVAL_AUTO_RULES). Empty keeps approvals fully manual — the + // default. Malformed input is logged and ignored, never fatal. + ApprovalAutoRules string // RenewalReminderInterval controls the machine-renewal reminder scheduler. // Zero uses the production default. DisableRenewalScheduler is intended for // tests that need full control over reminder evaluation. @@ -206,6 +210,9 @@ type Server struct { // config, not persisted policy; restart with the flag/env cleared to re-enable // queueing and leasing. taskExecutionDisabled bool + // approvalAutoRules is the parsed auto-approve policy list. Nil (the + // default) keeps every approval manual. + approvalAutoRules []approvalAutoRule // terminalBroker owns short-lived interactive terminal sessions. Sessions are // intentionally in-memory only; a server restart forces operators to reopen. terminalBroker *terminalBroker @@ -336,6 +343,13 @@ func New(opts Options) (*Server, error) { } build := normalizeBuildInfo(opts.Build) build.TaskExecutionDisabled = opts.TaskExecutionDisabled + approvalAutoRules, err := parseApprovalAutoRules(opts.ApprovalAutoRules) + if err != nil { + // Auto-approve is an operator opt-in; malformed policy must never block + // startup, so fall back to the zero-rule (fully manual) default. + opts.Logger.Printf("WARNING: ignoring approval auto-approve rules: %v", err) + approvalAutoRules = nil + } s := &Server{ store: opts.Store, logStore: opts.LogStore, @@ -370,6 +384,7 @@ func New(opts Options) (*Server, error) { agentReleaseRepo: agentReleaseRepo, auditHeadShipper: auditHeadShipper, taskExecutionDisabled: opts.TaskExecutionDisabled, + approvalAutoRules: approvalAutoRules, terminalBroker: newTerminalBroker(), terminalHub: newTerminalHub(), agentControlHub: newAgentControlHub(), @@ -386,6 +401,9 @@ func New(opts Options) (*Server, error) { if s.taskExecutionDisabled { s.logger.Printf("WARNING: task execution fleet kill switch is enabled; new tasks will not queue and agents will receive no task leases") } + if len(s.approvalAutoRules) > 0 { + s.logger.Printf("approval auto-approve: %d rule(s) active", len(s.approvalAutoRules)) + } s.emitNotify = s.notifyEvent s.pluginRPC = plugin.NewRPCRegistry() s.registerVPNCoreRPC() @@ -4564,7 +4582,8 @@ func (s *Server) handleNFTPlan(w http.ResponseWriter, r *http.Request, p princip ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -4744,7 +4763,8 @@ func (s *Server) handleTunnelPlan(w http.ResponseWriter, r *http.Request, p prin ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -4795,7 +4815,8 @@ func (s *Server) handleWireGuardPlan(w http.ResponseWriter, r *http.Request, p p ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -5385,48 +5406,80 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip if !s.requireApprovalDecisionScopes(w, p, approval) { return } - if approval.Status != model.ApprovalPending { - writeJSON(w, http.StatusOK, toApprovalView(approval)) + updated, err := s.approveApprovalCore(p, approval, req.QueueApply, req.PlanSHA256) + if err != nil { + var decisionErr *approvalDecisionError + if errors.As(err, &decisionErr) { + if decisionErr.taskExecutionDisabled { + writeTaskExecutionDisabled(w) + return + } + writeError(w, decisionErr.status, decisionErr.err) + return + } + writeError(w, http.StatusInternalServerError, err) return } - if approvalRequiresPlanHash(approval) && req.PlanSHA256 == "" { - writeError(w, http.StatusBadRequest, apiError(model.APIErrorBadRequest, "plan_sha256 is required for this approval")) - return + writeJSON(w, http.StatusOK, toApprovalView(updated)) +} + +// approvalDecisionError pairs a failed approval decision with the HTTP +// response the manual endpoint would have produced, so non-HTTP callers (the +// auto-approve policy engine) share one decision path without losing response +// fidelity at the endpoint. +type approvalDecisionError struct { + status int + err error + // taskExecutionDisabled selects the dedicated kill-switch response body. + taskExecutionDisabled bool +} + +func (e *approvalDecisionError) Error() string { return e.err.Error() } + +// approveApprovalCore is the single decision path behind both the manual +// approve endpoint and auto-approve policies. It re-validates the reviewed +// plan (the plan_sha256 binding), re-checks plugin-specific freshness, marks +// the approval approved, and optionally queues the apply task. Keeping one +// implementation matters most for the hash binding: an approval must only +// ever transition for the exact stored plan bytes, whichever entry point +// drove the decision. +func (s *Server) approveApprovalCore(p principal, approval model.Approval, queueApply bool, planSHA256 string) (model.Approval, error) { + if approval.Status != model.ApprovalPending { + // Already-decided approvals stay idempotent, matching the manual + // endpoint's retry semantics. + return approval, nil } - if req.PlanSHA256 != "" { + if approvalRequiresPlanHash(approval) && planSHA256 == "" { + return approval, &approvalDecisionError{status: http.StatusBadRequest, err: apiError(model.APIErrorBadRequest, "plan_sha256 is required for this approval")} + } + if planSHA256 != "" { sum := sha256.Sum256([]byte(approval.Plan)) - if !strings.EqualFold(req.PlanSHA256, hex.EncodeToString(sum[:])) { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, "plan changed since review; re-review before approving")) - return + if !strings.EqualFold(planSHA256, hex.EncodeToString(sum[:])) { + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, "plan changed since review; re-review before approving")} } } if approval.Plugin == "nftpolicy" { if err := s.requireCurrentNetPolicyApproval(approval); err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())} } } if approval.Plugin == proxyCorePlugin { if err := s.requireCurrentProxyCoreApproval(approval); err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())} } } if approval.Plugin == agentUpdatePlugin { if err := s.requireCurrentAgentUpdateApproval(approval); err != nil { if errors.Is(err, errAgentUpdateApprovalStale) { if rejectErr := s.rejectAgentUpdateApprovalWithReason(approval, err.Error(), s.now()); rejectErr != nil { - writeError(w, http.StatusInternalServerError, rejectErr) - return + return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: rejectErr} } - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())} } - writeError(w, http.StatusConflict, apiError(model.APIErrorBadRequest, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorBadRequest, err.Error())} } } - if req.QueueApply && s.taskExecutionDisabled { + if queueApply && s.taskExecutionDisabled { s.recordPrincipalAudit(p, model.AuditEvent{ ID: id.New("audit"), NodeID: approval.NodeID, @@ -5436,36 +5489,31 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip Reason: errTaskExecutionDisabled.Error(), Metadata: map[string]string{"approval_id": approval.ID}, }) - writeTaskExecutionDisabled(w) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(apiErrorTaskExecutionDisabled, errTaskExecutionDisabled.Error()), taskExecutionDisabled: true} } applyScript := "" - if req.QueueApply { + if queueApply { switch approval.Plugin { case "selfdns": var err error applyScript, err = selfdns.ApplyScriptFromPlan(approval.Plan) if err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, "selfdns plan is no longer applyable; re-plan before approving")) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, "selfdns plan is no longer applyable; re-plan before approving")} } if err := s.requireSelfDNSDeploymentForApproval(approval); err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())} } case proxyCorePlugin: var err error applyScript, err = s.proxyCoreApplyScript(approval) if err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorApprovalStale, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorApprovalStale, err.Error())} } case agentUpdatePlugin: var err error applyScript, err = agentUpdateApplyScript(approval) if err != nil { - writeError(w, http.StatusConflict, apiError(model.APIErrorBadRequest, err.Error())) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(model.APIErrorBadRequest, err.Error())} } default: applyScript = s.applyScriptFor(approval) @@ -5474,10 +5522,9 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip approval.Status = model.ApprovalApproved approval.ApprovedBy = p.ActorID if err := s.store.UpsertApproval(approval); err != nil { - writeError(w, http.StatusInternalServerError, err) - return + return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err} } - if req.QueueApply { + if queueApply { timeoutSec := approvalApplyTaskTimeoutSec(approval.Plugin) task := model.Task{ ID: id.New("task"), @@ -5503,21 +5550,18 @@ func (s *Server) handleApprove(w http.ResponseWriter, r *http.Request, p princip Reason: err.Error(), Metadata: map[string]string{"approval_id": approval.ID}, }) - writeTaskExecutionDisabled(w) - return + return approval, &approvalDecisionError{status: http.StatusConflict, err: apiError(apiErrorTaskExecutionDisabled, errTaskExecutionDisabled.Error()), taskExecutionDisabled: true} } - writeError(w, http.StatusInternalServerError, err) - return + return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err} } if approval.Plugin == "selfdns" { if err := s.markSelfDNSApplying(approval); err != nil { - writeError(w, http.StatusInternalServerError, err) - return + return approval, &approvalDecisionError{status: http.StatusInternalServerError, err: err} } } } s.recordPrincipalAudit(p, model.AuditEvent{ID: id.New("audit"), NodeID: approval.NodeID, Action: "network." + approval.Plugin + ".approve", Scope: approvalDecisionAuditScope(approval), Metadata: map[string]string{"approval_id": approval.ID}}) - writeJSON(w, http.StatusOK, toApprovalView(approval)) + return approval, nil } func (s *Server) handleRejectApproval(w http.ResponseWriter, r *http.Request, p principal) { diff --git a/internal/server/server_agent_update.go b/internal/server/server_agent_update.go index 223b5ee..74f7f65 100644 --- a/internal/server/server_agent_update.go +++ b/internal/server/server_agent_update.go @@ -476,7 +476,8 @@ func (s *Server) createAgentUpdateApproval(nodeID, actorID string, force bool, m CreatedAt: now, UpdatedAt: now, } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { return model.Approval{}, err } policy.LastPlannedVersion = policy.TargetVersion diff --git a/internal/server/server_approval_policy.go b/internal/server/server_approval_policy.go new file mode 100644 index 0000000..27bdef3 --- /dev/null +++ b/internal/server/server_approval_policy.go @@ -0,0 +1,180 @@ +package server + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/id" + "github.com/LatticeNet/lattice-server/internal/rbac" +) + +// approvalPolicyActorPrefix prefixes the synthetic actor identity stamped on +// auto-approved records, so they are distinguishable from human decisions and +// countable for the per-rule daily cap. +const approvalPolicyActorPrefix = "policy:" + +// approvalAutoRule is one operator-configured auto-approve rule. Rules are +// opt-in (empty config = fully manual approvals) and match freshly submitted +// pending approvals; the first matching rule wins. +type approvalAutoRule struct { + // Name is required: it identifies the rule in audit events, logs, and the + // synthetic actor identity used for the daily cap. + Name string `json:"name"` + // Writer exact-matches the approval's creator (ActorID); empty matches any. + Writer string `json:"writer"` + // Plugin exact-matches the approval's plugin; empty matches any. + Plugin string `json:"plugin"` + // ActionPrefix prefix-matches the approval's action; empty matches any. + ActionPrefix string `json:"action_prefix"` + // Queue selects approve-and-queue (true) over approve-only (false). + Queue bool `json:"queue"` + // DailyCap bounds how many approvals this rule may auto-approve per UTC + // day; 0 means no cap. + DailyCap int `json:"daily_cap"` +} + +// parseApprovalAutoRules decodes the operator's JSON rule list. The empty +// string is the disabled default and yields no rules; any malformed input is +// an error so the caller can warn and start with zero rules. +func parseApprovalAutoRules(raw string) ([]approvalAutoRule, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var rules []approvalAutoRule + if err := json.Unmarshal([]byte(raw), &rules); err != nil { + return nil, fmt.Errorf("invalid approval auto rules JSON: %w", err) + } + for i := range rules { + rules[i].Name = strings.TrimSpace(rules[i].Name) + if rules[i].Name == "" { + return nil, fmt.Errorf("approval auto rule #%d is missing its required name", i+1) + } + if rules[i].DailyCap < 0 { + return nil, fmt.Errorf("approval auto rule %q has a negative daily_cap", rules[i].Name) + } + } + return rules, nil +} + +// matches reports whether the rule applies to a freshly submitted approval. +func (r approvalAutoRule) matches(a model.Approval) bool { + if r.Writer != "" && r.Writer != a.ActorID { + return false + } + if r.Plugin != "" && r.Plugin != a.Plugin { + return false + } + if r.ActionPrefix != "" && !strings.HasPrefix(a.Action, r.ActionPrefix) { + return false + } + return true +} + +// submitApproval is the single chokepoint for recording a NEW approval: it +// persists the plan, then lets the opt-in auto-approve policy engine evaluate +// it. It returns the approval as stored after evaluation so HTTP callers +// answer the terminal state (e.g. already auto-approved) instead of the +// pre-evaluation pending one. Status transitions of existing approvals +// (approve/reject/dismiss/apply) keep calling store.UpsertApproval directly — +// policies only ever act on fresh pending submissions. +func (s *Server) submitApproval(a model.Approval) (model.Approval, error) { + if err := s.store.UpsertApproval(a); err != nil { + return model.Approval{}, err + } + return s.evaluateApprovalAutoRules(a), nil +} + +// evaluateApprovalAutoRules runs the first matching auto-approve rule against +// a freshly submitted approval. With no rules configured (the default) it is +// a pure pass-through, so an unconfigured server behaves exactly as before. +func (s *Server) evaluateApprovalAutoRules(a model.Approval) model.Approval { + if a.Status != model.ApprovalPending || len(s.approvalAutoRules) == 0 { + return a + } + for _, rule := range s.approvalAutoRules { + if !rule.matches(a) { + continue + } + // First matching rule wins; later rules never see the approval. + return s.applyApprovalAutoRule(a, rule) + } + return a +} + +// applyApprovalAutoRule auto-approves a fresh pending approval through the +// same decision path as the manual approve endpoint, crediting the decision to +// the rule's synthetic policy identity. Any failure leaves the approval +// pending for a human; a policy engine must never lose a submitted plan. +func (s *Server) applyApprovalAutoRule(a model.Approval, rule approvalAutoRule) model.Approval { + actor := approvalPolicyActorPrefix + rule.Name + if rule.DailyCap > 0 && s.countPolicyApprovalsToday(actor, s.now()) >= rule.DailyCap { + s.recordAudit(model.AuditEvent{ + ID: id.New("audit"), + NodeID: a.NodeID, + ActorID: actor, + Action: "approval.auto_skip", + Scope: approvalDecisionAuditScope(a), + Metadata: map[string]string{"policy": rule.Name, "approval_id": a.ID, "reason": "daily_cap"}, + }) + return a + } + // Bind the decision to the exact stored plan, mirroring the manual + // endpoint's plan_sha256 check: the hash is computed over the plan bytes we + // just persisted, so the auto path can never approve a different plan than + // the one recorded for review. + sum := sha256.Sum256([]byte(a.Plan)) + p := principal{Principal: rbac.Principal{ActorID: actor}} + updated, err := s.approveApprovalCore(p, a, rule.Queue, hex.EncodeToString(sum[:])) + if err != nil { + s.logger.Printf("approval auto-approve policy %q left approval %s pending: %v", rule.Name, a.ID, err) + // The in-memory copy may lag what the decision path persisted (e.g. a + // stale agent-update plan is auto-rejected there); answer the stored row. + if stored, ok := s.store.Approval(a.ID); ok { + return stored + } + return a + } + // Stamp the policy identity as the record's actor so the daily cap can + // count policy-driven approvals without a new store index, and so readers + // can tell an automated decision from a human one. Done after the decision + // path so a failed auto-approve never poisons the cap, and so the original + // writer remains on the creation audit event. + updated.ActorID = actor + if err := s.store.UpsertApproval(updated); err != nil { + s.logger.Printf("approval auto-approve policy %q: restamp actor on %s: %v", rule.Name, a.ID, err) + } + s.recordAudit(model.AuditEvent{ + ID: id.New("audit"), + NodeID: updated.NodeID, + ActorID: actor, + Action: "approval.auto_approve", + Scope: approvalDecisionAuditScope(updated), + Metadata: map[string]string{"policy": rule.Name, "approval_id": updated.ID, "queued": fmt.Sprintf("%t", rule.Queue)}, + }) + return updated +} + +// countPolicyApprovalsToday counts approvals the policy actor already decided +// on the same UTC day as now. It is an O(N) scan over the approvals table; +// acceptable at the current scale of tens of approvals, and worth revisiting +// only if approval volume grows substantially. +func (s *Server) countPolicyApprovalsToday(actor string, now time.Time) int { + year, month, day := now.UTC().Date() + count := 0 + for _, a := range s.store.Approvals() { + if a.ActorID != actor { + continue + } + ay, am, ad := a.CreatedAt.UTC().Date() + if ay == year && am == month && ad == day { + count++ + } + } + return count +} diff --git a/internal/server/server_approval_policy_test.go b/internal/server/server_approval_policy_test.go new file mode 100644 index 0000000..f5a2977 --- /dev/null +++ b/internal/server/server_approval_policy_test.go @@ -0,0 +1,327 @@ +package server + +import ( + "io" + "log" + "strings" + "testing" + "time" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/store" +) + +func TestParseApprovalAutoRules(t *testing.T) { + tests := []struct { + name string + raw string + wantLen int + wantErr bool + }{ + {name: "empty disables", raw: "", wantLen: 0}, + {name: "whitespace disables", raw: " \n\t ", wantLen: 0}, + { + name: "valid rule list", + raw: `[{"name":"linemeta-fleet","writer":"lattice-server","plugin":"singbox-linemeta","action_prefix":"apply-metadata","queue":true,"daily_cap":100}]`, + wantLen: 1, + }, + {name: "empty array is valid and disabled", raw: `[]`, wantLen: 0}, + {name: "invalid JSON", raw: `[{"name":`, wantErr: true}, + {name: "rule without name is rejected", raw: `[{"plugin":"nft"}]`, wantErr: true}, + {name: "whitespace-only name is rejected", raw: `[{"name":" "}]`, wantErr: true}, + {name: "negative daily cap is rejected", raw: `[{"name":"x","daily_cap":-1}]`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rules, err := parseApprovalAutoRules(tt.raw) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q, got rules %+v", tt.raw, rules) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for %q: %v", tt.raw, err) + } + if len(rules) != tt.wantLen { + t.Fatalf("got %d rules, want %d", len(rules), tt.wantLen) + } + }) + } +} + +// TestNewServerIgnoresInvalidApprovalAutoRules pins the never-fail-startup +// contract: malformed policy config logs a warning and leaves the server with +// zero rules (fully manual approvals). +func TestNewServerIgnoresInvalidApprovalAutoRules(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{ + Store: st, + AdminPassword: testAdminPass, + ApprovalAutoRules: `[{"plugin":"nft"}]`, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New must not fail on invalid approval rules: %v", err) + } + if len(srv.approvalAutoRules) != 0 { + t.Fatalf("expected zero rules after invalid config, got %+v", srv.approvalAutoRules) + } +} + +func TestApprovalAutoRuleMatches(t *testing.T) { + rule := approvalAutoRule{Name: "r", Writer: "lattice-server", Plugin: "singbox-linemeta", ActionPrefix: "apply-metadata"} + tests := []struct { + name string + rule approvalAutoRule + approval model.Approval + want bool + }{ + {name: "exact match", rule: rule, approval: model.Approval{ActorID: "lattice-server", Plugin: "singbox-linemeta", Action: "apply-metadata:abc"}, want: true}, + {name: "action prefix matches parameterized action", rule: rule, approval: model.Approval{ActorID: "lattice-server", Plugin: "singbox-linemeta", Action: "apply-metadata:0123456789abcdef"}, want: true}, + {name: "different writer rejected", rule: rule, approval: model.Approval{ActorID: "user-admin", Plugin: "singbox-linemeta", Action: "apply-metadata:abc"}, want: false}, + {name: "different plugin rejected", rule: rule, approval: model.Approval{ActorID: "lattice-server", Plugin: "nft", Action: "apply-metadata:abc"}, want: false}, + {name: "different action rejected", rule: rule, approval: model.Approval{ActorID: "lattice-server", Plugin: "singbox-linemeta", Action: "delete-metadata:abc"}, want: false}, + {name: "empty writer matches any", rule: approvalAutoRule{Name: "r", Plugin: "nft"}, approval: model.Approval{ActorID: "user-admin", Plugin: "nft", Action: "apply-ruleset"}, want: true}, + {name: "empty prefix matches any action", rule: approvalAutoRule{Name: "r", Plugin: "nft"}, approval: model.Approval{ActorID: "user-admin", Plugin: "nft", Action: "apply-ruleset"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.rule.matches(tt.approval); got != tt.want { + t.Fatalf("matches() = %v, want %v", got, tt.want) + } + }) + } +} + +func newTestServerWithApprovalRules(t *testing.T, rulesJSON string) (*Server, *store.Store) { + t.Helper() + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{ + Store: st, + AdminPassword: testAdminPass, + ApprovalAutoRules: rulesJSON, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatal(err) + } + return srv, st +} + +func pendingTestApproval(id, actor, plugin, action string) model.Approval { + return model.Approval{ + ID: id, + NodeID: "node-1", + Plugin: plugin, + Action: action, + Plan: "table inet lattice_guard {}", + Status: model.ApprovalPending, + ActorID: actor, + CreatedAt: time.Now().UTC(), + } +} + +func auditActions(st *store.Store) []string { + out := []string{} + for _, ev := range st.AuditEvents() { + out = append(out, ev.Action) + } + return out +} + +func TestApprovalAutoApproveDefaultOff(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, "") + stored, err := srv.submitApproval(pendingTestApproval("ap-1", "lattice-server", "nft", "apply-ruleset")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalPending { + t.Fatalf("no rules configured: approval must stay pending, got %q", stored.Status) + } + for _, action := range auditActions(st) { + if strings.HasPrefix(action, "approval.auto_") { + t.Fatalf("no rules configured: unexpected auto audit event %q", action) + } + } +} + +// TestApprovalAutoApproveNeverTouchesUserSubmissions pins the trust boundary: +// a plan submitted by an interactive user must stay manual even when a rule +// covers its plugin and action. +func TestApprovalAutoApproveNeverTouchesUserSubmissions(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, + `[{"name":"fleet-nft","writer":"lattice-server","plugin":"nft","action_prefix":"apply-ruleset","queue":true}]`) + stored, err := srv.submitApproval(pendingTestApproval("ap-user", "user-admin", "nft", "apply-ruleset")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalPending { + t.Fatalf("user-submitted plan must stay pending, got %q", stored.Status) + } + if tasks := st.Tasks(); len(tasks) != 0 { + t.Fatalf("user-submitted plan must not queue tasks, got %d", len(tasks)) + } + for _, action := range auditActions(st) { + if strings.HasPrefix(action, "approval.auto_") { + t.Fatalf("unexpected auto audit event %q", action) + } + } +} + +func TestApprovalAutoApproveAndQueue(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, + `[{"name":"linemeta-fleet","writer":"lattice-server","plugin":"singbox-linemeta","action_prefix":"apply-metadata","queue":true,"daily_cap":100}]`) + stored, err := srv.submitApproval(pendingTestApproval("ap-auto", "lattice-server", "singbox-linemeta", "apply-metadata:0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalApproved { + t.Fatalf("expected approved, got %q", stored.Status) + } + if stored.ActorID != "policy:linemeta-fleet" { + t.Fatalf("expected policy actor identity on the record, got %q", stored.ActorID) + } + if stored.ApprovedBy != "policy:linemeta-fleet" { + t.Fatalf("expected policy approver identity, got %q", stored.ApprovedBy) + } + persisted, ok := st.Approval("ap-auto") + if !ok || persisted.Status != model.ApprovalApproved || persisted.ActorID != "policy:linemeta-fleet" { + t.Fatalf("stored row mismatch: %+v", persisted) + } + tasks := st.Tasks() + if len(tasks) != 1 { + t.Fatalf("expected one queued apply task, got %d", len(tasks)) + } + if tasks[0].ApprovalID != "ap-auto" || tasks[0].Status != model.TaskQueued { + t.Fatalf("unexpected task: %+v", tasks[0]) + } + found := false + for _, ev := range st.AuditEvents() { + if ev.Action != "approval.auto_approve" { + continue + } + found = true + if ev.Metadata["policy"] != "linemeta-fleet" || ev.Metadata["approval_id"] != "ap-auto" || ev.Metadata["queued"] != "true" { + t.Fatalf("unexpected auto_approve metadata: %+v", ev.Metadata) + } + } + if !found { + t.Fatal("expected an approval.auto_approve audit event") + } +} + +func TestApprovalAutoApproveOnlyDoesNotQueue(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, + `[{"name":"linemeta-fleet","writer":"lattice-server","plugin":"singbox-linemeta","action_prefix":"apply-metadata","queue":false}]`) + stored, err := srv.submitApproval(pendingTestApproval("ap-only", "lattice-server", "singbox-linemeta", "apply-metadata:abc")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalApproved { + t.Fatalf("expected approved, got %q", stored.Status) + } + if tasks := st.Tasks(); len(tasks) != 0 { + t.Fatalf("approve-only rule must not queue tasks, got %d", len(tasks)) + } + for _, ev := range st.AuditEvents() { + if ev.Action == "approval.auto_approve" && ev.Metadata["queued"] != "false" { + t.Fatalf("expected queued=false in audit metadata, got %+v", ev.Metadata) + } + } +} + +func TestApprovalAutoApproveFirstMatchWins(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, + `[{"name":"first","writer":"lattice-server","plugin":"singbox-linemeta","queue":false},`+ + `{"name":"second","writer":"lattice-server","plugin":"singbox-linemeta","queue":true}]`) + stored, err := srv.submitApproval(pendingTestApproval("ap-prec", "lattice-server", "singbox-linemeta", "apply-metadata:abc")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalApproved { + t.Fatalf("expected approved, got %q", stored.Status) + } + if stored.ActorID != "policy:first" { + t.Fatalf("first matching rule must win, got actor %q", stored.ActorID) + } + if tasks := st.Tasks(); len(tasks) != 0 { + t.Fatalf("first rule is approve-only; expected no tasks, got %d", len(tasks)) + } +} + +func TestApprovalAutoApproveDailyCap(t *testing.T) { + srv, st := newTestServerWithApprovalRules(t, + `[{"name":"capped","writer":"lattice-server","plugin":"singbox-linemeta","queue":false,"daily_cap":1}]`) + // A decision from a previous UTC day must not consume today's budget. + if err := st.UpsertApproval(model.Approval{ + ID: "ap-old", + NodeID: "node-1", + Plugin: "singbox-linemeta", + Action: "apply-metadata:old", + Status: model.ApprovalApproved, + ActorID: "policy:capped", + CreatedAt: time.Now().UTC().Add(-25 * time.Hour), + }); err != nil { + t.Fatal(err) + } + first, err := srv.submitApproval(pendingTestApproval("ap-cap-1", "lattice-server", "singbox-linemeta", "apply-metadata:1")) + if err != nil { + t.Fatal(err) + } + if first.Status != model.ApprovalApproved { + t.Fatalf("first approval within cap must be approved, got %q", first.Status) + } + second, err := srv.submitApproval(pendingTestApproval("ap-cap-2", "lattice-server", "singbox-linemeta", "apply-metadata:2")) + if err != nil { + t.Fatal(err) + } + if second.Status != model.ApprovalPending { + t.Fatalf("second approval beyond cap must stay pending, got %q", second.Status) + } + skips := 0 + for _, ev := range st.AuditEvents() { + if ev.Action == "approval.auto_skip" { + skips++ + if ev.Metadata["policy"] != "capped" || ev.Metadata["reason"] != "daily_cap" { + t.Fatalf("unexpected auto_skip metadata: %+v", ev.Metadata) + } + } + } + if skips != 1 { + t.Fatalf("expected exactly one approval.auto_skip event, got %d", skips) + } +} + +// TestApprovalAutoApproveLeavesPendingOnDecisionFailure ensures a policy whose +// decision path fails (here: the fleet kill switch blocks queueing) leaves the +// approval pending for a human instead of failing the submission. +func TestApprovalAutoApproveLeavesPendingOnDecisionFailure(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv, err := New(Options{ + Store: st, + AdminPassword: testAdminPass, + ApprovalAutoRules: `[{"name":"q","writer":"lattice-server","plugin":"nft","queue":true}]`, + TaskExecutionDisabled: true, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatal(err) + } + stored, err := srv.submitApproval(pendingTestApproval("ap-kill", "lattice-server", "nft", "apply-ruleset")) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.ApprovalPending { + t.Fatalf("kill switch on: queueing fails, approval must stay pending, got %q", stored.Status) + } + if stored.ActorID != "lattice-server" { + t.Fatalf("failed auto-approve must not restamp the actor, got %q", stored.ActorID) + } +} diff --git a/internal/server/server_approval_reason.go b/internal/server/server_approval_reason.go new file mode 100644 index 0000000..a0b43d1 --- /dev/null +++ b/internal/server/server_approval_reason.go @@ -0,0 +1,132 @@ +package server + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/LatticeNet/lattice-sdk/model" +) + +// approvalDisplayReason returns the human-readable reason shown for an +// approval. Approvals created before reasons existed (and system writers that +// never set one) store an empty Reason. Rather than migrating stored rows, the +// reason is derived at read time from the same plan the reviewer is shown, so +// stored data stays untouched and every reader — old rows included — sees a +// consistent sentence. A non-empty stored Reason always wins. +func approvalDisplayReason(a model.Approval) string { + if strings.TrimSpace(a.Reason) != "" { + return a.Reason + } + switch { + case a.Plugin == agentUpdatePlugin && approvalActionIs(a.Action, agentUpdateAction): + return agentUpdateDisplayReason(a.Plan) + case a.Plugin == "singbox-linemeta" && approvalActionIs(a.Action, "apply-metadata"): + return lineMetaDisplayReason(a.Plan) + case a.Plugin == "nft" && a.Action == "apply-ruleset": + return "Apply nftables ruleset" + case a.Plugin == "nftpolicy" && approvalActionIs(a.Action, nftPolicyApplyAction): + return "Apply network policy ruleset" + case a.Plugin == "selfdns" && approvalActionIs(a.Action, selfDNSApplyAction): + return selfDNSDisplayReason(a.Plan) + case a.Plugin == proxyCorePlugin && approvalActionIs(a.Action, proxyCoreApplyAction): + return proxyCoreDisplayReason(a.Plan) + case a.Plugin == "cftunnel" && a.Action == "apply-config": + return "Apply Cloudflare Tunnel config" + case a.Plugin == "wireguard" && a.Action == "apply-config": + return "Apply WireGuard mesh config" + default: + return approvalFallbackReason(a) + } +} + +// approvalActionIs matches an approval action against a bare action name or +// its parameterized ":" form (e.g. "apply-metadata:"). +func approvalActionIs(action, name string) bool { + return action == name || strings.HasPrefix(action, name+":") +} + +// agentUpdateDisplayReason summarizes the YAML-ish agent update plan header +// (current_version:/target_version:/node_name: lines written by +// renderAgentUpdatePlan). +func agentUpdateDisplayReason(plan string) string { + current := approvalPlanField(plan, "current_version") + target := approvalPlanField(plan, "target_version") + if current == "" || target == "" { + return "Node agent upgrade" + } + if name := approvalPlanField(plan, "node_name"); name != "" { + return fmt.Sprintf("Node agent upgrade %s -> %s (%s)", current, target, name) + } + return fmt.Sprintf("Node agent upgrade %s -> %s", current, target) +} + +// lineMetaDisplayReason summarizes a singbox-linemeta plan (JSON, schema +// lattice.singbox-metadata.v2) by counting the inbounds it carries. +func lineMetaDisplayReason(plan string) string { + var parsed struct { + Inbounds []json.RawMessage `json:"inbounds"` + } + if err := json.Unmarshal([]byte(plan), &parsed); err != nil { + // The reviewer still sees the raw plan; a malformed one just gets the + // generic sentence instead of an inbound count. + return "Line identity metadata sync" + } + return fmt.Sprintf("Line identity metadata sync (%d inbounds)", len(parsed.Inbounds)) +} + +// selfDNSDisplayReason names the DNS deployment from the plan header when it +// is present. +func selfDNSDisplayReason(plan string) string { + if name := approvalPlanField(plan, "name"); name != "" { + return fmt.Sprintf("Apply self-hosted DNS %q", name) + } + return "Apply self-hosted DNS plan" +} + +// proxyCoreDisplayReason summarizes the rendered proxycore plan header +// (core:/inbound_count: lines written by renderProxyCoreApprovalPlan). +func proxyCoreDisplayReason(plan string) string { + core := approvalPlanField(plan, "core") + if core == "" { + return "Apply proxy core config" + } + if n, err := strconv.Atoi(approvalPlanField(plan, "inbound_count")); err == nil { + return fmt.Sprintf("Apply %s proxy config (%d inbounds)", core, n) + } + return fmt.Sprintf("Apply %s proxy config", core) +} + +// approvalPlanField reads "key: value" lines from a human-reviewable plan +// header. Plans are display text, not a schema, so parsing is deliberately +// best-effort: the first matching line wins and anything unexpected yields "". +func approvalPlanField(plan, key string) string { + for _, line := range strings.Split(plan, "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok || k != key { + continue + } + return strings.TrimSpace(v) + } + return "" +} + +// approvalFallbackReason title-cases " " for plugins that +// have no dedicated sentence yet, so the API still answers something readable +// instead of an empty string. +func approvalFallbackReason(a model.Approval) string { + action := a.Action + if i := strings.Index(action, ":"); i >= 0 { + action = action[:i] + } + text := strings.TrimSpace(strings.TrimSpace(a.Plugin) + " " + strings.TrimSpace(action)) + if text == "" { + return "" + } + words := strings.Fields(text) + for i, w := range words { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + return strings.Join(words, " ") +} diff --git a/internal/server/server_approval_reason_test.go b/internal/server/server_approval_reason_test.go new file mode 100644 index 0000000..ffb1176 --- /dev/null +++ b/internal/server/server_approval_reason_test.go @@ -0,0 +1,179 @@ +package server + +import ( + "testing" + + "github.com/LatticeNet/lattice-sdk/model" +) + +func TestApprovalDisplayReason(t *testing.T) { + agentPlan := "plugin: agentupdate\n" + + "mode: auto\n" + + "node_id: node-1\n" + + "node_name: edge-1\n" + + "current_version: 0.3.0\n" + + "target_version: 0.3.3\n" + + "binary_url: https://example.com/lattice-agent\n" + + "sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n" + + "install_path: /opt/lattice/node-agent/lattice-agent\n" + + "service_name: lattice-agent.service\n" + + "\nSafety:\n" + + "- download is HTTPS-only and verified against the pinned SHA-256 digest\n" + + lineMetaPlan := `{"schema":"lattice.singbox-metadata.v2","node_id":"node-1","inbounds":[{"tag":"a"},{"tag":"b"},{"tag":"c"}]}` + + proxyPlan := "# Lattice proxycore review plan\n\n" + + "node_id: node-1\n" + + "profile_id: prof-1\n" + + "core: sing-box\n" + + "config_path: /etc/sing-box/config.json\n" + + "artifact_sha256: abc123\n" + + "inbound_count: 4\n" + + dnsPlan := "# Lattice Self-host DNS plan\n\n" + + "deployment_id: dep-1\n" + + "name: Internal DNS\n" + + "node_id: node-1\n" + + "engine: coredns\n" + + "exposure: lan\n" + + tests := []struct { + name string + approval model.Approval + want string + }{ + { + name: "stored reason always wins", + approval: model.Approval{Plugin: "nft", Action: "apply-ruleset", Plan: "x", Reason: "rejected: task failed"}, + want: "rejected: task failed", + }, + { + name: "agent update with versions and node name", + approval: model.Approval{Plugin: "agentupdate", Action: "update-agent:eyJub2RlX2lkIjoibm9kZS0xIn0", Plan: agentPlan}, + want: "Node agent upgrade 0.3.0 -> 0.3.3 (edge-1)", + }, + { + name: "agent update without node name", + approval: model.Approval{Plugin: "agentupdate", Action: "update-agent:abc", Plan: "plugin: agentupdate\n" + + "node_id: node-1\n" + + "current_version: 0.3.0\n" + + "target_version: 0.3.3\n"}, + want: "Node agent upgrade 0.3.0 -> 0.3.3", + }, + { + name: "agent update with unparseable plan falls back to the generic sentence", + approval: model.Approval{Plugin: "agentupdate", Action: "update-agent:abc", Plan: "not a plan"}, + want: "Node agent upgrade", + }, + { + name: "linemeta counts inbounds", + approval: model.Approval{Plugin: "singbox-linemeta", Action: "apply-metadata:0123456789abcdef", Plan: lineMetaPlan}, + want: "Line identity metadata sync (3 inbounds)", + }, + { + name: "linemeta with malformed JSON must not error, falls back", + approval: model.Approval{Plugin: "singbox-linemeta", Action: "apply-metadata:abc", Plan: "{not json"}, + want: "Line identity metadata sync", + }, + { + name: "linemeta with empty inbounds", + approval: model.Approval{Plugin: "singbox-linemeta", Action: "apply-metadata:abc", Plan: `{"schema":"lattice.singbox-metadata.v2"}`}, + want: "Line identity metadata sync (0 inbounds)", + }, + { + name: "nft ruleset", + approval: model.Approval{Plugin: "nft", Action: "apply-ruleset", Plan: "table inet lattice_guard {}"}, + want: "Apply nftables ruleset", + }, + { + name: "nft ruleset from netguard shares the sentence", + approval: model.Approval{Plugin: "nft", Action: "apply-ruleset", Plan: "table inet lattice_guard {}", ActorID: "user-1"}, + want: "Apply nftables ruleset", + }, + { + name: "nftpolicy bare action", + approval: model.Approval{Plugin: "nftpolicy", Action: "apply-ruleset", Plan: "ruleset"}, + want: "Apply network policy ruleset", + }, + { + name: "nftpolicy parameterized action", + approval: model.Approval{Plugin: "nftpolicy", Action: "apply-ruleset:eyJwdWJsaWNfdXJsIjoieCJ9", Plan: "ruleset"}, + want: "Apply network policy ruleset", + }, + { + name: "selfdns names the deployment", + approval: model.Approval{Plugin: "selfdns", Action: "apply-config:ZGVwLTE", Plan: dnsPlan}, + want: `Apply self-hosted DNS "Internal DNS"`, + }, + { + name: "selfdns without a name line", + approval: model.Approval{Plugin: "selfdns", Action: "apply-config:ZGVwLTE", Plan: "# Lattice Self-host DNS plan\n"}, + want: "Apply self-hosted DNS plan", + }, + { + name: "proxycore with core and inbound count", + approval: model.Approval{Plugin: "proxycore", Action: "apply-config:abc123", Plan: proxyPlan}, + want: "Apply sing-box proxy config (4 inbounds)", + }, + { + name: "proxycore without parseable header", + approval: model.Approval{Plugin: "proxycore", Action: "apply-config:abc123", Plan: "??"}, + want: "Apply proxy core config", + }, + { + name: "cftunnel", + approval: model.Approval{Plugin: "cftunnel", Action: "apply-config", Plan: "tunnel: x"}, + want: "Apply Cloudflare Tunnel config", + }, + { + name: "wireguard", + approval: model.Approval{Plugin: "wireguard", Action: "apply-config", Plan: "[Interface]"}, + want: "Apply WireGuard mesh config", + }, + { + name: "unknown plugin falls back to title-cased plugin and action name", + approval: model.Approval{Plugin: "acme-dns", Action: "publish-zone:abc", Plan: "x"}, + want: "Acme-dns Publish-zone", + }, + { + name: "empty writer empty plan still falls back", + approval: model.Approval{Plugin: "nft", Action: "apply-ruleset"}, + want: "Apply nftables ruleset", + }, + { + name: "whitespace-only stored reason is treated as empty", + approval: model.Approval{Plugin: "nft", Action: "apply-ruleset", Reason: " "}, + want: "Apply nftables ruleset", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := approvalDisplayReason(tt.approval); got != tt.want { + t.Fatalf("approvalDisplayReason() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestToApprovalViewPopulatesReason pins the read-path contract: the API +// reason field is populated for legacy rows whose stored Reason is empty, +// while stored rows themselves are never rewritten. +func TestToApprovalViewPopulatesReason(t *testing.T) { + a := model.Approval{ + ID: "ap-1", + Plugin: "nft", + Action: "apply-ruleset", + Plan: "table inet lattice_guard {}", + Status: model.ApprovalPending, + } + views := toApprovalViews([]model.Approval{a}) + if len(views) != 1 { + t.Fatalf("expected one view, got %d", len(views)) + } + if views[0].Reason != "Apply nftables ruleset" { + t.Fatalf("view reason = %q", views[0].Reason) + } + if a.Reason != "" { + t.Fatalf("derivation must not mutate the stored approval, reason = %q", a.Reason) + } +} diff --git a/internal/server/server_dns.go b/internal/server/server_dns.go index d3bea25..b9dee02 100644 --- a/internal/server/server_dns.go +++ b/internal/server/server_dns.go @@ -243,7 +243,8 @@ func (s *Server) handleDNSPlan(w http.ResponseWriter, r *http.Request, p princip ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } diff --git a/internal/server/server_group_policy.go b/internal/server/server_group_policy.go index 02f7839..e7c0927 100644 --- a/internal/server/server_group_policy.go +++ b/internal/server/server_group_policy.go @@ -261,7 +261,8 @@ func (s *Server) handleGroupPolicyPlan(w http.ResponseWriter, r *http.Request, p ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } diff --git a/internal/server/server_netguard.go b/internal/server/server_netguard.go index ecf8915..2e10c3d 100644 --- a/internal/server/server_netguard.go +++ b/internal/server/server_netguard.go @@ -608,7 +608,8 @@ func (s *Server) handleNetGuardPlan(w http.ResponseWriter, r *http.Request, p pr ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } diff --git a/internal/server/server_netpolicy.go b/internal/server/server_netpolicy.go index aed74bc..45389bd 100644 --- a/internal/server/server_netpolicy.go +++ b/internal/server/server_netpolicy.go @@ -151,7 +151,8 @@ func (s *Server) handleNetPolicyPlan(w http.ResponseWriter, r *http.Request, p p ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } diff --git a/internal/server/server_proxy.go b/internal/server/server_proxy.go index d060dc6..7e90249 100644 --- a/internal/server/server_proxy.go +++ b/internal/server/server_proxy.go @@ -777,7 +777,8 @@ func (s *Server) handleProxyNodePlan(w http.ResponseWriter, r *http.Request, p p ActorID: p.ActorID, CreatedAt: time.Now().UTC(), } - if err := s.store.UpsertApproval(approval); err != nil { + approval, err = s.submitApproval(approval) + if err != nil { writeError(w, http.StatusInternalServerError, err) return } diff --git a/internal/server/server_views.go b/internal/server/server_views.go index 47b920f..90a9222 100644 --- a/internal/server/server_views.go +++ b/internal/server/server_views.go @@ -45,7 +45,9 @@ func toApprovalView(a model.Approval) approvalView { stale, staleCode := approvalStaleMetadata(a) return approvalView{ ID: a.ID, NodeID: a.NodeID, Plugin: a.Plugin, Action: action, - Plan: a.Plan, Status: a.Status, Reason: a.Reason, Stale: stale, StaleCode: staleCode, ActorID: a.ActorID, + // Reason is derived at read time (never migrated into stored rows) so + // pre-reason approvals also answer a human-readable sentence. + Plan: a.Plan, Status: a.Status, Reason: approvalDisplayReason(a), Stale: stale, StaleCode: staleCode, ActorID: a.ActorID, ApprovedBy: a.ApprovedBy, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, } }