console: the create door also refuses a host another app has claimed
An attached alias is a global hostname claim with no instance record and, until the domains pass derives it, no route, so hostHolder cannot see it, and a route minted at that host would shadow the alias at the edge. The door now reads the claim itself (one GET, authoritative): another app's claim refuses with 409, another account's refuses without saying whose, the app's own does not, and an unreadable claim fails closed. Also names, at the check, the race it does not close: two apps creating one host concurrently can both pass, because CreateRecord's conditional write covers only the per-app key. Host-scoped atomic ownership is a pre-existing instance-door gap, left to its own follow-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+114
−0
internal/console/apex.go+42 −0internal/console/apiinstances.go+20 −0internal/console/apiinstancescanary_test.go+52 −0
diff --git a/internal/console/apex.go b/internal/console/apex.go| index eb977c525..eb002fdc4 100644 |
| --- a/internal/console/apex.go |
| +++ b/internal/console/apex.go |
| @@ -19,6 +19,7 @@ import ( |
| "os" |
| "strings" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| @@ -160,6 +161,47 @@ func (s *Server) hostHolder(ctx context.Context, acct *Account, host, own string |
| return nil, nil |
| } |
| +// hostClaimHolder is the other half of hostHolder: the global hostname claim |
| +// (domains.ClaimKey, one object per host whoever asks) that an attached alias |
| +// is derived from. An alias has no instance record and, until the domains |
| +// pass materializes it, no route either, so hostHolder cannot see it; the |
| +// claim is the authoritative record and one GET. A route minted at a host |
| +// somebody else has claimed would shadow their alias at the edge. |
| +// |
| +// It returns who the claim belongs to when that is not own in acct, nil when |
| +// there is no claim or it is own's. A claim of another account comes back |
| +// with only OtherAccount set and no Holder: domains.ErrHostUnavailable is |
| +// deliberately neutral about who owns a hostname, and this door must not be a |
| +// way round that. Anything but "no such claim" is an error: it fails closed. |
| +func (s *Server) hostClaimHolder(ctx context.Context, acct *Account, host, own string) (*apexHold, error) { |
| + if s.cfg.Releases == nil { |
| + return nil, nil |
| + } |
| + c, err := domains.GetClaim(ctx, s.cfg.Releases, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, &apexErr{bucket: true, err: err} |
| + } |
| + switch { |
| + case c.Account != acct.Sqid: |
| + return &apexHold{OtherAccount: c.Account}, nil |
| + case c.App != own: |
| + return &apexHold{Holder: c.App}, nil |
| + } |
| + return nil, nil |
| +} |
| + |
| +// claimHeldSentence is hostHeldSentence for a hostname claim. A claim of |
| +// another account is refused without saying whose or that it is a claim. |
| +func claimHeldSentence(host string, hold *apexHold) string { |
| + if hold.OtherAccount != "" { |
| + return hostHeldSentence(host, nil) |
| + } |
| + return hostHeldSentence(host, hold) |
| +} |
| + |
| // apexHeldSentence is the refusal, in one voice for every door that gives |
| // it, with the advice that fits who holds the host. |
| func apexHeldSentence(host string, hold *apexHold) string { |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index aecbbcf23..d3b0267a4 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -760,6 +760,15 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| // nothing. Refused here, for every host, on the same fail-closed terms as |
| // the apex checks; a host this app already holds falls through to the |
| // create's own "already exists". |
| + // |
| + // KNOWN LIMIT, filed as its own discussion: this is a check, then a |
| + // write, and CreateRecord's conditional write protects only this app's |
| + // own account/app/host key. Two creates for one host from two different |
| + // apps, racing, can both pass and both be written; the provisioning pass |
| + // then keeps one and skips the other ("duplicate host"). Closing that |
| + // needs host-scoped atomic ownership, which is a gap in the instance door |
| + // that predates the flat canary host and is not built here. The |
| + // sequential case, the common one, is closed. |
| switch hold, err := s.hostHolder(ctx, acct, host, app); { |
| case err != nil: |
| log.Printf("console: api instances create %s/%s: host holder check %s: %v", acct.Sqid, app, host, err) |
| @@ -769,6 +778,17 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusConflict, hostHeldSentence(host, hold)) |
| return |
| } |
| + // An attached alias has no record and may have no route yet, so the |
| + // holder check cannot see it; its authoritative claim is one read. |
| + switch hold, err := s.hostClaimHolder(ctx, acct, host, app); { |
| + case err != nil: |
| + log.Printf("console: api instances create %s/%s: host claim check %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not check the host — try again") |
| + return |
| + case hold != nil: |
| + jsonError(w, http.StatusConflict, claimHeldSentence(host, hold)) |
| + return |
| + } |
| // A fleet-placed app (customer-fleets plan, `carlos apps place`) governs |
| // every one of its instances' Region uniformly — spec's "one flag on the |
| // app, all its instances follow" (appPlacement, fleets.go) — so when a |
diff --git a/internal/console/apiinstancescanary_test.go b/internal/console/apiinstancescanary_test.go| index b21d6e4ee..e217562ef 100644 |
| --- a/internal/console/apiinstancescanary_test.go |
| +++ b/internal/console/apiinstancescanary_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -133,3 +134,54 @@ func TestFlatCanaryHostsOfTwoAppsCannotCollide(t *testing.T) { |
| t.Fatalf("re-create by the holder = %d %s, want 409 already exists", status, out) |
| } |
| } |
| + |
| +// An attached alias is a hostname claim (domains.ClaimKey) with no instance |
| +// record, and no route until the domains pass derives one, so the holder |
| +// check cannot see it; a route minted at that host would shadow the alias at |
| +// the edge. The create door reads the claim itself: another app's claim, in |
| +// this account or another, refuses the create (the other account's without |
| +// saying whose), this app's own claim does not, and a claim that cannot be |
| +// read fails closed. |
| +func TestInstanceCreateRefusesAHostAnotherAppHasClaimed(t *testing.T) { |
| + fx, member, domain := canaryDoorFixture(t, "code-canary-claim", "other") |
| + ctx := context.Background() |
| + claim := func(host, acct, app string) { |
| + t.Helper() |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{Schema: 1, Host: host, Account: acct, App: app, |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + create := func(label string) (int, string) { |
| + t.Helper() |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+label+`.`+domain+`","channel":"canary/`+label+`"}`) |
| + return status, out |
| + } |
| + |
| + claim("probe-titogo."+domain, "bab", "other") |
| + if status, out := create("probe-titogo"); status != http.StatusConflict || !strings.Contains(out, "other") { |
| + t.Fatalf("create over a sibling's claim = %d %s, want 409 naming app other", status, out) |
| + } |
| + |
| + claim("away-titogo."+domain, "zzzz", "other") |
| + status, out := create("away-titogo") |
| + if status != http.StatusConflict || strings.Contains(out, "zzzz") || strings.Contains(out, "other") { |
| + t.Fatalf("create over another account's claim = %d %s, want 409 that names neither", status, out) |
| + } |
| + |
| + claim("mine-titogo."+domain, "bab", "titogo") |
| + if status, out := create("mine-titogo"); status != http.StatusCreated { |
| + t.Fatalf("create over this app's own claim = %d %s, want 201", status, out) |
| + } |
| + |
| + if _, err := fx.rel.Put(ctx, domains.ClaimKey("junk-titogo."+domain), []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, out := create("junk-titogo"); status != http.StatusInternalServerError { |
| + t.Fatalf("create with an unreadable claim = %d %s, want 500 (fail closed)", status, out) |
| + } |
| + if _, err := provision.GetRecord(ctx, fx.rel, release.OpsAccount, "titogo", "probe-titogo."+domain); err == nil { |
| + t.Fatal("a refused create left a record behind") |
| + } |
| +} |