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.
4 files changed,
+247
−17
internal/console/apigatepass.go+85 −0internal/console/apigatepass_test.go+127 −0internal/console/authcli.go+9 −0internal/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 |