mirror of
https://github.com/hanzoai/ci.git
synced 2026-10-20 15:28:05 +00:00
This service shipped with /v1/runs answering 200 to anyone on the
internet, disclosing repo names, workflow names, branches, commit SHAs,
actor logins and pass/fail across EVERY org. The cause was a category
error, not a missing check: `?org=` narrowed what was rendered and read
like tenancy, so it looked like the surface had one. A query parameter
is a request for a view. It can never be the authority for one.
The authority is now 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
value, so it cannot be forged on the wired path).
Three properties, each with a test that fails without it:
- ABSENCE IS FATAL. No X-Org-Id => 403, never "no filter". Defaulting
an absent scope to "everything" is precisely the bug; absence means
the request did not come through the gate, so refusing is the only
honest answer. The refusal body carries no repo names.
- THE PARAMETER CAN ONLY NARROW. Permission is applied first, then
`?org=` selects within it. A lux viewer asking ?org=hanzo gets an
empty list, not hanzo's builds.
- THE ORG LIST IS SCOPED TOO. A tenant sees only its own org in the
nav. Hiding the runs but listing every org still discloses the set of
orgs that build on the platform.
The admin org keeps the cross-tenant fleet view, matched to
admin-guard's IAM_ADMIN_ORG via CI_ADMIN_ORG — the guard decides who
gets in, this decides who sees everything, and the two must name the
same org or the fleet view silently collapses (or, set too wide,
promotes a tenant into it).
renderDashboard now takes the viewer and is handed only rows that
already passed v.visible. A template that can see everything is one edit
away from showing it.
Mutation-verified: restoring `visible` to the old filter-as-gate
behaviour fails TestTenantCannotWidenWithQueryParam and the end-to-end
handler test.
Co-authored-by: Hanzo Dev <[email protected]>
97 lines
4.0 KiB
Go
97 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// scope.go answers exactly one question: whose builds may THIS request see?
|
|
//
|
|
// It exists because the first cut of this service conflated a FILTER with a
|
|
// GATE. `?org=lux` narrowed what was rendered and read like tenancy, but it
|
|
// decided nothing about who was allowed to ask — so /v1/runs answered 200 to
|
|
// anyone on the internet with every org's repo names, branches, commit SHAs and
|
|
// actor logins. A query parameter is a request for a view; it can never be the
|
|
// authority for one.
|
|
//
|
|
// 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 []Run, want string) []Run {
|
|
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 []Run) []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
|
|
}
|