console: the paths door takes Instance as well as Publish
Publish-only was the wrong cut, and the reason is internal coherence rather than threat model. The paths READ on handleAPIDomainGet is gated Publish+Instance, so as it stood an Instance credential could read a hostname's published prefix map and not write it — and apidomains.go's own file header names Tito's router, calling this family with its CARLOS_INSTANCE_TOKEN, as the caller the domains doors exist for. Widening crosses no tenancy boundary. The door is already owner-gated on account and app through domainsGate and refuses a control-plane app outright, so an Instance token can only ever publish its OWN app's map, and the reconcile pass re-checks every target against the same account regardless. TestAPIDomainPathsIsPublishOnly becomes TestAPIDomainPathsReachesPublishAndInstance and pins the new contract, with Operate as a negative control so "widened" means two named roles rather than every service role. The door joins exemptFromLegacyPin, with the reasoning recorded there: it did not exist before service principals, so listing it in that test's `want` map would make the legacy-reach map lie — this is a deliberate grant, not compatibility being preserved. TestControlPlaneAppRefusesAServicePathPublish now exercises both roles the door admits and asserts the refusals are byte-identical; its comment claiming an instance credential never reaches the handler is corrected. The decision and its reasoning are recorded in design spec §7 so the scoping is a visible choice rather than an accident.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+69
−20
internal/console/apidomainpaths_test.go+21 −7internal/console/authcli.go+18 −8internal/console/controlplane_test.go+17 −5internal/console/svcroutes_test.go+13 −0
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go| index 014f747c0..65ca4d4ec 100644 |
| --- a/internal/console/apidomainpaths_test.go |
| +++ b/internal/console/apidomainpaths_test.go |
| @@ -314,21 +314,35 @@ func getPaths(t *testing.T, fx *consoleFixture, token, host string) map[string]s |
| return got.Paths |
| } |
| -// TestAPIDomainPathsIsPublishOnly: publishing a prefix map decides where an |
| -// account's traffic goes, which is a deployment-shaped act. The instance |
| -// credential an app's own router holds reaches the attach and detach doors |
| -// beside this one for compatibility; it does not reach this one. |
| -func TestAPIDomainPathsIsPublishOnly(t *testing.T) { |
| +// TestAPIDomainPathsReachesPublishAndInstance is the door's role contract. |
| +// |
| +// Publish AND Instance, matching the READ of the same state beside it (GET |
| +// .../domains/{host} carries both). An earlier revision was Publish-only on |
| +// the reasoning that a flip is deployment-shaped; that left an instance |
| +// credential able to READ a hostname's published prefix map and not write |
| +// it, while apidomains.go's own header names titogo's router — calling this |
| +// family with its CARLOS_INSTANCE_TOKEN — as the caller the door exists for. |
| +// The widening crosses no tenancy boundary: the door is owner-gated on |
| +// account and app and refuses a control-plane app, so an instance token can |
| +// only publish its OWN app's map. |
| +// |
| +// Operate is the negative control: widening the door must widen it to a |
| +// named pair, not to every service role. |
| +func TestAPIDomainPathsReachesPublishAndInstance(t *testing.T) { |
| fx, member := instanceFixture(t, "code-dom-paths-roles") |
| claimedHost(t, fx, member, "ti.to") |
| body := pathsBody(t, map[string]string{"paulca": "go.tito.io"}) |
| inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| - if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusForbidden { |
| - t.Fatalf("instance-role publish = %d %s, want 403", code, out) |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusOK { |
| + t.Fatalf("instance-role publish = %d %s, want 200 — the router that reads this map must be able to write it", code, out) |
| } |
| pub := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) |
| if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), pub, body); code != http.StatusOK { |
| t.Fatalf("publish-role publish = %d %s, want 200", code, out) |
| } |
| + op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), op, body); code != http.StatusForbidden { |
| + t.Fatalf("operate-role publish = %d %s, want 403 — the door is two named roles, not every service role", code, out) |
| + } |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 08cf3c055..232143bbb 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -628,14 +628,24 @@ func (s *Server) registerCLIRoutes() { |
| // regenerates the map from its database and republishes it — which is |
| // why there is no per-prefix verb and no CLI writer at all. |
| // |
| - // `Publish` ONLY, and deliberately narrower than the three doors above |
| - // it: those carry `Instance` for compatibility with a router that has |
| - // always re-attached its hostnames, and this door has no such history to |
| - // preserve. Deciding which accounts' traffic leaves the legacy app is a |
| - // deployment-shaped act, and an instance-scoped token has no business |
| - // making it. Being new, it is not in TestInstanceRoleMatchesTodaysAppTokenReach's |
| - // legacy map either — nothing to exempt, because nothing reached it before. |
| - s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths) |
| + // `Publish` AND `Instance`, matching the READ of the same state on |
| + // handleAPIDomainGet above. An earlier revision made this Publish-only on |
| + // the reasoning that a flip is deployment-shaped; the objection that |
| + // carried is internal coherence — as Publish-only, an Instance credential |
| + // could read a hostname's published prefix map and not write it, and this |
| + // file's own header names titogo's router, calling the domains family |
| + // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for. |
| + // |
| + // Widening crosses no tenancy boundary. The door is already owner-gated |
| + // on account and app (domainsGate) and guarded by |
| + // refuseServiceControlPlane, so an Instance token can only ever publish |
| + // its OWN app's map, naming targets the reconcile pass then re-checks |
| + // against the same account (aliases.go). It is not in |
| + // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because |
| + // nothing reached it before service principals existed — see |
| + // exemptFromLegacyPin, where the deliberate grant is recorded. Design |
| + // spec §7. |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainPaths) |
| // App feature flags (apifeatures.go, #347). The read carries Instance |
| // because the app IS the consumer — its router reads its own switches |
| // with the CARLOS_INSTANCE_TOKEN, the same credential and the same |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index 32168b8ed..ad3780db6 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -1287,11 +1287,10 @@ func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) { |
| // That is not a decision a CI credential should be able to take. |
| // |
| // Service-only, like the attach above and unlike detach: a PERSON publishing a |
| -// prefix map for the control plane is an ordinary operator act. Only Publish |
| -// is exercised because it is the only role the route admits at all — an |
| -// instance credential never reaches the handler to be refused by this guard |
| -// (TestAPIDomainPathsIsPublishOnly pins that it gets a 403 from cliRoute |
| -// instead). |
| +// prefix map for the control plane is an ordinary operator act. BOTH service |
| +// roles the route admits are exercised (Publish and Instance — see |
| +// TestAPIDomainPathsReachesPublishAndInstance), because this guard, not the |
| +// role table, is what keeps either of them off the console's own hostname. |
| func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) { |
| const host = "docs.example.com" |
| fx, person := cpDetachFixture(t, "code-cp-paths", host) |
| @@ -1310,6 +1309,19 @@ func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) { |
| t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths) |
| } |
| + // The other role the door admits, refused identically: an app's router |
| + // holding a CARLOS_INSTANCE_TOKEN reaches this handler (that is the |
| + // widening in spec §7) and meets the same guard. |
| + inst := serviceToken(t, fx.st, OpsAccountID, "router", "kass", svcrole.Instance) |
| + instStatus, _, instOut := rawReq(t, http.MethodPut, u, inst, body) |
| + if instStatus != status || instOut != out { |
| + t.Fatalf("an INSTANCE publishing paths for the control plane = %d %s, want the same %d %s a PUBLISH gets", |
| + instStatus, instOut, status, out) |
| + } |
| + if c := claimOf(t, fx, host); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths) |
| + } |
| + |
| // The guard sits BEFORE the hostname is normalized, so its answer cannot |
| // vary with host SHAPE: a flagged app refuses a malformed host and a valid |
| // one with the identical status and the identical body. Were the order |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index f5fac7a98..2c3fb99a4 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -230,9 +230,22 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // credential that may already CREATE and DELETE instances (both in `want`) |
| // is not gaining authority by relocating one. TestAPIInstanceMoveVerbs |
| // pins what the doors themselves refuse. |
| +// |
| +// PUT .../domains/{host}/paths is the seventh, and on the same footing: path |
| +// routing did not exist before service principals, so listing it in `want` |
| +// would make the legacy map lie. The instance role reaches it DELIBERATELY, |
| +// to match the READ of the same state (GET .../domains/{host}, exempted |
| +// above) — an app's router that may read its own published prefix map with |
| +// its CARLOS_INSTANCE_TOKEN is the writer this door was built for, and a |
| +// credential that may already attach and detach that hostname is not gaining |
| +// authority by saying how its traffic splits. The door is owner-gated on |
| +// account and app and refuses a control-plane app outright, so an instance |
| +// token can only ever publish its OWN app's map. |
| +// TestAPIDomainPathsReachesPublishAndInstance pins the contract itself. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": 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, |