projects: the operator vouch is an admin scope, not a membership
Binding a custom domain WITHOUT proving control of it — the bind lands
VERIFIED and routes immediately, and the "a host we operate" refusal does not
apply — is platform authority. The gate read
vouched := c.IsAdmin() || s.State.operatorOrgs[org]
and that second term is bare MEMBERSHIP. operatorOrgs defaults to the
deployment's own brand org (config.go getenv CLOUD_BRAND, brand.Default
"hanzo"), so the set is {hanzo} in every deployment, and `org` is the
IAM-validated effective org, gated upstream by isMember alone. Every staff
account whatever its role, plus anyone a brand-org admin ever invited, could
bind login.example-bank.com live with no DNS-01 proof: attacker content served
at any custom-domain customer whose DNS already points at our edge, and the
name denied to its rightful owner for good, since a verified row is first-come
and global.
vouches() now names the two grants, both admin-scoped:
SuperAdmin platform sudo (owner == the reserved admin org).
Cross-tenant by construction, so it vouches in ANY
org — the operator switched into a customer's org to
bind the domain it manages DNS for. Unchanged.
operator-org ADMIN the deployment named this org an operator AND IAM
says the caller administers it.
The second is a conjunction of two independently administered facts: a
capability the deployment grants to an org, and the role IAM grants inside it.
The set names an org; it never names an authority. The org-admin bit is asked
of the EFFECTIVE org — the same value SanitizeIdentity keys X-User-IsOrgAdmin
on — so the pair reads "admin OF this operator org" and never "admin of some
org I switched out of". Both bits are stripped on ingress and re-minted only
from validated claims.
Fail-secure: an issuer that stops signing the org role drops the operator-org
grant to a PENDING claim carrying the DNS challenge, the same self-service path
every other tenant takes. SuperAdmin onboarding never depended on that claim.
TestOperatorVouchNeedsAdminScope drives the real handler over four identities.
On the parent commit it fails with the exploit verbatim — a plain member's bind
of login.example-bank.com comes back {Status: live, Verified: true}. It also
holds the positive cases, so a fix that merely disabled operator onboarding
would not pass. TestOperatorVouchIsVerbatimEndToEnd keeps the verbatim-owner
pin and now has both callers be org admins, leaving the org name as the only
axis; it went from panicking to passing because it no longer builds the whole
surface.
The harness registers the ops it drives rather than calling routes(), which
composes only on the Router production gives it: routes() declares middleware
with Group(prefix, mw) and registers its typed ops on the App with full paths,
so on the bare *zip.App a test holds the prefix node has no routes beneath it
and zip refuses to compose. cloud.Listen mounts on a *scope, whose Use and
Group install at the root and gate by request path, so the same registration
composes there. What is driven is the production chain at the production paths:
cloud.Bridge parking the request, siteOf resolving the tenant, bindDomains
deciding.
The lifted prose and both published specs carry the corrected contract; the old
text told customers that membership of the operator org was the vouch. zipdoc
regenerates; the OpenAPI subsets cannot be projected for this app yet, so those
two files take the identical substitution zipdoc made.
apps/projects: 55 -> 58 tests passing, no test that passed on the parent fails.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+71
-21
@@ -10,16 +10,21 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/principal"
|
||||
"github.com/hanzoai/cloud/apps/sites"
|
||||
"github.com/hanzoai/cloud/internal/fqdn"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// operatorOrgsFromEnv builds the set of orgs that may bind a custom domain
|
||||
// operatorOrgsFromEnv builds the set of orgs whose ADMINS may bind a custom domain
|
||||
// WITHOUT proving ownership (besides a SuperAdmin): CLOUD_PLATFORM_OPERATOR_ORGS
|
||||
// (comma-separated) when set, else the deployment's own brand org. The brand org
|
||||
// is the platform operator and manages customer DNS on their behalf, so its bind
|
||||
// IS the vouch. Every other org self-serves through the DNS challenge below.
|
||||
// (comma-separated) when set, else the deployment's own brand org. The brand org is
|
||||
// the platform operator and manages customer DNS on their behalf, so its admin's
|
||||
// bind IS the vouch. Every other org self-serves through the DNS challenge below.
|
||||
//
|
||||
// The SET names an org; it never names an authority. Naming an org here is the
|
||||
// DEPLOYMENT's grant of a capability to that tenant, and who inside that tenant may
|
||||
// exercise it is IAM's answer, not this file's — see vouches, which requires both.
|
||||
//
|
||||
// Each entry is the VERBATIM validated IAM owner, trimmed and nothing else —
|
||||
// the SAME value setDomains looks the caller up by (org → principal.Org, which
|
||||
@@ -69,22 +74,64 @@ func publicHost(s *cloud.Service[state], slug string) string {
|
||||
return slug + "." + s.State.apex
|
||||
}
|
||||
|
||||
// ours reports whether host is a domain WE run, or anything beneath it. Those names
|
||||
// are ours to assign, and no DNS proof is even possible for them — a customer cannot
|
||||
// publish a TXT record in a zone we run.
|
||||
// ours reports whether host is a name WE hold — a domain we run, or anything
|
||||
// beneath it. Those names are ours to assign, and no DNS proof is even possible for
|
||||
// them: a customer cannot publish a TXT record in a zone we run.
|
||||
//
|
||||
// It asks the SAME shared self-domain set the sites serve gate uses
|
||||
// (sites.IsSelfHost), not just the published-site apex. Checking only the apex left
|
||||
// the brand domain claimable: `api.hanzo.ai` — our production API host — passed this
|
||||
// gate and took a first-come claim row. It could never serve (the serve gate excluded
|
||||
// it), but the row denied the host to its real owner for good. The sites apex is
|
||||
// always in that set, so this is strictly wider, never narrower; the explicit apex
|
||||
// test remains as the floor for a deployment that registered no self domains.
|
||||
// It asks the SAME shared predicate the storage invariant asks (sites.Ours, which
|
||||
// for a hostname is the self-domain set the serve gate uses), not just the
|
||||
// published-site apex. Checking only the apex left the brand domain claimable:
|
||||
// `api.hanzo.ai` — our production API host — passed this gate and took a first-come
|
||||
// claim row. It could never serve (the serve gate excluded it), but the row denied
|
||||
// the host to its real owner for good. The sites apex is always in that set, so
|
||||
// this is strictly wider, never narrower; the explicit apex test remains as the
|
||||
// floor, since the apex this app serves under is its own state.
|
||||
func ours(s *cloud.Service[state], host string) bool {
|
||||
return sites.IsSelfHost(host) ||
|
||||
return sites.Ours(host) ||
|
||||
host == s.State.apex || strings.HasSuffix(host, "."+s.State.apex)
|
||||
}
|
||||
|
||||
// vouches reports whether this caller may bind a host WITHOUT proving control of
|
||||
// it: the bind lands VERIFIED and routes immediately, and the "a host we operate"
|
||||
// refusal (ours) does not apply. It is the whole of the authority question this
|
||||
// surface asks, so it is one function and the gate below reads as one word.
|
||||
//
|
||||
// TWO GRANTS, BOTH ADMIN-SCOPED, and membership is neither.
|
||||
//
|
||||
// SuperAdmin platform sudo — the caller is a member of the reserved
|
||||
// `admin` org (owner == admin, principal.IsSuperAdmin).
|
||||
// Cross-tenant by construction, so it vouches in ANY org:
|
||||
// this is the operator onboarding a customer's domain
|
||||
// while switched into that customer's org.
|
||||
// operator-org ADMIN the deployment named this org an operator
|
||||
// (CLOUD_PLATFORM_OPERATOR_ORGS, else the brand) AND IAM
|
||||
// says the caller ADMINISTERS it (principal.IsOrgAdmin).
|
||||
//
|
||||
// The second grant is a CONJUNCTION of two independently administered facts — a
|
||||
// capability the deployment grants to an org, and the role IAM grants inside it —
|
||||
// and that is exactly what it used to be missing. It read `operatorOrgs[org]`
|
||||
// alone: bare MEMBERSHIP of the brand org, which every deployment has by default
|
||||
// (brand.Default = "hanzo"), so every staff account regardless of role, plus anyone
|
||||
// a brand-org admin ever invited, could bind `login.example-bank.com` live with no
|
||||
// DNS-01 proof — serving attacker content at any custom-domain customer whose DNS
|
||||
// already points at our edge, and denying the name to its rightful owner forever
|
||||
// (a verified row is first-come and global; the real owner gets 409).
|
||||
//
|
||||
// Membership is not an admin scope and the two must never be conjoined: that
|
||||
// conflation IS the privilege escalation. The org term is asked of the EFFECTIVE
|
||||
// org — the same value SanitizeIdentity keys X-User-IsOrgAdmin on — so the pair
|
||||
// reads "admin OF this operator org" and never "admin of some org I switched out
|
||||
// of". Both bits are stripped on ingress and re-minted only from validated claims,
|
||||
// so neither is forgeable.
|
||||
//
|
||||
// FAIL-SECURE either way: if an issuer stops signing the org role, IsOrgAdmin goes
|
||||
// false and the operator-org grant degrades to a PENDING claim carrying the DNS
|
||||
// challenge — the same self-service path every other tenant takes. Nothing opens,
|
||||
// and SuperAdmin onboarding is untouched because it never depended on that claim.
|
||||
func vouches(c *zip.Ctx, operatorOrgs map[string]bool, org string) bool {
|
||||
return principal.IsSuperAdmin(c) || (operatorOrgs[org] && principal.IsOrgAdmin(c))
|
||||
}
|
||||
|
||||
// projectsDomain is one row of a site's domains panel.
|
||||
//
|
||||
// live the edge answers for this host now
|
||||
@@ -165,16 +212,19 @@ type projectsBoundDomains struct {
|
||||
// BindDomains attaches one or more CUSTOM public hostnames to this org's site.
|
||||
//
|
||||
// Binding a host you do not own would let you shadow it at the edge, so which
|
||||
// outcome you get depends on whether ownership is already established: a
|
||||
// platform admin or the platform-operator org — which manages customer DNS, so
|
||||
// its bind IS the vouch — binds VERIFIED immediately; any other org has the host
|
||||
// outcome you get depends on whether ownership is already established: a caller
|
||||
// vouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages
|
||||
// customer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;
|
||||
// every other caller, INCLUDING a plain member of an operator org, has the host
|
||||
// CLAIMED as pending and gets the DNS challenge back in `bound[].records`. A
|
||||
// pending claim HOLDS the name so nobody else can take it, but it does not route
|
||||
// until POST .../domains/{host}/verify proves control.
|
||||
//
|
||||
// A hostname we operate is refused to a non-vouched caller (those are assigned
|
||||
// by the platform, never claimed), a host another site already holds is a 409,
|
||||
// and a reserved label is a 400. Claims and binds are idempotent for the same
|
||||
// and a name the platform holds is a 400 for EVERY caller — a vouch skips the
|
||||
// ownership proof, never the host table's own invariant. Claims and binds are
|
||||
// idempotent for the same
|
||||
// (org, slug), and re-claiming returns the SAME token rather than invalidating a
|
||||
// record the customer has already published. The edge cache-tag is flushed
|
||||
// afterwards so a newly-verified host serves the current build immediately.
|
||||
@@ -193,7 +243,7 @@ func (o ops) bindDomains(ctx context.Context, in *projectsDomainsBind) (*project
|
||||
if len(in.Domains) == 0 {
|
||||
return nil, zip.ErrBadRequest("no domains to bind")
|
||||
}
|
||||
vouched := c.IsAdmin() || s.State.operatorOrgs[org]
|
||||
vouched := vouches(c, s.State.operatorOrgs, org)
|
||||
now := time.Now().Unix()
|
||||
target := publicHost(s, p.Slug)
|
||||
|
||||
@@ -225,7 +275,7 @@ func (o ops) bindDomains(ctx context.Context, in *projectsDomainsBind) (*project
|
||||
case errors.Is(bindErr, errHostTaken):
|
||||
return nil, zip.ErrConflict("domain " + host + " is already bound to another site")
|
||||
case errors.Is(bindErr, errReservedHost):
|
||||
return nil, zip.ErrBadRequest("domain " + host + " is a reserved label")
|
||||
return nil, zip.ErrBadRequest("domain " + host + " is a name the platform holds")
|
||||
case bindErr != nil:
|
||||
return nil, zip.Errorf(http.StatusInternalServerError, "bind %q: %v", host, bindErr)
|
||||
}
|
||||
|
||||
+213
-30
@@ -57,16 +57,169 @@ func TestOperatorOrgsFromEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// caller is one identity as the trust boundary presents it: the effective org plus
|
||||
// the two admin bits SanitizeIdentity mints from validated claims. Both are stripped
|
||||
// on ingress and re-injected only for a verified principal, so setting them here is
|
||||
// modelling a token IAM signed, not forging a header — the forgery path is closed
|
||||
// upstream and is middleware_identity_test.go's subject, not this file's.
|
||||
type caller struct {
|
||||
org string
|
||||
superAdmin bool // X-User-IsAdmin — platform sudo (member of the reserved admin org)
|
||||
orgAdmin bool // X-User-IsOrgAdmin — admin OF the effective org
|
||||
}
|
||||
|
||||
// domainsApp installs the REAL domain ops behind the REAL bridge, at their real
|
||||
// paths, and returns a bind driver.
|
||||
//
|
||||
// It registers the ops it drives rather than calling routes(), because routes()
|
||||
// composes only on the Router production gives it. It declares its middleware with
|
||||
// Group(prefix, mw) and registers the typed ops on the App with FULL paths; on a
|
||||
// bare *zip.App — which is what a test holds — that makes a node at the prefix with
|
||||
// no routes beneath it, and zip refuses to compose a program whose middleware could
|
||||
// never run. cloud.Listen mounts on a *scope instead, whose Use and Group install at
|
||||
// the root and gate by request path (scope.go), so the same registration composes
|
||||
// there. scope is unexported, so a test either reaches for it or registers what it
|
||||
// drives.
|
||||
//
|
||||
// What is under test is unchanged either way: cloud.Bridge parking the request,
|
||||
// siteOf resolving the tenant from it, bindDomains deciding. That is the production
|
||||
// chain, at the production paths.
|
||||
func domainsApp(t *testing.T, s *cloud.Service[state]) func(c caller, slug, host string) (int, projectsDomain) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: s.Log})
|
||||
app.Use(cloud.Bridge(), cloud.DenyEnvelope())
|
||||
r := cloud.ZipApp(app)
|
||||
o := ops{s: s}
|
||||
zip.Post(r, "/v1/projects/:slug/domains", o.bindDomains)
|
||||
zip.Get(r, "/v1/projects/:slug/domains", o.listDomains)
|
||||
|
||||
return func(c caller, slug, host string) (int, projectsDomain) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(projectsDomainsBind{Domains: []string{host}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/projects/"+slug+"/domains", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", c.org)
|
||||
req.Header.Set("X-User-Id", "u-"+c.org) // a validated principal (org() gates on it)
|
||||
if c.superAdmin {
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
}
|
||||
if c.orgAdmin {
|
||||
req.Header.Set("X-User-IsOrgAdmin", "true")
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("bind %q for %+v: %v", host, c, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out struct {
|
||||
Bound []projectsDomain `json:"bound"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if len(out.Bound) == 0 {
|
||||
return resp.StatusCode, projectsDomain{}
|
||||
}
|
||||
return resp.StatusCode, out.Bound[0]
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperatorVouchNeedsAdminScope is the privilege-escalation regression, driven
|
||||
// through the REAL handler. The vouch skips the DNS-01 ownership proof (BindHost,
|
||||
// live immediately) and bypasses the "host we operate" refusal, so it is platform
|
||||
// authority and only an ADMIN scope may carry it.
|
||||
//
|
||||
// It used to key on MEMBERSHIP: `operatorOrgs[org]` alone, and the set defaults to
|
||||
// the deployment's brand org in EVERY deployment (brand.Default = "hanzo"). So any
|
||||
// member of org hanzo — every staff account whatever its role, plus anyone a
|
||||
// hanzo admin ever invited — could bind `login.example-bank.com` VERIFIED with no
|
||||
// proof at all: attacker content served at any custom-domain customer whose DNS
|
||||
// already points at our edge, and the name denied to its rightful owner for good,
|
||||
// since a verified row is first-come and global.
|
||||
//
|
||||
// Four identities, one operator org, one question each:
|
||||
//
|
||||
// plain member of the operator org → NOT vouched (pending claim + 403 on ours)
|
||||
// SuperAdmin → vouched (operator onboarding, unchanged)
|
||||
// ADMIN of the operator org → vouched (the set's grant, exercised by its admin)
|
||||
// ADMIN of some OTHER org → NOT vouched (the set is not a role, and a
|
||||
// role is not the set)
|
||||
func TestOperatorVouchNeedsAdminScope(t *testing.T) {
|
||||
t.Setenv("CLOUD_PLATFORM_OPERATOR_ORGS", "")
|
||||
ctx := context.Background()
|
||||
log := luxlog.New("test")
|
||||
store := newTestStore(t)
|
||||
svc := &cloud.Service[state]{
|
||||
Base: cloud.Base{Log: log},
|
||||
State: state{
|
||||
apex: "hanzo.app", store: store, cf: sites.NewPurger(log),
|
||||
operatorOrgs: operatorOrgsFromEnv("hanzo"), // the default set: {hanzo}
|
||||
},
|
||||
}
|
||||
bind := domainsApp(t, svc)
|
||||
for _, org := range []string{"hanzo", "acme"} {
|
||||
if err := store.CreateProject(ctx, mkProject(org, "site", org)); err != nil {
|
||||
t.Fatalf("create %s project: %v", org, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A PLAIN MEMBER of the brand operator org. No admin bit of either kind — the
|
||||
// identity every staff account and every invitee has.
|
||||
member := caller{org: "hanzo"}
|
||||
|
||||
// It self-serves like any other tenant: a PENDING claim carrying the challenge.
|
||||
code, v := bind(member, "site", "login.example-bank.com")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("member bind = %d, want 200 (a pending claim)", code)
|
||||
}
|
||||
if v.Verified || v.Status != "pending" || len(v.Records) == 0 {
|
||||
t.Fatalf("a PLAIN MEMBER of the operator org was vouched: %+v — membership is not "+
|
||||
"an admin scope, and this bind skipped the DNS-01 ownership proof on a host "+
|
||||
"the caller does not own", v)
|
||||
}
|
||||
// …and it cannot take a host WE operate at all; only a vouched caller may.
|
||||
if code, _ = bind(member, "site", "evil.hanzo.app"); code != http.StatusForbidden {
|
||||
t.Fatalf("member claim of our own host = %d, want 403 — ours() must apply to "+
|
||||
"every non-vouched caller, operator-org membership included", code)
|
||||
}
|
||||
|
||||
// OPERATOR ONBOARDING STILL WORKS. A real SuperAdmin vouches — and in ANY org,
|
||||
// because platform sudo is cross-tenant by construction: this is the operator
|
||||
// switched into a customer's org to bind the domain it manages DNS for.
|
||||
code, v = bind(caller{org: "acme", superAdmin: true}, "site", "customer.example")
|
||||
if code != http.StatusOK || !v.Verified || v.Status != "live" {
|
||||
t.Fatalf("SuperAdmin lost the vouch: code=%d %+v — operator onboarding is "+
|
||||
"disabled, which is a regression and not a fix", code, v)
|
||||
}
|
||||
|
||||
// The set's own grant, exercised by an ADMIN of the org it names.
|
||||
code, v = bind(caller{org: "hanzo", orgAdmin: true}, "site", "operator.example")
|
||||
if code != http.StatusOK || !v.Verified || v.Status != "live" {
|
||||
t.Fatalf("admin OF the operator org lost the vouch: code=%d %+v", code, v)
|
||||
}
|
||||
|
||||
// An org admin OUTSIDE the set gets nothing: the org-admin bit is self-service
|
||||
// authority within one's own tenant, never platform authority.
|
||||
code, v = bind(caller{org: "acme", orgAdmin: true}, "site", "outsider.example")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("non-operator org-admin bind = %d, want 200 (a pending claim)", code)
|
||||
}
|
||||
if v.Verified || v.Status != "pending" {
|
||||
t.Fatalf("an org admin OUTSIDE the operator set was vouched: %+v — the IAM "+
|
||||
"isAdmin bit is org-scoped self-service, never platform authority", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperatorVouchIsVerbatimEndToEnd is the cross-tenant privilege-bleed
|
||||
// regression, driven through the REAL route. `vouched` is what skips the DNS-01
|
||||
// regression, driven through the REAL route. The vouch is what skips the DNS-01
|
||||
// ownership proof (BindHost, live immediately) and bypasses the "host we operate"
|
||||
// refusal — so a tenant that merely case-folds onto an operator's name must NOT
|
||||
// get it.
|
||||
// get it, however privileged it is inside its OWN org.
|
||||
//
|
||||
// CLOUD_PLATFORM_OPERATOR_ORGS="Acme" names ONE operator. Tenant "acme" is a
|
||||
// different IAM owner and must self-serve: its bind is a PENDING claim carrying a
|
||||
// DNS challenge, and a host we operate is refused outright. The operator "Acme"
|
||||
// keeps its vouch: bound live, no challenge.
|
||||
// keeps its vouch: bound live, no challenge. BOTH callers are org admins here, so
|
||||
// the only axis left is the org name — which is the axis under test, and it is
|
||||
// never folded.
|
||||
func TestOperatorVouchIsVerbatimEndToEnd(t *testing.T) {
|
||||
t.Setenv("CLOUD_PLATFORM_OPERATOR_ORGS", "Acme")
|
||||
ctx := context.Background()
|
||||
@@ -79,8 +232,7 @@ func TestOperatorVouchIsVerbatimEndToEnd(t *testing.T) {
|
||||
operatorOrgs: operatorOrgsFromEnv("ignored-when-env-set"),
|
||||
},
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: log})
|
||||
routes(app, svc)
|
||||
bind := domainsApp(t, svc)
|
||||
|
||||
// Two DISTINCT tenants whose owners differ only in case, each with its own
|
||||
// project. Slugs are lowercase because slugParam lowercases the path segment;
|
||||
@@ -91,30 +243,9 @@ func TestOperatorVouchIsVerbatimEndToEnd(t *testing.T) {
|
||||
if err := store.CreateProject(ctx, mkProject("acme", "tenant-site", "Tenant")); err != nil {
|
||||
t.Fatalf("create tenant project: %v", err)
|
||||
}
|
||||
bind := func(org, slug, host string) (int, projectsDomain) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(projectsDomainsBind{Domains: []string{host}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/projects/"+slug+"/domains", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("bind %q for %q: %v", host, org, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out struct {
|
||||
Bound []projectsDomain `json:"bound"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if len(out.Bound) == 0 {
|
||||
return resp.StatusCode, projectsDomain{}
|
||||
}
|
||||
return resp.StatusCode, out.Bound[0]
|
||||
}
|
||||
|
||||
// The lookalike tenant is NOT the operator: it self-serves through DNS-01.
|
||||
code, v := bind("acme", "tenant-site", "lookalike.example")
|
||||
code, v := bind(caller{org: "acme", orgAdmin: true}, "tenant-site", "lookalike.example")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("lookalike bind = %d, want 200 (a pending claim)", code)
|
||||
}
|
||||
@@ -122,17 +253,69 @@ func TestOperatorVouchIsVerbatimEndToEnd(t *testing.T) {
|
||||
t.Fatalf("tenant %q was VOUCHED as platform operator: %+v — a case fold onto "+
|
||||
"operator \"Acme\" skipped the DNS-01 ownership proof", "acme", v)
|
||||
}
|
||||
// …and it cannot claim a host WE operate at all; only a vouched org may.
|
||||
if code, _ = bind("acme", "tenant-site", "api.hanzo.app"); code != http.StatusForbidden {
|
||||
// …and it cannot claim a host WE operate at all; only a vouched caller may.
|
||||
if code, _ = bind(caller{org: "acme", orgAdmin: true}, "tenant-site", "api.hanzo.app"); code != http.StatusForbidden {
|
||||
t.Fatalf("lookalike claim of our own host = %d, want 403", code)
|
||||
}
|
||||
// The real operator keeps its vouch: bound live, no challenge owed.
|
||||
code, v = bind("Acme", "operator-site", "operator.example")
|
||||
code, v = bind(caller{org: "Acme", orgAdmin: true}, "operator-site", "operator.example")
|
||||
if code != http.StatusOK || !v.Verified || v.Status != "live" {
|
||||
t.Fatalf("operator %q lost its vouch: code=%d %+v", "Acme", code, v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOursNeverEntersHostTable is the storage backstop behind the claim gate: a
|
||||
// name the platform holds can never PHYSICALLY enter site_hosts, whatever the
|
||||
// caller — so a vouch that skips the gate still cannot write one.
|
||||
//
|
||||
// It could. bindHost asked sites.IsReserved with the full hostname while that set
|
||||
// holds bare LABELS, so every FQDN matched nothing: `login.hanzo.ai` sailed past
|
||||
// the only guard behind ours() and took a first-come row on our own auth apex.
|
||||
// Now it asks sites.Ours, which splits on shape.
|
||||
//
|
||||
// The other half matters as much: the label policy must NEVER reach a customer's
|
||||
// own hostname. `www.` and `login.` are reserved LABELS on our apex and are also
|
||||
// the two most common custom domains a customer brings, so a backstop that keyed
|
||||
// on "first label is reserved" would refuse the ordinary case.
|
||||
func TestOursNeverEntersHostTable(t *testing.T) {
|
||||
sites.SetSelfDomains([]string{"hanzo.app", "hanzo.ai"})
|
||||
t.Cleanup(func() { sites.SetSelfDomains(nil) })
|
||||
ctx := context.Background()
|
||||
log := luxlog.New("test")
|
||||
store := newTestStore(t)
|
||||
svc := &cloud.Service[state]{
|
||||
Base: cloud.Base{Log: log},
|
||||
State: state{
|
||||
apex: "hanzo.app", store: store, cf: sites.NewPurger(log),
|
||||
operatorOrgs: map[string]bool{"hanzo": true},
|
||||
},
|
||||
}
|
||||
bind := domainsApp(t, svc)
|
||||
if err := store.CreateProject(ctx, mkProject("hanzo", "site", "Site")); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// A SuperAdmin — the most privileged caller there is, and the one whose vouch
|
||||
// skips ours() entirely. The table still refuses our own names.
|
||||
super := caller{org: "hanzo", superAdmin: true}
|
||||
for _, h := range []string{"login.hanzo.ai", "api.hanzo.ai", "hanzo.ai", "anything.hanzo.app"} {
|
||||
if code, _ := bind(super, "site", h); code != http.StatusBadRequest {
|
||||
t.Errorf("bind %q = %d, want 400 — a host we run reached site_hosts", h, code)
|
||||
}
|
||||
if _, err := store.ResolveHost(ctx, h); err == nil {
|
||||
t.Errorf("%q holds a row in site_hosts", h)
|
||||
}
|
||||
}
|
||||
// A REAL customer domain is unaffected, including the labels our own apex
|
||||
// reserves. This is the regression the shape split exists to avoid.
|
||||
for _, h := range []string{"www.example.com", "login.example-bank.com", "api.yadota.tech"} {
|
||||
if code, v := bind(super, "site", h); code != http.StatusOK || !v.Verified {
|
||||
t.Errorf("customer domain %q = %d %+v, want 200 live — the LABEL policy must "+
|
||||
"never apply to a hostname a customer owns", h, code, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hostname syntax is internal/fqdn's contract now, and fqdn_test.go pins every
|
||||
// case this file used to assert (plus the trailing root dot and the 253-byte
|
||||
// bound, neither of which this path used to handle). Re-asserting them here would
|
||||
|
||||
@@ -89,14 +89,16 @@ type state struct {
|
||||
store *Store
|
||||
blob *blobStore
|
||||
cf *sites.Purger
|
||||
// operatorOrgs may bind a CUSTOM domain to their sites WITHOUT proving they
|
||||
// own it, in addition to a global admin — the platform operator (the
|
||||
// deployment's own brand org) manages customer DNS, so its bind is the vouch.
|
||||
// Env CLOUD_PLATFORM_OPERATOR_ORGS (comma-separated) overrides; default is the
|
||||
// brand org (hanzo). Keyed by the VERBATIM validated IAM owner — the same value
|
||||
// org() resolves, never a fold (operatorOrgsFromEnv says why). Every OTHER org
|
||||
// self-serves: it claims the host pending and proves control with the DNS
|
||||
// challenge (domains.go).
|
||||
// operatorOrgs are the orgs whose ADMINS may bind a CUSTOM domain to their sites
|
||||
// WITHOUT proving they own it, in addition to a SuperAdmin — the platform
|
||||
// operator (the deployment's own brand org) manages customer DNS, so its admin's
|
||||
// bind is the vouch. Env CLOUD_PLATFORM_OPERATOR_ORGS (comma-separated)
|
||||
// overrides; default is the brand org (hanzo). Keyed by the VERBATIM validated
|
||||
// IAM owner — the same value org() resolves, never a fold (operatorOrgsFromEnv
|
||||
// says why). Membership of one of these orgs grants NOTHING on its own: the set
|
||||
// is one half of a conjunction whose other half is IAM's org-admin bit (see
|
||||
// domains.go vouches). Every OTHER caller self-serves: it claims the host pending
|
||||
// and proves control with the DNS challenge (domains.go).
|
||||
operatorOrgs map[string]bool
|
||||
// resolver reads the custom-domain ownership challenge (domains.go); nil ⇒ the
|
||||
// system resolver. Tests inject a fake so verification is deterministic.
|
||||
|
||||
@@ -192,7 +192,7 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/platform/sites/:slug/domains", zip.Doc{
|
||||
Description: "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a\nplatform admin or the platform-operator org — which manages customer DNS, so\nits bind IS the vouch — binds VERIFIED immediately; any other org has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a reserved label is a 400. Claims and binds are idempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
Description: "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
Fields: map[string]string{
|
||||
"Record.name": "the record name the customer creates",
|
||||
"Record.type": "TXT | CNAME",
|
||||
@@ -270,7 +270,7 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/projects/:slug/domains", zip.Doc{
|
||||
Description: "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a\nplatform admin or the platform-operator org — which manages customer DNS, so\nits bind IS the vouch — binds VERIFIED immediately; any other org has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a reserved label is a 400. Claims and binds are idempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
Description: "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
Fields: map[string]string{
|
||||
"Record.name": "the record name the customer creates",
|
||||
"Record.type": "TXT | CNAME",
|
||||
|
||||
+14
-8
@@ -83995,16 +83995,19 @@ paths:
|
||||
Attaches one or more CUSTOM public hostnames to this org's site.
|
||||
|
||||
Binding a host you do not own would let you shadow it at the edge, so which
|
||||
outcome you get depends on whether ownership is already established: a
|
||||
platform admin or the platform-operator org — which manages customer DNS, so
|
||||
its bind IS the vouch — binds VERIFIED immediately; any other org has the host
|
||||
outcome you get depends on whether ownership is already established: a caller
|
||||
vouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages
|
||||
customer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;
|
||||
every other caller, INCLUDING a plain member of an operator org, has the host
|
||||
CLAIMED as pending and gets the DNS challenge back in `bound[].records`. A
|
||||
pending claim HOLDS the name so nobody else can take it, but it does not route
|
||||
until POST .../domains/{host}/verify proves control.
|
||||
|
||||
A hostname we operate is refused to a non-vouched caller (those are assigned
|
||||
by the platform, never claimed), a host another site already holds is a 409,
|
||||
and a reserved label is a 400. Claims and binds are idempotent for the same
|
||||
and a name the platform holds is a 400 for EVERY caller — a vouch skips the
|
||||
ownership proof, never the host table's own invariant. Claims and binds are
|
||||
idempotent for the same
|
||||
(org, slug), and re-claiming returns the SAME token rather than invalidating a
|
||||
record the customer has already published. The edge cache-tag is flushed
|
||||
afterwards so a newly-verified host serves the current build immediately.
|
||||
@@ -85387,16 +85390,19 @@ paths:
|
||||
Attaches one or more CUSTOM public hostnames to this org's site.
|
||||
|
||||
Binding a host you do not own would let you shadow it at the edge, so which
|
||||
outcome you get depends on whether ownership is already established: a
|
||||
platform admin or the platform-operator org — which manages customer DNS, so
|
||||
its bind IS the vouch — binds VERIFIED immediately; any other org has the host
|
||||
outcome you get depends on whether ownership is already established: a caller
|
||||
vouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages
|
||||
customer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;
|
||||
every other caller, INCLUDING a plain member of an operator org, has the host
|
||||
CLAIMED as pending and gets the DNS challenge back in `bound[].records`. A
|
||||
pending claim HOLDS the name so nobody else can take it, but it does not route
|
||||
until POST .../domains/{host}/verify proves control.
|
||||
|
||||
A hostname we operate is refused to a non-vouched caller (those are assigned
|
||||
by the platform, never claimed), a host another site already holds is a 409,
|
||||
and a reserved label is a 400. Claims and binds are idempotent for the same
|
||||
and a name the platform holds is a 400 for EVERY caller — a vouch skips the
|
||||
ownership proof, never the host table's own invariant. Claims and binds are
|
||||
idempotent for the same
|
||||
(org, slug), and re-claiming returns the SAME token rather than invalidating a
|
||||
record the customer has already published. The edge cache-tag is flushed
|
||||
afterwards so a newly-verified host serves the current build immediately.
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_platform_sites_slug_domains",
|
||||
"summary": "Attaches one or more CUSTOM public hostnames to this org's site.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a\nplatform admin or the platform-operator org — which manages customer DNS, so\nits bind IS the vouch — binds VERIFIED immediately; any other org has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a reserved label is a 400. Claims and binds are idempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"tags": [
|
||||
"platform"
|
||||
],
|
||||
@@ -989,7 +989,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_projects_slug_domains",
|
||||
"summary": "Attaches one or more CUSTOM public hostnames to this org's site.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a\nplatform admin or the platform-operator org — which manages customer DNS, so\nits bind IS the vouch — binds VERIFIED immediately; any other org has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a reserved label is a 400. Claims and binds are idempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"tags": [
|
||||
"projects"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user