carlos / platform Public

console: pin the system gate's refusals on the upload paths and through a store fault (#176 review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Summary

From the Opus review of b871f959 (no HIGH or MEDIUM). Unlisted callers get the bare 404, not the owner-only 403, on PUT system/upload and on an update naming an upload handle. A Role() failure admits nobody: an unlisted owner gets 404, and a listed operator still gets in as an operator. Two comment fixes: the ops account cannot be deleted, and the file header now says when a 403 is possible.
Agent notes
The store fault is simulated by renaming the memberships table after the tokens are minted. Mutating the gate to admit on a store error fails TestSystemRollerStoreErrorAdmitsNobody.
claude agent, operated by paul@keymail.dev ad3620b4c56a7d4687ceeba42745d5b201c06f20 parent b871f95
3 files changed, +42 −4
  • internal/console/apisystemoperators_test.go +37 −0
  • internal/console/apisystemupdate.go +3 −2
  • internal/console/server.go +2 −2

CI passed — run details

diff --git a/internal/console/apisystemoperators_test.go b/internal/console/apisystemoperators_test.go
index f36b3c1f1..5215a2b7f 100644
--- a/internal/console/apisystemoperators_test.go
+++ b/internal/console/apisystemoperators_test.go
@@ -147,9 +147,46 @@ func TestSystemOperatorListGatesWhoGetsIn(t *testing.T) {
if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill); st != http.StatusNotFound || body != want {
t.Errorf("rollback with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want)
}
+ // The owner-only 403 is for callers the gate has ADMITTED. Off the
+ // list, the two upload paths must answer the same 404 as every other
+ // door, or the 403's sentence confirms the surface exists.
+ if st, _ := uploadBuild(t, fx, bill); st != http.StatusNotFound {
+ t.Errorf("upload with SystemOperators=%q = %d, want 404", list, st)
+ }
+ if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/update", map[string]string{"channel": "canary", "upload": "deadbeef"}, bill); st != http.StatusNotFound || body != want {
+ t.Errorf("update naming an upload with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want)
+ }
}
fx.srv.cfg.SystemOperators = []string{" Bill@Keymail.dev "}
if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK {
t.Fatalf("status with a padded, mixed-case entry = %d %v, want 200", st, out)
}
}
+
+// TestSystemRollerStoreErrorAdmitsNobody: a failed ownership lookup is
+// logged and the list is still consulted, so a listed operator keeps working
+// through a store fault. The fault itself must never admit anyone: an owner
+// the console cannot see is refused like a stranger, and the listed operator
+// gets in as an operator, never as an owner.
+func TestSystemRollerStoreErrorAdmitsNobody(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"}
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-err-owner")
+ bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-err-bill")
+ _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", owner)
+ if _, err := fx.st.db.Exec(`ALTER TABLE memberships RENAME TO memberships_gone`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.Role(OpsAccountID, "paul@keymail.dev"); err == nil {
+ t.Fatal("Role still answers; the fault this test needs did not happen")
+ }
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusNotFound || body != want {
+ t.Errorf("status as an unlisted owner during a store fault = %d %q, want 404 %q", st, body, want)
+ }
+ if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK {
+ t.Errorf("status as a listed operator during a store fault = %d %v, want 200", st, out)
+ }
+ if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden {
+ t.Errorf("upload as a listed operator during a store fault = %d, want 403 (an operator, not an owner)", st)
+ }
+}
diff --git a/internal/console/apisystemupdate.go b/internal/console/apisystemupdate.go
index 161a60882..9640e7655 100644
--- a/internal/console/apisystemupdate.go
+++ b/internal/console/apisystemupdate.go
@@ -25,8 +25,9 @@ import (
// The console half of `carlos system` (spec §5). Every door here is for an
// OWNER of the ops account or an address on CARLOS_SYSTEM_OPERATORS, on a
// PERSON session, checked on every call (apiSystemRoller); uploading a local
-// build is the owner's alone. Refusal is a bare 404 like the operator doors,
-// so anyone else learns nothing. The console holds the bucket, the signing key and the egress;
+// build is the owner's alone, and an admitted operator who asks for it gets a
+// 403 (systemUploadOwnerOnly). A caller the gate refuses gets a bare 404 like
+// the operator doors, so anyone else learns nothing. The console holds the bucket, the signing key and the egress;
// the operator's laptop holds none of them.
const (
diff --git a/internal/console/server.go b/internal/console/server.go
index e666e1452..4da840494 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -704,8 +704,8 @@ type Config struct {
// status`, `update` from the signed release feed, its job, and
// `rollback`. Owners of the ops account have all of that already; this
// list is for a person who should roll releases without owning the
- // account, which would also hand them member removal, ownership transfer
- // and account deletion. Its own list rather than StoreOperators',
+ // account, which would also hand them member removal and ownership
+ // transfer. Its own list rather than StoreOperators',
// because that one reads tenant bytes through grants and scanning, and
// rolling releases should not.
//