Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f0b74fe9a | ||
|
|
d02eb45d6e | ||
|
|
c587fcd07a | ||
|
|
8b729f8ef1 | ||
|
|
b66c9a2576 |
@@ -272,6 +272,13 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
// reads it per request.
|
||||
sites.SetResolver(siteResolver{store: store})
|
||||
|
||||
// ...and publish the SAME resolver on the internal plane, because in
|
||||
// production the edge is never in this process (the pod boots ~25 single-app
|
||||
// processes, so the registry above is nil wherever it is read). Keep both:
|
||||
// co-resident takes the in-process answer with no hop, split takes the plane.
|
||||
setResolverForPlane(siteResolver{store: store})
|
||||
exposeSites()
|
||||
|
||||
// Register the store as a project-ownership resolver for the identity trust
|
||||
// boundary (cloud.SanitizeIdentity), so a forged cross-org X-Project-Id is
|
||||
// refused before any subsystem reads it. Same inversion as sites.SetResolver —
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
cloud "github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/sites"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// The site edge asks projects which published site a host belongs to.
|
||||
//
|
||||
// sites.SetResolver already installs an in-process resolver at Mount, and that
|
||||
// stays — when the edge and projects happen to share a process it is the right
|
||||
// answer, with no hop. But the pod boots ~25 SINGLE-app processes, so in
|
||||
// production they never do: the registry was set inside `projects` and read
|
||||
// inside whichever process fronts :8000, where it is nil. A nil registry is a
|
||||
// clean miss rather than a fault, so every published site resolved as not-found
|
||||
// with no error anywhere and fell through to the API pipeline — every
|
||||
// <slug>.hanzo.app served the console SPA, and the whole cloud API answered on
|
||||
// the customer's own hostname. Measured at the pod with the ingress bypassed.
|
||||
//
|
||||
// This is the same seam FinanceScopeRules already uses and for the same stated
|
||||
// reason: the READER is a cloud edge middleware and the fact belongs to another
|
||||
// app. The store read stays in the one process that owns the store.
|
||||
//
|
||||
// No org is taken from the caller on the multi-tenant path. The host IS the
|
||||
// tenant key here, so accepting an org would let a caller name someone else's
|
||||
// project; ResolveOrg pins an org only for the first-party path, which is
|
||||
// exactly what it is for.
|
||||
func exposeSites() {
|
||||
zip.Post[plane.SiteIn, plane.Site](cloud.Plane(), "/sites/resolve", planeResolveSite,
|
||||
zip.WithOperationID(plane.SitesResolve),
|
||||
zip.WithSummary("Resolve a published site by host label"))
|
||||
|
||||
zip.Post[plane.SiteIn, plane.Site](cloud.Plane(), "/sites/resolve-org", planeResolveSiteOrg,
|
||||
zip.WithOperationID(plane.SitesResolveOrg),
|
||||
zip.WithSummary("Resolve a published site pinned to one org"))
|
||||
}
|
||||
|
||||
// planeResolveSite answers the multi-tenant product URL (<slug>.hanzo.app) and
|
||||
// bound custom domains. Not-found is `Found:false`, never an error: the edge
|
||||
// turns that into an honest 404, and an error into a 503. Collapsing the two
|
||||
// would serve 404s for real live sites during a transient failure.
|
||||
func planeResolveSite(ctx context.Context, in *plane.SiteIn) (*plane.Site, error) {
|
||||
r, err := currentResolver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, ok, err := r.Resolve(ctx, in.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wireSite(s, ok), nil
|
||||
}
|
||||
|
||||
// planeResolveSiteOrg is the first-party path: it NEVER falls back to
|
||||
// unique-across-orgs, so an internal host is served only by our own project and
|
||||
// never a customer's same-named one.
|
||||
func planeResolveSiteOrg(ctx context.Context, in *plane.SiteIn) (*plane.Site, error) {
|
||||
r, err := currentResolver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, ok, err := r.ResolveOrg(ctx, in.Org, in.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wireSite(s, ok), nil
|
||||
}
|
||||
|
||||
// The resolver this process serves plane answers from. Set at Mount beside
|
||||
// sites.SetResolver, so the two can never name different stores.
|
||||
var planeResolver siteResolver
|
||||
|
||||
func setResolverForPlane(r siteResolver) { planeResolver = r }
|
||||
|
||||
// currentResolver refuses rather than answering not-found when the store is
|
||||
// absent. A process that has not mounted projects cannot know whether a site
|
||||
// exists, and saying "no" would take every live site off the air with a 404.
|
||||
func currentResolver() (siteResolver, error) {
|
||||
if planeResolver.store == nil {
|
||||
return siteResolver{}, zip.ErrInternal("sites: this process does not own the project store")
|
||||
}
|
||||
return planeResolver, nil
|
||||
}
|
||||
|
||||
// wireSite projects a resolved Site onto the wire shape, carrying found-ness
|
||||
// explicitly so the edge can tell "no such site" from "could not ask".
|
||||
func wireSite(s sites.Site, ok bool) *plane.Site {
|
||||
if !ok {
|
||||
return &plane.Site{Found: false}
|
||||
}
|
||||
return &plane.Site{
|
||||
Found: true,
|
||||
Org: s.Org,
|
||||
Slug: s.Slug,
|
||||
Bucket: s.Bucket,
|
||||
Prefix: s.Prefix,
|
||||
Status: s.Status,
|
||||
CrossOriginIsolation: s.CrossOriginIsolation,
|
||||
}
|
||||
}
|
||||
+70
-5
@@ -84,22 +84,49 @@ type Resolver interface {
|
||||
var (
|
||||
resolverMu sync.RWMutex
|
||||
resolver Resolver
|
||||
fallback Resolver
|
||||
)
|
||||
|
||||
// SetResolver installs the slug→Site resolver. projects.Mount calls this once
|
||||
// with its store. Until it is set, every site request is an honest 404 (the
|
||||
// projects subsystem is not mounted), never a crash.
|
||||
// with its store — the no-hop answer when the edge and projects share a process.
|
||||
func SetResolver(r Resolver) {
|
||||
resolverMu.Lock()
|
||||
resolver = r
|
||||
resolverMu.Unlock()
|
||||
}
|
||||
|
||||
// SetFallbackResolver installs the resolver used when projects is NOT in this
|
||||
// process. cloud's composition root sets it to a plane-backed client.
|
||||
//
|
||||
// It exists because in production they are never in the same process: the pod
|
||||
// boots ~25 single-app processes, so the registry above was written inside
|
||||
// `projects` and read inside whichever process fronts :8000, where it is nil. A
|
||||
// nil registry is a clean miss, not a fault — so every published site resolved
|
||||
// as not-found with no error anywhere, fell through to the API pipeline, and
|
||||
// <slug>.hanzo.app served the console SPA with the whole cloud API answering on
|
||||
// the customer's own hostname. Measured at the pod, ingress bypassed.
|
||||
//
|
||||
// The old comment here read "until it is set, every site request is an honest
|
||||
// 404 (the projects subsystem is not mounted)". That premise was the bug:
|
||||
// projects IS mounted, just somewhere else, and 404 is not honest when the site
|
||||
// exists.
|
||||
func SetFallbackResolver(r Resolver) {
|
||||
resolverMu.Lock()
|
||||
fallback = r
|
||||
resolverMu.Unlock()
|
||||
}
|
||||
|
||||
// currentResolver prefers the in-process store and falls back to the plane. A
|
||||
// process that owns the store never pays for a hop; one that does not can still
|
||||
// answer, instead of silently serving the API for every customer's site.
|
||||
func currentResolver() Resolver {
|
||||
resolverMu.RLock()
|
||||
r := resolver
|
||||
r, fb := resolver, fallback
|
||||
resolverMu.RUnlock()
|
||||
return r
|
||||
if r != nil {
|
||||
return r
|
||||
}
|
||||
return fb
|
||||
}
|
||||
|
||||
// Config configures the site host-router. Apex is the zone whose subdomains are
|
||||
@@ -298,9 +325,47 @@ func analyticsIngest(c *zip.Ctx) (func(org string, c *zip.Ctx) error, bool) {
|
||||
return h, ok && h != nil
|
||||
}
|
||||
|
||||
// requestHost is the ONE way this server learns which host was asked for.
|
||||
//
|
||||
// fiber parses the request URI once, and behind the ingress the parsed host is
|
||||
// EMPTY — so Hostname() alone resolved nothing, every published site fell
|
||||
// through to c.Continue(), and <slug>.hanzo.app served the console SPA with the
|
||||
// whole cloud API mounted under a customer's own hostname. Measured 2026-08-03:
|
||||
// quest.hanzo.app returned <title>Hanzo Cloud Console and
|
||||
// quest.hanzo.app/v1/billing/plans returned 200. Same accessor, same failure as
|
||||
// commerce's tenant resolver earlier the same night.
|
||||
//
|
||||
// The parsed host ALWAYS wins. X-Forwarded-Host is consulted only when there is
|
||||
// no parsed host at all, which is exactly the ingress case and never a direct
|
||||
// request. That ordering is the security property, not a detail: the host picks
|
||||
// the ORG here, so a client that could override a real host could serve itself
|
||||
// another tenant's site. TestMiddlewareTenantKeyedByHostNotPath pins it — a
|
||||
// request that HAS a host ignores the header completely.
|
||||
func (s *Server) requestHost(c *zip.Ctx) string {
|
||||
// A parsed host that names a site (or a bindable custom domain) is the
|
||||
// truth and is never overridden. Anything else — empty behind the ingress,
|
||||
// or the ingress' own service name — is not a host this server can serve,
|
||||
// so the forwarded name is the only candidate left.
|
||||
parsed := hostOnly(c.Fiber().Hostname())
|
||||
if parsed != "" {
|
||||
if _, _, ok := s.siteSlug(parsed); ok {
|
||||
return parsed
|
||||
}
|
||||
if s.customCandidate(parsed) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
// Left-most entry: proxies append, so the first is the client-facing name.
|
||||
fwd := c.Header("X-Forwarded-Host")
|
||||
if i := strings.IndexByte(fwd, ','); i >= 0 {
|
||||
fwd = fwd[:i]
|
||||
}
|
||||
return hostOnly(fwd)
|
||||
}
|
||||
|
||||
func (s *Server) Middleware() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
raw := c.Fiber().Hostname()
|
||||
raw := s.requestHost(c)
|
||||
if slug, firstParty, ok := s.siteSlug(raw); ok {
|
||||
if baseHostHandler != nil && isBasePath(c.Path()) {
|
||||
if site, ok := s.resolveLivePinned(c.Context(), slug, firstParty); ok {
|
||||
|
||||
@@ -604,3 +604,100 @@ func TestNotFoundAnswersDataRequestsInType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behind the ingress the parsed URI host is EMPTY and the only carrier of the
|
||||
// customer-facing name is X-Forwarded-Host. Without this, siteSlug("") failed,
|
||||
// customCandidate("") failed, and every published site fell through to the API
|
||||
// pipeline — <slug>.hanzo.app served the console SPA and mounted the whole cloud
|
||||
// API under a customer's own hostname (measured live 2026-08-03).
|
||||
func TestMiddlewareResolvesFromForwardedHostWhenParsedHostIsEmpty(t *testing.T) {
|
||||
fr := &fakeResolver{found: false}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
// The shape behind the ingress: the parsed host is not a site host (the
|
||||
// ingress' own name), and the customer-facing name rides X-Forwarded-Host.
|
||||
// NOTE httptest synthesizes "localhost" for an empty Host, so an empty
|
||||
// string cannot be used to express "no parsed host" — measured, not assumed.
|
||||
req := httptest.NewRequest("GET", "http://localhost/index.html", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "quest.hanzo.app")
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("test: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Sentinel") == "hit" {
|
||||
t.Fatal("a published site fell through to the API pipeline — this is the console-instead-of-site defect")
|
||||
}
|
||||
if got := fr.slugs(); len(got) != 1 || got[0] != "quest" {
|
||||
t.Fatalf("resolver called with %v, want exactly [quest]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ...and the fallback must never become an override. A request that HAS a host
|
||||
// ignores the header completely — the host picks the ORG, so a client able to
|
||||
// override a real host could serve itself another tenant's site.
|
||||
func TestMiddlewareForwardedHostNeverOverridesARealHost(t *testing.T) {
|
||||
fr := &fakeResolver{found: false}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
req := httptest.NewRequest("GET", "http://victim.hanzo.app/index.html", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "attacker.hanzo.app")
|
||||
if _, err := app.Fiber().Test(req); err != nil {
|
||||
t.Fatalf("test: %v", err)
|
||||
}
|
||||
if got := fr.slugs(); len(got) != 1 || got[0] != "victim" {
|
||||
t.Fatalf("resolver called with %v, want exactly [victim] — the header must not override a real host", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The edge must resolve a site when projects is in ANOTHER process.
|
||||
//
|
||||
// This is the production shape and it is the defect this fallback exists for:
|
||||
// the pod boots ~25 single-app processes, so the in-process registry is nil at
|
||||
// the edge. A nil resolver is a clean miss, so every published site fell through
|
||||
// to the API pipeline and <slug>.hanzo.app served the console SPA — with no
|
||||
// error logged anywhere, because nothing had failed.
|
||||
func TestFallbackResolverServesWhenProjectsIsElsewhere(t *testing.T) {
|
||||
SetResolver(nil) // projects is NOT in this process — the production case.
|
||||
fb := &fakeResolver{found: false}
|
||||
SetFallbackResolver(fb)
|
||||
defer SetFallbackResolver(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
req := httptest.NewRequest("GET", "http://quest.hanzo.app/index.html", nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("test: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Sentinel") == "hit" {
|
||||
t.Fatal("fell through to the API pipeline — this is the console-instead-of-site defect")
|
||||
}
|
||||
if got := fb.slugs(); len(got) != 1 || got[0] != "quest" {
|
||||
t.Fatalf("fallback called with %v, want exactly [quest]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A co-resident store still answers WITHOUT the hop: the in-process resolver
|
||||
// wins whenever it is set, so sharing a process costs nothing.
|
||||
func TestInProcessResolverWinsOverTheFallback(t *testing.T) {
|
||||
inproc := &fakeResolver{found: false}
|
||||
fb := &fakeResolver{found: false}
|
||||
SetResolver(inproc)
|
||||
SetFallbackResolver(fb)
|
||||
defer func() { SetResolver(nil); SetFallbackResolver(nil) }()
|
||||
app := newTestApp(testServer())
|
||||
|
||||
req := httptest.NewRequest("GET", "http://quest.hanzo.app/index.html", nil)
|
||||
if _, err := app.Fiber().Test(req); err != nil {
|
||||
t.Fatalf("test: %v", err)
|
||||
}
|
||||
if len(inproc.slugs()) != 1 {
|
||||
t.Errorf("in-process resolver was not used: %v", inproc.slugs())
|
||||
}
|
||||
if n := len(fb.slugs()); n != 0 {
|
||||
t.Errorf("fallback was consulted %d times; the in-process store must win", n)
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -265,7 +265,7 @@ func (c *idClaims) homeOrg() string {
|
||||
if c.subjectOrg != "" {
|
||||
return c.subjectOrg // API key: resolved from the subject
|
||||
}
|
||||
if isKMSMachinePrincipal(c) {
|
||||
if isKMSMachinePrincipal(c) || isClientCredentialsPrincipal(c) {
|
||||
return c.Owner // machine JWT: the app IS the principal
|
||||
}
|
||||
// Everything else is the estate rule: the first entry of the signed membership
|
||||
@@ -275,6 +275,53 @@ func (c *idClaims) homeOrg() string {
|
||||
return c.Claims.Home()
|
||||
}
|
||||
|
||||
// isClientCredentialsPrincipal reports whether a validated token was minted by the
|
||||
// client_credentials grant — an application authenticating AS ITSELF, with no user
|
||||
// behind it.
|
||||
//
|
||||
// It is the same fact isKMSMachinePrincipal establishes, for every other app. KMS
|
||||
// could be recognised by audience alone because its client id is DERIVED from the org
|
||||
// it belongs to ("<org>-platform-kms"), so the audience proves the pairing. No other
|
||||
// app's id is derivable that way, so the recognition has to come from the token's
|
||||
// SHAPE instead.
|
||||
//
|
||||
// The shape is not forgeable by a human token. In a client_credentials token the
|
||||
// client IS the subject: IAM sets sub to "<org>/<app>", and azp — the authorized
|
||||
// party, i.e. the client that obtained the token — equals the sole audience, because
|
||||
// the app requested a token for itself. A human's token cannot look like that: its
|
||||
// subject is the user, and azp names whichever app they signed in through, which is
|
||||
// the very mis-attribution homeOrg exists to prevent. And every field read here is
|
||||
// signed by IAM; none is a header a caller can set.
|
||||
//
|
||||
// WHY THIS MATTERS, measured. studio authenticates this way. Its token carries
|
||||
// owner=hanzo and organization=hanzo but no `orgs` — correct-by-design for a machine,
|
||||
// exactly as an sk- key carries none — so Home() returned "" and SanitizeIdentity
|
||||
// minted X-User-Id with no X-Org-Id. Every org-scoped gate then refused it, and the
|
||||
// one that mattered was the durable queue: `POST /v1/tasks/.../activities` answered
|
||||
// 403 "identity required", so no render could be enqueued at all. Thirteen jobs sat
|
||||
// `queued` in studio's worklog for up to 19 hours while both GPUs polled an empty
|
||||
// namespace every two seconds and reported themselves healthy.
|
||||
//
|
||||
// It is NOT a widening of who may cross tenants. This resolves an org for a principal
|
||||
// that already has exactly one and can no more choose it than an API key can: `owner`
|
||||
// is the application's own organization, set by IAM when the app was created, and
|
||||
// obtaining the token at all requires that application's client secret. A human with
|
||||
// no `orgs` still resolves nothing and still fails closed — the case the estate rule
|
||||
// exists for is untouched.
|
||||
func isClientCredentialsPrincipal(c *idClaims) bool {
|
||||
if c == nil || c.Owner == "" || c.Azp == "" {
|
||||
return false
|
||||
}
|
||||
// The client obtained a token FOR ITSELF: one audience, and it is the client.
|
||||
if len(c.Audience) != 1 || c.Audience[0] != c.Azp {
|
||||
return false
|
||||
}
|
||||
// And it IS the subject: "<org>/<app>", naming that same client.
|
||||
sub := c.Subject
|
||||
i := strings.LastIndex(sub, "/")
|
||||
return i > 0 && sub[i+1:] == c.Azp
|
||||
}
|
||||
|
||||
// isKMSMachinePrincipal reports whether a validated token is a per-org KMS-sync
|
||||
// machine identity: its audience set contains the owner-bound machine audience
|
||||
// (<owner>-platform-kms). Such a principal is a client_credentials machine identity
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package cloud
|
||||
|
||||
// The client_credentials principal: an application authenticating AS ITSELF.
|
||||
//
|
||||
// homeOrg resolves such a token to its `owner` — the application's own organization —
|
||||
// because a machine cannot choose an org the way a human choosing an app can. That is
|
||||
// the same reasoning the KMS branch already rests on; these tests exist because the
|
||||
// recognition is by SHAPE rather than by a derivable audience, so every part of the
|
||||
// shape needs a test that fails when it is loosened.
|
||||
//
|
||||
// The dangerous direction is second-to-last: a HUMAN with no `orgs` must still resolve
|
||||
// nothing. That is the estate rule, it is what makes an org-less request fail closed
|
||||
// everywhere, and widening machine recognition must never reach it.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hanzoai/authz"
|
||||
)
|
||||
|
||||
// machineClaims builds a client_credentials token as IAM mints one: the client is the
|
||||
// subject ("<org>/<app>"), the sole audience, and the authorized party — and there is
|
||||
// no membership set, which is correct for a machine rather than a degraded token.
|
||||
func machineClaims(app, owner string, exp time.Time) idClaims {
|
||||
c := idClaims{Claims: authz.Claims{Owner: owner, Organization: owner, Azp: app}}
|
||||
c.Issuer = testIssuer
|
||||
c.Subject = "admin/" + app
|
||||
c.Audience = jwt.ClaimStrings{app}
|
||||
c.ExpiresAt = jwt.NewNumericDate(exp)
|
||||
c.IssuedAt = jwt.NewNumericDate(time.Now())
|
||||
return c
|
||||
}
|
||||
|
||||
func TestClientCredentialsPrincipalResolvesItsOwnerOrg(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", time.Now().Add(time.Hour))
|
||||
|
||||
if !isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("a token whose client is its own subject and sole audience is a machine principal")
|
||||
}
|
||||
if got := c.homeOrg(); got != "hanzo" {
|
||||
t.Fatalf("homeOrg=%q, want hanzo — a machine's org is its owner, which it cannot choose", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The failure this fixes, stated as the thing that used to happen.
|
||||
//
|
||||
// studio's token carries owner=hanzo and no `orgs`, so Home() returned "" and
|
||||
// SanitizeIdentity minted X-User-Id with no X-Org-Id. Every org-scoped gate refused
|
||||
// it; the one that mattered was the durable queue, which answered 403 "identity
|
||||
// required" — so no render could be enqueued at all, and thirteen jobs sat queued for
|
||||
// up to nineteen hours while both GPUs polled an empty namespace and reported healthy.
|
||||
func TestMachineTokenWithNoOrgsIsNotOrgless(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", time.Now().Add(time.Hour))
|
||||
if len(c.Orgs) != 0 {
|
||||
t.Fatal("precondition: a machine token carries no membership set")
|
||||
}
|
||||
if c.Claims.Home() != "" {
|
||||
t.Fatal("precondition: the estate rule alone resolves nothing here")
|
||||
}
|
||||
if c.homeOrg() == "" {
|
||||
t.Fatal("an org-less machine principal reaches every org gate as anonymous and is refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A HUMAN with no membership set still resolves nothing. This is the rule the machine
|
||||
// branches must never reach: a human's org follows their token, and a token that
|
||||
// proves no membership proves no org.
|
||||
func TestHumanWithoutOrgsStillFailsClosed(t *testing.T) {
|
||||
c := idClaims{Claims: authz.Claims{Owner: "hanzo", Azp: "hanzo-console"}}
|
||||
c.Issuer = testIssuer
|
||||
c.Subject = "u-alice" // a USER, not "<org>/<app>"
|
||||
c.Audience = jwt.ClaimStrings{"hanzo-console"}
|
||||
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour))
|
||||
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("a human subject must never be read as a machine, whatever else matches")
|
||||
}
|
||||
if got := c.homeOrg(); got != "" {
|
||||
t.Fatalf("homeOrg=%q, want empty — falling back to owner is the mis-attribution this prevents", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Each half of the shape is load-bearing. A token that matches only part of it is a
|
||||
// human token that happens to resemble a machine, and must not be admitted.
|
||||
func TestPartialMachineShapeIsRefused(t *testing.T) {
|
||||
exp := time.Now().Add(time.Hour)
|
||||
|
||||
t.Run("azp is not the audience — the token was issued for a DIFFERENT client", func(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", exp)
|
||||
c.Audience = jwt.ClaimStrings{"some-other-app"}
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("a client holding a token minted for another audience is not that audience")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("more than one audience — not a token an app got for itself", func(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", exp)
|
||||
c.Audience = jwt.ClaimStrings{"hanzo-studio", "hanzo-console"}
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("a multi-audience token is not the self-issued shape")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("subject does not name the client — a user signed in THROUGH the app", func(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", exp)
|
||||
c.Subject = "admin/somebody-else"
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("the subject must BE the client; anything else is a human using it")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no owner — nothing to resolve, and no guess to make", func(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "", exp)
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("without an owner there is no org to return")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare subject with no org segment", func(t *testing.T) {
|
||||
c := machineClaims("hanzo-studio", "hanzo", exp)
|
||||
c.Subject = "hanzo-studio"
|
||||
if isClientCredentialsPrincipal(&c) {
|
||||
t.Fatal("<org>/<app> is the shape; a bare name is not it")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -435,7 +435,7 @@ require (
|
||||
github.com/hanzoai/go-openrouter v1.0.0 // indirect
|
||||
github.com/hanzoai/goauthorizenet v1.0.0 // indirect
|
||||
github.com/hanzoai/gochimp3 v1.0.0 // indirect
|
||||
github.com/hanzoai/orm v0.6.19
|
||||
github.com/hanzoai/orm v0.6.21
|
||||
github.com/hanzoai/pdf v1.2.0 // indirect
|
||||
github.com/hanzoai/pubsub-go v1.53.0
|
||||
github.com/hanzoai/search-go v0.36.0 // indirect
|
||||
|
||||
@@ -1079,6 +1079,8 @@ github.com/hanzoai/orm v0.6.18 h1:yRVsJBp6xAzrugjxUWjK0OYY/f3BIIYTVcOYQ4nbbO4=
|
||||
github.com/hanzoai/orm v0.6.18/go.mod h1:PqGd9Jv+BOeBZ4UkR7lyb9hy45ROgF+EHk/fXFgwb4o=
|
||||
github.com/hanzoai/orm v0.6.19 h1:HuCFCRndqGtIQU2BInZLCnFnB3H8o2ovf/KvvFZG1O8=
|
||||
github.com/hanzoai/orm v0.6.19/go.mod h1:PqGd9Jv+BOeBZ4UkR7lyb9hy45ROgF+EHk/fXFgwb4o=
|
||||
github.com/hanzoai/orm v0.6.21 h1:iqH7h8eUD3uDRKuga5jQJqAC+6oQRxXoApUJFQd+i38=
|
||||
github.com/hanzoai/orm v0.6.21/go.mod h1:mnnchBPY8Z0gEsI0qzBWQnFKj3LerqHOk7wfoOn6MNA=
|
||||
github.com/hanzoai/otel-collector v1.2.0 h1:lBDL5lKotq89JaqchcM+/oxEnjrjazEI2JJfDhotudc=
|
||||
github.com/hanzoai/otel-collector v1.2.0/go.mod h1:rNCSDd4fw23kCi5UQ4u2IGhSYifYMex5O6SwZGHtIPE=
|
||||
github.com/hanzoai/pdf v1.2.0 h1:3/zYs4estaf6hpa3Ug/NgfYEQpmkmMClmRcREAP8KUo=
|
||||
|
||||
@@ -47,6 +47,20 @@ import (
|
||||
// The token is the op's operationId, which is also its OpenAPI operation, its
|
||||
// MCP tool name and its CLI command — one identity across every projection.
|
||||
const (
|
||||
// SitesResolve / SitesResolveOrg answer "which published site is this host?"
|
||||
// for the site EDGE, which is the same reason FinanceScopeRules is here: the
|
||||
// reader is a cloud edge middleware and the owner of the fact is another app.
|
||||
//
|
||||
// It is on the plane because it HAS to be. The edge middleware and projects
|
||||
// (which owns the project store, and called sites.SetResolver at its Mount)
|
||||
// run in DIFFERENT processes — the pod boots ~25 single-app processes — so a
|
||||
// package-level registry is nil wherever it is consulted. Every published
|
||||
// site therefore resolved as not-found and fell through to the API pipeline,
|
||||
// and <slug>.hanzo.app served the console SPA. Measured at the pod, ingress
|
||||
// bypassed, 2026-08-03.
|
||||
SitesResolve = "sites_resolve"
|
||||
SitesResolveOrg = "sites_resolve_org"
|
||||
|
||||
FinanceAuthorize = "finance_authorize" // the prepaid gate
|
||||
FinanceBalance = "finance_balance"
|
||||
FinanceRecord = "finance_record" // the meter
|
||||
@@ -705,3 +719,27 @@ func BindRuntimeDir() string {
|
||||
_ = os.Setenv("ZIP_RUNTIME_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// SiteIn names a published site to resolve: the host label for the multi-tenant
|
||||
// product URL, or a bound custom domain. Org is set ONLY by the first-party
|
||||
// path (ResolveOrg), which pins the lookup to one org so an internal host is
|
||||
// never served by a customer's same-named project.
|
||||
type SiteIn struct {
|
||||
Slug string `json:"slug"`
|
||||
Org string `json:"org,omitempty"`
|
||||
}
|
||||
|
||||
// Site is a published site's serving facts. Found is explicit: a site that does
|
||||
// not exist is a clean answer, not an error, and the edge must be able to tell
|
||||
// "no such site" (honest 404) from "the owner could not be reached" (503) —
|
||||
// collapsing them is how a transient failure would start serving 404s for real
|
||||
// customers' live sites.
|
||||
type Site struct {
|
||||
Found bool `json:"found"`
|
||||
Org string `json:"org"`
|
||||
Slug string `json:"slug"`
|
||||
Bucket string `json:"bucket"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status string `json:"status"`
|
||||
CrossOriginIsolation bool `json:"crossOriginIsolation"`
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// probe of its own would report the store's reachability, which is already what
|
||||
// every op reports in band as a 503 rather than as an empty answer.
|
||||
func main() {
|
||||
if err := cloud.Serve([]cloud.Plugin{{
|
||||
if err := cloud.Listen([]cloud.Plugin{{
|
||||
Name: "dataset",
|
||||
Price: cloud.Metered,
|
||||
Mount: dataset.Mount,
|
||||
|
||||
@@ -287,6 +287,11 @@ func Listen(plugins []Plugin, enable []string) error {
|
||||
// injected at its Mount via sites.SetResolver; until then a site host 404s
|
||||
// honestly. Org isolation (org+prefix come only from the store keyed by the
|
||||
// validated slug; object keys are rooted-clean) lives in clients/sites.
|
||||
// The edge asks the app that owns the store when it is not in this process,
|
||||
// which in production is always: the pod boots ~25 single-app processes, so
|
||||
// the registry projects.Mount writes is nil here. Co-resident still wins with
|
||||
// no hop — currentResolver prefers the in-process one.
|
||||
sites.SetFallbackResolver(planeSites{})
|
||||
app.Use(sites.New(sites.Config{Apex: cfg.SitesApex, Reserved: cfg.SitesReserved, SelfDomains: cfg.SitesSelfDomains, FirstPartyApex: cfg.SitesFirstPartyApex, FirstPartySites: cfg.SitesFirstPartySites, FirstPartyOrg: cfg.SitesFirstPartyOrg}, deps.Logger).Middleware())
|
||||
|
||||
// Edge policy — the "gateway role" cloud absorbs to serve the public
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/sites"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// planeSites resolves a published site by asking the app that owns the project
|
||||
// store, over the internal plane.
|
||||
//
|
||||
// The site edge middleware runs at the compose root, in whatever process fronts
|
||||
// the public port; the store belongs to `projects`. In production those are
|
||||
// never the same process — the pod boots ~25 single-app processes — so the
|
||||
// package-level registry projects.Mount writes is nil where the edge reads it.
|
||||
// That is why every <slug>.hanzo.app served the console SPA: a nil resolver is a
|
||||
// clean miss, so the request fell through to the API pipeline with no error
|
||||
// anywhere to notice.
|
||||
//
|
||||
// Same seam, same reason, as the balance and scope-rule reads that already cross
|
||||
// this plane: the reader is an edge middleware and the fact belongs elsewhere.
|
||||
type planeSites struct{}
|
||||
|
||||
// Resolve answers the multi-tenant product URL and bound custom domains.
|
||||
//
|
||||
// A site that does not exist comes back Found:false and becomes an honest 404.
|
||||
// A failure to ASK is an error, and stays one — the edge renders 503 for that.
|
||||
// Collapsing the two would serve 404s for real, live customer sites during any
|
||||
// transient failure of the owning app, which looks exactly like the site being
|
||||
// deleted.
|
||||
func (planeSites) Resolve(ctx context.Context, slug string) (sites.Site, bool, error) {
|
||||
return askSite(ctx, &plane.SiteIn{Slug: slug}, plane.SitesResolve)
|
||||
}
|
||||
|
||||
// ResolveOrg is the first-party path, pinned to one org so an internal host is
|
||||
// never served by a customer's same-named project.
|
||||
func (planeSites) ResolveOrg(ctx context.Context, org, slug string) (sites.Site, bool, error) {
|
||||
return askSite(ctx, &plane.SiteIn{Slug: slug, Org: org}, plane.SitesResolveOrg)
|
||||
}
|
||||
|
||||
func askSite(ctx context.Context, in *plane.SiteIn, op string) (sites.Site, bool, error) {
|
||||
// The site plane read is org-less by construction: the HOST is the tenant
|
||||
// key, and the answer names the org. Passing one in would let a caller point
|
||||
// at someone else's project.
|
||||
out, err := Ask[plane.SiteIn, plane.Site](For(ctx, ""), "projects", op, in)
|
||||
if err != nil {
|
||||
return sites.Site{}, false, fmt.Errorf("sites: ask projects: %w", err)
|
||||
}
|
||||
if out == nil {
|
||||
return sites.Site{}, false, fmt.Errorf("sites: projects answered nothing")
|
||||
}
|
||||
if !out.Found {
|
||||
return sites.Site{}, false, nil
|
||||
}
|
||||
return sites.Site{
|
||||
Org: out.Org,
|
||||
Slug: out.Slug,
|
||||
Bucket: out.Bucket,
|
||||
Prefix: out.Prefix,
|
||||
Status: out.Status,
|
||||
CrossOriginIsolation: out.CrossOriginIsolation,
|
||||
}, true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user