carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev d0c40bfd693b058e7480644094ef395538c56486 parent 80b1706
4 files changed, +69 −20
  • internal/console/apidomainpaths_test.go +21 −7
  • internal/console/authcli.go +18 −8
  • internal/console/controlplane_test.go +17 −5
  • internal/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,