carlos / platform Public

console: name the changed fields in the instance-update audit line

Review finding: "updated instance <host> (cli)" was field-agnostic to a
fault — on a security surface, "did anyone turn on steering for this host
last week?" needs to stay answerable from the audit log, and a collapsed
detail string couldn't answer it. Build the changed-fields list from the
same req.Addrs/req.Probe/req.Steering nil checks the mutation already
made, so the audit line can never drift from what was actually applied:
"updated instance <host> (addrs, steering) (cli)" etc.

Also fixes a stale doc-comment quote in instances_cli.go that still named
the pre-Task-3 two-field refusal text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 35ce2758d71bbaa9435ac8600a756f15bb7e61af parent 02222c5
3 files changed, +80 −5
  • instances_cli.go +3 −2
  • internal/console/apiinstances.go +19 −3
  • internal/console/apiinstances_test.go +58 −0
diff --git a/instances_cli.go b/instances_cli.go
index 7882c100f..951f87137 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -430,8 +430,9 @@ func runInstancesDelete(args []string) {
// spec §9).
//
// Neither -addrs nor a -health-* flag is required by this parser: the
-// console's own refusal ("nothing to update — send addrs, probe, or both")
-// is left to fire when a caller gives neither, so that refusal's wording
+// console's own refusal ("nothing to update — send addrs, probe, steering,
+// or a combination") is left to fire when a caller gives neither, so that
+// refusal's wording
// reaches the operator verbatim rather than being paraphrased here — the
// self-review rule this task carries (ValidateShape's own sentences are
// operator guidance, not something to summarize).
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 637b9e586..8139c4452 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -827,9 +827,25 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
return
}
// "upstreams" no longer names the whole of what this door can change
- // (steering joined it in Task 3), so the audit line stays field-agnostic
- // rather than claiming a change this particular patch may not have made.
- s.audit(acct, app, principal, "instance-update", "updated instance "+host+" (cli)")
+ // (steering joined it in Task 3), so the audit line names exactly the
+ // fields THIS request carried rather than either overclaiming ("upstreams"
+ // on a steering-only patch) or going field-agnostic ("updated instance
+ // <host> (cli)" alone) — this is a security surface, and "did anyone turn
+ // on steering for this host last week?" has to stay answerable from the
+ // audit log. Built from the same req.* nil checks the mutation above
+ // already made, so it can never drift from what was actually applied.
+ var changed []string
+ if req.Addrs != nil {
+ changed = append(changed, "addrs")
+ }
+ if req.Probe != nil {
+ changed = append(changed, "probe")
+ }
+ if req.Steering != nil {
+ changed = append(changed, "steering")
+ }
+ s.audit(acct, app, principal, "instance-update",
+ "updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)")
// Strictly after the write landed, and exactly once: the record is what a
// box's reconcile leg converges upstreams from (provision.convergeOwned),
// so this announcement is what makes the new set live in seconds instead
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index e99be750d..8da4b3b74 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -1406,6 +1406,64 @@ func TestAPIInstanceUpdateSteeringRoundTrip(t *testing.T) {
}
}
+// lastEventDetail returns the most recent instance-update audit row's
+// Detail — the field this test cares about — or fails the test if none
+// exists. Events is newest-first (the activity tab's own assumption, and
+// every other audit test in this file's).
+func lastEventDetail(t *testing.T, fx *consoleFixture, app string) string {
+ t.Helper()
+ events, err := fx.st.Events(OpsAccountID, app, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range events {
+ if e.Action == "instance-update" {
+ return e.Detail
+ }
+ }
+ t.Fatalf("events = %v, want an instance-update row", events)
+ return ""
+}
+
+// TestAPIInstanceUpdateAuditNamesChangedFields: review finding (Task 3
+// follow-up) — a field-agnostic "updated instance <host> (cli)" audit line
+// collapses three distinct member-facing changes (addrs/probe/steering)
+// into one indistinguishable string on a security surface: "did anyone turn
+// on steering for this host last week?" must stay answerable from the audit
+// log alone. The detail names exactly the fields THIS request carried, built
+// from the same req.* nil checks the mutation itself uses, so it can never
+// claim a change the request didn't make (or omit one it did).
+func TestAPIInstanceUpdateAuditNamesChangedFields(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-audit-fields")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"audit.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ pu := updateURL(fx, "bab", "titogo", "audit.go.tito.io")
+
+ // Single-field case: a steering-only patch names ONLY steering — not
+ // "upstreams", not addrs, not probe.
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":"latency"}`); status != http.StatusOK {
+ t.Fatalf("patch steering = %d %s, want 200", status, out)
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance audit.go.tito.io (steering) (cli)" {
+ t.Fatalf("audit detail = %q, want it to name exactly steering", detail)
+ }
+
+ // Multi-field case: a patch touching addrs AND steering together names
+ // both, in the order the request struct declares them (addrs, probe,
+ // steering) — not just one of the two, and not a generic "updated".
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token,
+ `{"addrs":["127.0.0.1:7002"],"steering":""}`); status != http.StatusOK {
+ t.Fatalf("patch addrs+steering = %d %s, want 200", status, out)
+ }
+ if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance audit.go.tito.io (addrs, steering) (cli)" {
+ t.Fatalf("audit detail = %q, want it to name both addrs and steering", detail)
+ }
+}
+
// TestAPIInstanceUpdateSteeringInvalidValue: the door's own vocabulary rail
// (provision.ValidateShape -> ValidSteering), surfaced as a 400 with the
// door's own sentence — a caller sending anything but "" or "latency" is