Files
ci/scope.go
T
zeekayandHanzo Dev c36ea712a8
Hanzo CI/CD / cicd (push) Waiting to run
CI/CD / gate (push) Waiting to run
name the domain for a namespace it now shares
These types are published through the composed cloud document, whose
schema namespace is FLAT and global: `Service` appears in fifty-three
other app documents, `Run` and `Head` in ten each, and two names that
differ only in case are worse still — every generator PascalCases, so an
SDK binds whichever it read last or refuses to compile.

Three of the five are better names besides:

  Version  -> Artifact   a tag and a digest identifying built bytes is
                         not a version; nothing here is semver.
  Head     -> Tip        its own comment already said "the tip of the
                         default branch".
  Service  -> Pipeline   the row is not a workload, it is the chain
                         behind one: head, built, declared, running.
  Run      -> Execution   } free names for the two that only collided
  Build    -> Check       }

The wire does not move: JSON comes from the tags and those are
untouched, which the shape test asserts. What changes is the name a
generated client gives the class.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 20:20:43 -07:00

96 lines
3.9 KiB
Go

package ci
import (
"net/http"
"strings"
)
// scope.go answers exactly one question: whose builds may THIS request see?
//
// It exists to keep a FILTER and a GATE apart. `?org=lux` narrows what is
// rendered and reads like tenancy, but it decides nothing about who may ask: a
// query parameter is a request for a view, never the authority for one. Conflate
// the two and every org's repo names, branches, commit SHAs and actor logins are
// one URL away from an unauthenticated caller.
//
// The authority is X-Org-Id, minted by admin-guard from the IAM-verified `owner`
// claim and written onto the request by the ingress middleware's
// authResponseHeaders. Traefik OVERWRITES any client-sent X-Org-Id with the
// guard's value, so on the wired path the header cannot be forged. This file
// still treats its ABSENCE as fatal rather than as "no filter", because absence
// is the signal that the request did not come through the guard at all.
// orgHeader is the identity the whole surface is scoped by. One name, one
// meaning, platform-wide (see the X-* header convention: X-Org-Id is the org
// slug from the JWT `owner` claim).
const orgHeader = "X-Org-Id"
// viewer is the resolved, trusted answer. Constructed only from headers the
// guard controls — never from the query string, never from a cookie.
type viewer struct {
// org is the caller's home org slug, from the verified `owner` claim.
org string
// sudo reports whether org is the platform admin org, which is the ONE
// identity that may see across tenants (the fleet view).
sudo bool
}
// resolveViewer lifts the guard-set header into a viewer. It fails closed: a
// missing or blank X-Org-Id yields ok=false and the caller MUST refuse the
// request.
//
// Defaulting an absent header to "no filter" is the specific bug this function
// exists to prevent — that default is what turns "reached ci without the guard"
// into "rendered every org's builds".
func resolveViewer(r *http.Request, adminOrg string) (viewer, bool) {
org := strings.TrimSpace(r.Header.Get(orgHeader))
if org == "" {
return viewer{}, false
}
return viewer{org: org, sudo: strings.EqualFold(org, strings.TrimSpace(adminOrg))}, true
}
// visible narrows runs to what v is permitted to see, then applies want (the
// optional `?org=` selection) WITHIN that permission.
//
// The ordering is the whole point: permission is applied first and `want` can
// only ever narrow the result. A lux viewer asking for `?org=hanzo` gets an
// empty list, not hanzo's builds — the parameter selects among what you may
// already see, it never reaches for more.
func (v viewer) visible(runs []Execution, want string) []Execution {
want = strings.TrimSpace(want)
if v.sudo {
// The fleet view: every org, narrowed by the requested one if given.
return filterByOrg(runs, want)
}
if want != "" && !strings.EqualFold(want, v.org) {
return nil
}
return filterByOrg(runs, v.org)
}
// orgs lists the org tabs this viewer may choose between. A tenant gets exactly
// its own org — rendering the full org list to a tenant would leak the set of
// orgs that build on the platform even though their runs are correctly hidden.
func (v viewer) orgs(runs []Execution) []string {
if v.sudo {
return orgsOf(runs)
}
return []string{v.org}
}
// requireViewer resolves the viewer or writes the refusal. It returns ok=false
// when the request must not proceed.
func requireViewer(w http.ResponseWriter, r *http.Request, adminOrg string) (viewer, bool) {
v, ok := resolveViewer(r, adminOrg)
if ok {
return v, true
}
// 403, not 401: a 401 invites a credential retry, but there is nothing the
// CALLER can add to fix this. The header is set by infrastructure, so its
// absence is a routing fault (ci reached off-guard) and the honest answer is
// that this path is not authorized to serve, whoever is asking.
http.Error(w, "forbidden: no "+orgHeader+" (this service is only reachable through the IAM gate)", http.StatusForbidden)
return viewer{}, false
}