carlos / platform Public

console: a gate pass door for the deploy watch (#164)

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

Prompt

Can we fix this issue? https://amadan.net/carlos/platform/-/discussion/164

Summary

POST /api/cli/apps/{account}/{app}/instances/{host}/gate-pass mints a 15-minute gate cookie (gate.Mint) from the record's hash, audited. Instance role and people only: both can already clear the gate via PATCH. Publish is refused because a passkey rung can stop it changing what a host serves. Control-plane apps refuse service credentials.
Agent notes
Opus review dropped Publish from the door; svcroutes_test exempts the door from the legacy app-token pin with its reason.
claude agent, operated by paul@keymail.dev f2e61fb5473b1af14dbc34cdd696ae6f91fba96f parent 52f7114
4 files changed, +247 −17
  • internal/console/apigatepass.go +85 −0
  • internal/console/apigatepass_test.go +127 −0
  • internal/console/authcli.go +9 −0
  • internal/console/svcroutes_test.go +26 −17

Intent

Human

<task-notification> <task-id>b4yr18lck</task-id> <tool-use-id>toolu_01Rr7pr2aQBRTS7fxMCQ5nDa</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/3e4769a0-0ce4-451b-bb60-70794f33ce39/tasks/b4yr18lck.output</output-file> <status>completed</status> <summary>Background command "Wait for copy review result" completed (exit code 0)</summary> </task-notification>

diff --git a/internal/console/apigatepass.go b/internal/console/apigatepass.go
new file mode 100644
index 000000000..d99df38ab
--- /dev/null
+++ b/internal/console/apigatepass.go
@@ -0,0 +1,85 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// gatePassLifetime is how long a watch pass lets its holder past a gated
+// host. Long enough to outlast `carlos deploy`'s default watch (two minutes)
+// several times over, short enough that a pass copied out of a CI log is
+// stale before anyone reads it.
+const gatePassLifetime = 15 * time.Minute
+
+// handleAPIInstanceGatePass mints a short-lived gate cookie for one gated
+// host, so `carlos deploy` can check the version it just promoted without
+// the gate answering its watch with the login page (discussion #164).
+//
+// The pass is a real gate cookie (gate.Mint), minted from the record's own
+// hash and bound to the record's own host, so the edge verifies it exactly
+// as it verifies a visitor's — nothing new is taught to the edge, and
+// changing or clearing the password kills every pass along with every
+// visitor's cookie. What it grants is read access to the host for
+// gatePassLifetime, and the door is registered for exactly the callers who
+// already hold more than that: a person who is a member, and an Instance
+// credential, both of which can clear the gate through the PATCH door
+// beside this one. A pass is therefore never a way past the password that
+// its holder did not already have. Publish is left out on purpose (see the
+// registration in authcli.go).
+//
+// Refusals follow the instance family: principal first, then the
+// byte-identical 404 for a host that is badly shaped or names no record.
+func (s *Server) handleAPIInstanceGatePass(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ // The control plane's rule, applied as the PATCH door applies it: a
+ // service credential that cannot move the console by itself does not
+ // get to read past a gate on it either. A person may.
+ p, _ := principalFrom(r.Context())
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "get a gate pass for it") {
+ return
+ }
+ host := r.PathValue("host")
+ // Shape-checked before the key builder, as GET does it: {host} arrives
+ // percent-decoded, and RecordKey would join a path with separators in it.
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instances gate pass %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ if rec.Gate == "" {
+ jsonError(w, http.StatusConflict, "no password gate is set on "+host)
+ return
+ }
+ expires := time.Now().Add(gatePassLifetime)
+ // rec.Host, not the path segment: the edge keys the MAC on the route
+ // row's own host, which is the record's, and the two are the same string
+ // only because GetRecord found the record by it.
+ value := gate.Mint(rec.Host, rec.Gate, expires)
+ s.audit(acct, app, principal, "gate-pass", "issued a 15-minute gate pass for "+rec.Host+" to watch a deploy (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "name": gate.CookieName,
+ "value": value,
+ "expires": expires.UTC().Format(time.RFC3339),
+ })
+}
diff --git a/internal/console/apigatepass_test.go b/internal/console/apigatepass_test.go
new file mode 100644
index 000000000..ae68a978c
--- /dev/null
+++ b/internal/console/apigatepass_test.go
@@ -0,0 +1,127 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// gatedInstance creates one instance and sets a password on it, returning
+// the gate-pass URL for it.
+func gatedInstance(t *testing.T, fx *consoleFixture, token, host string) string {
+ t.Helper()
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"`+host+`","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "titogo", host), token,
+ `{"gate":"correct horse"}`); status != http.StatusOK {
+ t.Fatalf("patch gate = %d %s", status, out)
+ }
+ return updateURL(fx, "bab", "titogo", host) + "/gate-pass"
+}
+
+// TestAPIInstanceGatePassMintsAVerifyingCookie: the pass is a real gate
+// cookie for the record's host under the record's hash — the edge's own
+// gate.Verify accepts it — and it stops verifying after gatePassLifetime.
+// The hash itself never appears in the answer, and each pass is audited.
+func TestAPIInstanceGatePassMintsAVerifyingCookie(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-pass")
+ const host = "pass.go.tito.io"
+ pu := gatedInstance(t, fx, token, host)
+
+ before := time.Now()
+ status, _, out := rawReq(t, http.MethodPost, pu, token, "")
+ if status != http.StatusOK {
+ t.Fatalf("gate pass = %d %s, want 200", status, out)
+ }
+ var pass struct {
+ Name, Value, Expires string
+ }
+ if err := json.Unmarshal([]byte(out), &pass); err != nil {
+ t.Fatalf("gate pass body %s: %v", out, err)
+ }
+ if pass.Name != gate.CookieName {
+ t.Fatalf("name = %q, want %q", pass.Name, gate.CookieName)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !gate.Verify(pass.Value, host, stored.Gate, before) {
+ t.Fatal("the pass does not verify against the record's gate")
+ }
+ if gate.Verify(pass.Value, host, stored.Gate, before.Add(gatePassLifetime+time.Minute)) {
+ t.Fatal("the pass still verifies after its lifetime")
+ }
+ if gate.Verify(pass.Value, "other.go.tito.io", stored.Gate, before) {
+ t.Fatal("the pass verifies for a different host")
+ }
+ exp, err := time.Parse(time.RFC3339, pass.Expires)
+ if err != nil || exp.Before(before.Add(gatePassLifetime-time.Minute)) {
+ t.Fatalf("expires = %q (%v), want about %v from now", pass.Expires, err, gatePassLifetime)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var detail string
+ for _, e := range events {
+ if e.Action == "gate-pass" {
+ detail = e.Detail
+ break
+ }
+ }
+ if want := "issued a 15-minute gate pass for " + host + " to watch a deploy (cli)"; detail != want {
+ t.Fatalf("audit detail = %q, want %q", detail, want)
+ }
+}
+
+// TestAPIInstanceGatePassRefusals: an ungated host is a 409 naming it, and a
+// host that names no record is the family's 404.
+func TestAPIInstanceGatePassRefusals(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-gate-pass-refuse")
+ gatedInstance(t, fx, token, "gp.go.tito.io")
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"open.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ status, _, out := rawReq(t, http.MethodPost, updateURL(fx, "bab", "titogo", "open.go.tito.io")+"/gate-pass", token, "")
+ if status != http.StatusConflict {
+ t.Fatalf("ungated host = %d %s, want 409", status, out)
+ }
+ status, _, out = rawReq(t, http.MethodPost, updateURL(fx, "bab", "titogo", "nobody.go.tito.io")+"/gate-pass", token, "")
+ if status != http.StatusNotFound {
+ t.Fatalf("unknown host = %d %s, want 404", status, out)
+ }
+}
+
+// TestAPIInstanceGatePassRoles pins who holds a pass: Instance, which can
+// clear the gate through PATCH anyway. Publish cannot always change what a
+// host serves (a passkey rung makes its promote a proposal), so a pass would
+// be new access for it; Operate can do neither. Both are refused.
+func TestAPIInstanceGatePassRoles(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-gate-pass-roles")
+ pu := gatedInstance(t, fx, member, "roles.go.tito.io")
+ for _, c := range []struct {
+ name string
+ role svcrole.Role
+ want int
+ }{
+ {"hub", svcrole.Publish, http.StatusForbidden},
+ {"router", svcrole.Instance, http.StatusOK},
+ {"ops", svcrole.Operate, http.StatusForbidden},
+ } {
+ tok := serviceToken(t, fx.st, OpsAccountID, c.name, "titogo", c.role)
+ if status, _, out := rawReq(t, http.MethodPost, pu, tok, ""); status != c.want {
+ t.Errorf("%s: gate pass = %d %s, want %d", c.role, status, out, c.want)
+ }
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2c77fb0c0..77e4e726b 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -617,6 +617,15 @@ func (s *Server) registerCLIRoutes() {
// keeps serving. Same {host} wildcard, same gate, same byte-identical
// 404 as its GET and DELETE siblings.
s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate)
+ // The deploy watch's gate pass (discussion #164). Instance only, because
+ // an Instance credential can already clear the gate through the PATCH
+ // above, so a pass gives it nothing new. Publish is deliberately NOT
+ // here: a Publish credential cannot always change what a host serves (a
+ // passkey or approval rung turns its promote into a proposal), so a pass
+ // would be new read access past the password. It needs none anyway:
+ // `carlos deploy` lists instances before it watches, and that door is
+ // Instance-only too.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/gate-pass", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGatePass)
// The residence-move doors (issue #331): requested by the tenant's
// ROUTER with the app-held instance token (titogo's residence design —
// never by an instance about itself... which the role system cannot yet
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 44765af64..02a75a959 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -341,24 +341,33 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// records, split off 2026-09-24. Before the split an instance credential
// read these very rows through GET .../errors, so granting it here widens
// nothing — refusing it would be the change.
+//
+// POST .../instances/{host}/gate-pass stands on the same footing
+// (discussion #164). It
+// did not exist before service principals, so listing it in `want` would
+// make the legacy map lie. The instance role reaches it DELIBERATELY: the
+// pass is fifteen minutes' read past one host's password gate, and an
+// instance credential can already clear that gate outright through the
+// PATCH door pinned in `want`. TestAPIInstanceGatePassRoles pins who gets in.
var exemptFromLegacyPin = map[string]bool{
- "GET /api/cli/apps": true,
- "GET /api/cli/apps/{account}/{app}/errors": true,
- "GET /api/cli/apps/{account}/{app}/availability": true,
- "GET /api/cli/apps/{account}/{app}/analytics": true,
- "GET /api/cli/apps/{account}/{app}/domains/{host}": true,
- "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
- "GET /api/cli/apps/{account}/{app}/features": true,
- "GET /api/cli/apps/{account}/{app}/regions": true,
- "GET /api/cli/apps/{account}/{app}/instances/{host}/move": true,
- "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
- "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,
- "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true,
- "GET /api/cli/apps/{account}/{app}/sidecars": true,
- "POST /api/cli/apps/{account}/{app}/sidecars": true,
- "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
+ "GET /api/cli/apps": true,
+ "GET /api/cli/apps/{account}/{app}/errors": true,
+ "GET /api/cli/apps/{account}/{app}/availability": true,
+ "GET /api/cli/apps/{account}/{app}/analytics": true,
+ "GET /api/cli/apps/{account}/{app}/domains/{host}": true,
+ "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
+ "GET /api/cli/apps/{account}/{app}/features": true,
+ "GET /api/cli/apps/{account}/{app}/regions": true,
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/move": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,
+ "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/gate-pass": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec