Compare commits

...
Author SHA1 Message Date
hanzo-dev 97ea1a8e0b archive: 34 cloud branches fully merged into main
Each was verified to have zero commits not already reachable from main
(git rev-list --count origin/main..<branch> == 0 for all 34), so deleting the
branch refs loses no work. This commit keeps every tip reachable anyway, so any
branch can be restored with: git branch <name> <sha>
2026-07-28 11:35:32 -07:00
139 changed files with 4050 additions and 1633 deletions
+26 -6
View File
@@ -297,10 +297,30 @@ USER 65532:65532
#
# Host mode is the same image with the command replaced — `["/sbin/tini","--","/host"]`.
# tini matters MORE there, not less: the host's children are /cloud processes that
# fork git and friends of their own. Two things to check before switching a
# deployment over, because neither fails loudly: the host binds :8080 and :9653 but
# NOT the :9090 ops port (serve.go leaves it unbound for a plugin, since N children
# cannot share one port), so anything scraping 9090 must move to the child or be
# dropped; and the children need a writable directory for their sockets, which as
# uid 65532 on a read-only rootfs means mounting one.
# fork git and friends of their own. Three things to check before switching a
# deployment over, because none of them fails loudly:
#
# 1. CREDENTIALS ARE NOT SCOPED IN HOST MODE YET. /cloud calls credz.Boot, which
# takes CLOUD_KMS_MASTER_KEY_REF OUT of its environment before it spawns
# anything, so its children inherit no root key and must ask the broker for a
# per-app bundle — that is where the identity fix (credz/launch: the launcher
# stamps each child's app, the broker verifies it) is actually load-bearing.
# /host does NOT call credz.Boot. It has no store to open and nothing to
# decrypt, so it never had a reason to — but that means the root key is still
# in its environment when zip spawns each child with os.Environ(), every child
# resolves the Root posture from that inherited key, and none of them asks the
# broker at all. The scoping is bypassed, not broken: every child holds the
# root key and can open any store, exactly as it did before credz existed.
# /host already stamps every child's launch token and hands the secret to the
# kms child, so closing this is one credz.Boot call in cmd/host — deliberately
# not made here, because a light host that imports credz drags cek →
# modernc/sqlite + sqlcipher into a ~395-package build whose entire reason to
# exist is being small. The fix belongs with that dependency question, and
# until it lands, host mode is a routing topology and not a credential
# boundary. The DEPLOYED entrypoint is /cloud (below), where it is a boundary.
# 2. The host binds :8080 and :9653 but NOT the :9090 ops port (serve.go leaves
# it unbound for a plugin, since N children cannot share one port), so anything
# scraping 9090 must move to the child or be dropped.
# 3. The children need a writable directory for their sockets, which as uid 65532
# on a read-only rootfs means mounting one.
ENTRYPOINT ["/sbin/tini", "--", "/cloud"]
+72 -26
View File
@@ -366,27 +366,39 @@ for the credentials of the app it is.**
process start and holds it in memory, so no spawned child inherits it.
- **Who brokers**: the process that read the root key from its own environment
AND owns the sealed store (`deps.KMS` is the embedded client). That is the KMS
subsystem — exactly one process. `cmd/host` stays light; it holds nothing.
subsystem — exactly one process. `cmd/host` links no store and brokers nothing
(but it does still pass the key down — see host mode below).
- **Who asks**: every other `cloud` process, at the top of `Serve`, before
`LoadConfig` and before any store opens.
- **Identity is NOT sound yet — do not merge this to main as a security boundary.**
No token, no name in the request: the broker reads `SO_PEERCRED` and resolves
the peer's argv through `/proc`, accepting both spawn shapes the manifest
produces (`<dir>/<app>`, `cloud --enable=<app>`) checked against
`manifest.Apps`. `SO_PEERCRED` is kernel-authenticated for pid/uid — **argv is
not**. A process picks its own `argv[0]` at `execve`, so any same-uid process
can present itself as any app and receive that app's bundle, *including the
root key*. Demonstrated: a binary named `spoof` was granted `billing`'s and then
`ai`'s bundle and logged as a legitimate grant both times.
- **Identity comes from the launcher** (`credz/launch`, stdlib-only leaf). The
launcher stamps `CREDZ_TOKEN=<app>:<hex hmac-sha256(secret, app)>` into that
ONE child's `zip.Plugin.Env` at spawn; the child presents it; the broker opens
it with the secret it holds and gates the result on `manifest.Apps`. Claim and
proof are one variable, so neither half can be recombined with another's. Two
spawn sites, both per-plugin and never `os.Environ()`: `cloud.PluginSpec` (the
launcher *is* the broker, secret minted in-process and never emitted) and
`cmd/host` (mints it, stamps every child, hands `CREDZ_LAUNCH_SECRET` to the
`kms` child alone). `credz.Boot` reads the token once and unsets it.
So today this partitions credentials against **accident** (107 processes stop
carrying secrets they never use) and not against a **compromised** process. The
fix has to come from the spawner, which is the only party that knows which app
it started as which pid: a per-plugin nonce in `zip.Plugin.Env`, or a
pre-connected socket passed as an `ExtraFile` — both in `manifest/plugin.go` +
`cmd/host`.
This replaces reading the peer's argv out of `/proc` (#51). `SO_PEERCRED` is
kernel-authenticated for pid/uid but **argv is not** — a process picks its own
`argv[0]` at `execve`, so any same-uid process could present itself as any app
and be handed that app's bundle *including the root key*. `SO_PEERCRED` stays
for the two things it can do: the uid check, and the pid in the audit line.
- **The limit, stated honestly**: the token is in the child's environment, which
the same uid can read at `/proc/<pid>/environ`. So the cost of impersonating an
app went from *nothing* to *first steal a live peer's token*, and a stolen token
buys only the app it was stolen from — a boundary against accident and casual
forgery, **not** against a peer that reads its neighbours. A real same-uid
boundary means the socket becomes the credential (launcher pre-connects, passes
the fd as an `ExtraFile`), which is a change to `zip`'s spawn contract.
- **Host mode is not switched over**: `cmd/host` stamps tokens but does not call
`credz.Boot`, so it still passes `CLOUD_KMS_MASTER_KEY_REF` down via
`os.Environ()` and every child resolves ROOT without ever asking the broker —
scoping bypassed, not broken. The deployed entrypoint is `/cloud` (fused),
where the fix is live. See the Dockerfile's host-mode note.
- **Scope is derived, not configured** — the manifest names every app, the store
holds every secret, and the path is built from the peer's identity:
holds every secret, and the path is built from the app the launcher stamped:
/orgs/{adminOrg}/svc/_shared/{NAME} every app
/orgs/{adminOrg}/svc/{app}/{NAME} that app only
@@ -398,8 +410,9 @@ for the credentials of the app it is.**
{"path":"/svc/ai","name":"CLOUD_AI_API_KEY","env":"default","value":"sk-…"}
No second registry and no code change to add a credential. The `billing`
process is never handed `/svc/ai` — subject to the identity caveat above, which
is what decides whether "never handed" also means "cannot obtain".
process is never handed `/svc/ai`: the path is built from the app the launcher
stamped, so a peer cannot spell a path — only present the token for the one it
was started as.
- **The environment stays the interface**: the bundle is installed with
`os.Setenv`, so all 108 apps keep reading `os.Getenv` unchanged — and a value
set after `execve` never appears in `/proc/<pid>/environ`.
@@ -745,6 +758,18 @@ one before it.
document, so the prose never reached any of them either. `-run zipdoc` picks
the directives out of `./...` by name, so a typed op added anywhere is
covered and no unrelated generator fires.
- The same bug had a SECOND instance one projection over, and it outlived the
first. zip's `mcpTools` read `op.Summary` — set only by an explicit
`WithSummary`, which cloud uses nowhere because the doc comment is the source.
So `zipdoc` ran, the spec got its prose, and **all 164 MCP tools still served
an empty description over a schema whose fields said nothing.** Fixed in
`zap-proto/zip` v1.17.6 (`mcpTools` reads the same `docFor` extraction and
builds the input schema with `schemaOfDoc`); measured after: 164/164 tools
described, 324 documented fields. Requires zip >= v1.17.6 — an older zip
silently reverts the MCP plane to nameless tools while the spec still looks
correct, which is precisely why it went unnoticed. The lesson generalises:
when a projection reads a DIFFERENT field than its siblings, it does not fail,
it just goes quiet.
- **Each app describes ITSELF: `<binary> openapi <file>`** (openapi_dump.go). An
app's subset is generated from the app's OWN live router by the SAME
`openapi.FleetSpec` the whole document is, over an app with only that subsystem
@@ -1207,9 +1232,9 @@ no or it is a label rather than a filter. It was `true` on all 579 rows and read
`?forkable=false` silently meant *no filter*. Now: a repo that is itself a fork of a
third-party upstream is not ours to hand over, a live demo with no public source has
nothing to hand over, and a declared `upstream` credit vetoes both. The query is
tri-state (`boolQuery`/`strconv.ParseBool` — set-true, set-false, unasked; `official`
rides the same helper) and `facet` counts both sides through the same loop as every
other dimension. `Entry.Forkable` is NOT `omitempty`: false is an answer.
tri-state (`boolQuery`/`strconv.ParseBool` — set-true, set-false, unasked) and
`facet` counts both sides through the same loop as every other dimension.
`Entry.Forkable` is NOT `omitempty`: false is an answer.
**`origin` is what a row IS to you**, and it is the axis the two hanzo.app lanes
are cut on. The corpus flattened 579 rows into one list in which a curated starter
@@ -1232,12 +1257,33 @@ DERIVED:
- `fromRepo` lets GitHub's own `fork` bit override the address: a starter we
vendored from somebody else is not a starter of ours.
`origin` is deliberately NOT braided with `official`. Origin says which lane;
`official` says whose work it is. One `official-example` value would make them
unaskable separately, and *community apps that are NOT ours* is the whole point of
a community lane. Both are faceted and both filter, plus `?template=<parent id>`
`origin` is deliberately NOT braided with authorship. Origin says which lane;
**`org` says whose work it is** — the account that pays for a project, which the
tenancy boundary enforces and no request can forge. Keeping them separate is what
makes *community apps that are NOT ours* askable, which is the whole point of a
community lane. Both are faceted and both filter, plus `?template=<parent id>`
for one lineage — a facet nobody can act on is a rail that lies.
There used to be a third field here, an admin-gated `official` boolean, and it was
**deleted** (not deprecated) because it restated `org` and then disagreed with it:
the platform's own 74 demos were published by a script holding an ordinary org
token, so the gate refused them and this directory filed Hanzo's own work as
somebody else's. A patch had pinned the badge back on from an embedded 75-slug
manifest, which drifted out of agreement with reality within days of the template
rename. A second copy of an unforgeable fact can only ever be the wrong one.
**Visibility, not authorship, decides who appears** (`clients/projects/visibility.go`).
One axis owned by the publisher — `public` (default) or `private` — plus
`hidden`, the platform's subtractive moderation from admin.hanzo.ai. A row is
listed iff `public AND NOT hidden`, enforced in `LiveSites`' own query so a
consumer that forgets to filter cannot leak anything. Publishing is **ungated**
(a community you must be admitted to does not grow); going private is the paid
feature and rides the same `cloud.ResourceMeter` funded-org gate as hosting,
agents and functions, so an unfunded org asking for it gets a 402 rather than
being silently published. Moderation is the one admin-only field, and it is safe
to be one precisely because it only ever subtracts — the same shape as `Apex`'s
reserved-host denylist, never an allowlist.
**Third-party is attributed or NOT LISTED.** A fork holds somebody else's code
under one of our org headers. GitHub's org listing omits `parent`, so `credit`
spends one extra request per fork (a few dozen an hour against a listing pass of
+2 -4
View File
@@ -126,7 +126,6 @@ import (
// otel-collector, prometheus, gonum — linked into cloud whether or not any
// Wire entry referenced it. Unlinking a subsystem means deleting its import,
// not just its mount.
"github.com/hanzoai/cloud/clients/paas"
"github.com/hanzoai/cloud/clients/plan"
"github.com/hanzoai/cloud/clients/platform"
"github.com/hanzoai/cloud/clients/plugin"
@@ -322,10 +321,9 @@ func Wire() []cloud.MountSpec {
// recipient via wallets.ResolvePaymentTarget) and provides the Enforce
// middleware a marketplace applies to its priced routes.
{Name: "x402", Price: cloud.Free, Mount: x402.Mount, Shutdown: cloud.CtxShutdown(x402.Shutdown)},
{Name: "paas", Price: cloud.Free, Mount: paas.Mount, OwnsHealth: true},
// GitOps deploy dashboard /v1/deploy/* (the ArgoCD-grade fleet view over the
// operator App CRs). After paas so the release seam paas installs is registered
// before a gitops rollback delegates to it; owns its own /v1/deploy/health.
// operator App CRs). After platform so the release seam platform installs is
// registered before a gitops rollback delegates to it; owns /v1/deploy/health.
{Name: "deploy", Price: cloud.Free, Mount: deploy.Mount, OwnsHealth: true},
{Name: "functions", Price: cloud.Metered, Mount: functions.Mount},
{Name: "tracker", Price: cloud.Metered, Mount: tracker.Mount},
+11 -12
View File
@@ -24,16 +24,16 @@ var frozen = []struct {
hasShutdown bool
global bool // receives the bare *zip.App — see MountSpec.App
}{
{"pubsub", false, true, false}, // was order 5
{"kafka", false, true, false}, // was order 6
{"agentskills", false, false, false}, // was order 8
{"flags", true, true, false}, // was order 9; native engine: /v1/flags health + store shutdown
{"kms", true, false, false}, // was order 10
{"metrics", false, false, true}, // was order 40
{"ingress", false, true, false}, // was order 42
{"account", false, false, false}, // was order 48
{"iam", false, false, false}, // was order 50
{"base", true, true, false}, // was order 60; per-org embed added Shutdown (#298)
{"pubsub", false, true, false}, // was order 5
{"kafka", false, true, false}, // was order 6
{"agentskills", false, false, false}, // was order 8
{"flags", true, true, false}, // was order 9; native engine: /v1/flags health + store shutdown
{"kms", true, false, false}, // was order 10
{"metrics", false, false, true}, // was order 40
{"ingress", false, true, false}, // was order 42
{"account", false, false, false}, // was order 48
{"iam", false, false, false}, // was order 50
{"base", true, true, false}, // was order 60; per-org embed added Shutdown (#298)
// hasShutdown flipped true->false when o11y became a PLUGIN (cloud.PluginSpec,
// its own cmd/o11y binary). Deliberate and load-bearing, not drift: the host no
// longer owns any o11y resource to close. The collector/sink/Datastore moved into
@@ -61,8 +61,7 @@ var frozen = []struct {
{"agents", false, true, false}, // was order 127
{"link", false, true, false}, // new: unified AI login manager (/v1/links), after agents
{"wallets", false, true, false}, // was order 127
{"x402", false, true, false}, // new: x402 pay-per-use settlement (after wallets)
{"paas", true, false, false}, // was order 128
{"x402", false, true, false}, // new: x402 pay-per-use settlement (after wallets) // was order 128
{"deploy", true, false, false}, // after paas (release seam), before functions
{"functions", false, false, false}, // was order 128
{"tracker", false, false, false}, // was order 129
+1 -1
View File
@@ -620,7 +620,7 @@ func tamperOutOfBand(t *testing.T, path, stmt string) {
// so a bare sql.Open cannot read it. The modelled adversary is one with database
// access AND the key (an insider, or a compromised process) — file access alone
// no longer suffices, which is the point of encrypting it.
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("tamper open: %v", err)
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestShareability_ReaderSharesLiveWriterStore(t *testing.T) {
// handle that only ever reads — it still proves the reader sees the live
// writer's committed records, which is the claim, but it does not by itself
// prove the reader takes no write lock.
ro, err := cek.Open(path)
ro, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("reader Open: %v", err)
}
+1 -1
View File
@@ -112,7 +112,7 @@ type CheckpointFunc func(cp Checkpoint)
// the file lock — the same single-writer discipline pricing/provisioning use,
// here doubling as the chain's serialization guarantee.
func Open(path string, mirror Mirror) (*Recorder, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("audit: open sqlite %q: %w", path, err)
}
+104 -41
View File
@@ -17,11 +17,11 @@
// exposure above (no master key ⇒ no plaintext). It is NOT integrity,
// authenticity, or anti-rollback against a PV-WRITE (node-compromise) adversary
// who can modify the volume: the per-file id lives in the (unauthenticated) .dek
// sidecar, so such an adversary could swap two of OUR OWN {db,.dek} pairs or
// replay an old snapshot. That is outside the stated model and is deliberately
// NOT defended here (a logical-id+epoch binding would add complexity for an
// out-of-model threat); revisit only if tenant-isolation-under-node-compromise
// is scoped in.
// sidecar, so such an adversary could still replay an old snapshot of a store in
// place. Swapping two stores ACROSS tenants no longer works — the owner is in the
// KEK and the AAD, so a pair carried into another org's directory fails to unwrap
// (Principal). Rollback of a store onto itself remains out of model; it needs an
// epoch, which the sidecar does not carry.
//
// ENVELOPE (the primitives live in github.com/hanzoai/sqlite/cek.go and are
// reused verbatim — one crypto implementation, KAT-gated there):
@@ -29,15 +29,24 @@
// - Each database has its OWN random 256-bit DEK (the SQLCipher page key),
// minted once at first touch and NEVER changed, so ciphertext pages are
// never rewritten.
//
// - Each database also gets a random 128-bit FILE ID, stored in the clear at
// the head of its <db>.dek sidecar. The KEK is derived from that id, NOT the
// file path: KEK = HKDF-SHA256(masterKey, lp("global") || lp(hex(fileID))).
// The id is intrinsic to the file and travels with the sidecar, so moving
// the data dir or changing CLOUD_DATA_DIR can never change the KEK and brick
// a store. RFC-5869 HKDF via x/crypto/hkdf — NOT luxfi/crypto/kdf (a QZMQ
// KeySchedule, not generic HKDF; using it would brick every store).
// the head of its <db>.dek sidecar. The KEK derives from the OWNER and that
// id, never from the file path:
//
// KEK = HKDF-SHA256(masterKey, lp(type) || lp(owner || "/" || hex(fileID)))
//
// for a tenant store, and lp("global") || lp(hex(fileID)) for the platform
// partition, which has no owner. Because the path is absent, moving the data
// dir or changing CLOUD_DATA_DIR can never change a KEK or brick a store;
// because the owner is present, a {db,.dek} pair carried into another
// tenant's directory fails to unwrap rather than opening. RFC-5869 HKDF via
// x/crypto/hkdf — NOT luxfi/crypto/kdf (a QZMQ KeySchedule, not generic
// HKDF; using it would brick every store).
//
// - The DEK is wrapped AES-256-GCM under the KEK, bound to the same id as AAD.
// Sidecar = fileID(16) || wrapped-DEK. The raw DEK is never written.
//
// - Master-key ROTATION rewraps only the sidecar: the DEK and fileID are
// unchanged, so no page is rewritten and no file can be bricked.
//
@@ -88,9 +97,58 @@ const (
fileIDLen = 16 // random per-file KEK-derivation id, stored in the sidecar head
)
// principalType domain-separates cloud's platform databases from IAM's org/user
// stores in the shared HKDF namespace.
const principalType = sqlitedrv.PrincipalGlobal
// Principal is WHOSE store this is. It is the first argument to Open because it is
// part of a store's identity, not a property of its location: the same bytes at the
// same path belong to exactly one org, and the key says so.
//
// It binds the derivation. Before, every store — platform and per-org alike — derived
// under the single tag "global", so a store's key knew nothing about its owner and a
// {db,.dek} pair lifted into another org's directory opened there perfectly well.
// Confidentiality between orgs still held (each file has its own random DEK under its
// own KEK), but nothing tied a file to the tenant it belonged to. Now the owner is in
// the HKDF info and in the GCM AAD, so a store carried across a tenant boundary fails
// to unwrap instead of opening.
type Principal struct {
typ sqlitedrv.PrincipalType
id string
}
// Global is the cross-org platform partition: certs, providers, the audit trail, the
// gateway's own store. Its id is fixed, so a platform store's key derives from the
// file id alone exactly as it always has — the platform partition is not a tenant and
// has no owner to bind to.
var Global = Principal{typ: sqlitedrv.PrincipalGlobal}
// Org binds a store to one tenant. slug MUST be the value SanitizeOrg produced (the
// injective slugger OrgDB already folds every org through), so two distinct orgs can
// never derive the same key.
func Org(slug string) Principal { return Principal{typ: sqlitedrv.PrincipalOrg, id: slug} }
// User binds a store to one person, for the per-user partition.
func User(id string) Principal { return Principal{typ: sqlitedrv.PrincipalUser, id: id} }
// String renders the principal for errors and logs. It never carries key material.
func (p Principal) String() string {
if p.id == "" {
return string(p.typ)
}
return string(p.typ) + ":" + p.id
}
// derivationID is the HKDF/AAD identity of one file under this principal: the owner
// and the file, in that order, so neither alone determines the key.
//
// SanitizeOrg emits no "/", so owner and file id cannot run together into an ambiguous
// string — and lengthPrefixedInfo (which DeriveKey and PrincipalAAD both use) is
// injective over (type, id) regardless, so the separator is for reading, not safety.
// The platform partition keeps the bare file id, which is what its stores already use.
func (p Principal) derivationID(fileID []byte) string {
id := hex.EncodeToString(fileID)
if p.id == "" {
return id
}
return p.id + "/" + id
}
var (
masterOnce sync.Once
@@ -260,10 +318,13 @@ func Exists(path string) bool {
return err == nil
}
// Open returns a *sql.DB for the SQLite database at path, encrypted at rest when
// a master key is configured. It is the single drop-in replacement for
// sql.Open("sqlite", path) across every cloud store.
func Open(path string) (*sql.DB, error) {
// Open returns a *sql.DB for the SQLite database at path, encrypted at rest and bound
// to p. It is the single way a cloud store opens its file.
//
// The principal comes first because it is the question a caller must answer, not one
// it may forget: pass cek.Global for a platform store, cek.Org(slug) for a tenant's.
// Passing the wrong one is not a silent mistake — the store will not unwrap.
func Open(p Principal, path string) (*sql.DB, error) {
// An in-memory database never reaches disk, so there is nothing at rest to
// encrypt and no master key to require. Treating the spelling as a filename
// instead creates a FILE literally named ":memory:" — silently making an
@@ -288,10 +349,10 @@ func Open(path string) (*sql.DB, error) {
// (nil, nil). Fail closed anyway — cek never opens a store plaintext.
return nil, fmt.Errorf("cek: no master key resolved for %q", path)
}
return openEncrypted(path, master)
return openEncrypted(p, path, master)
}
func openEncrypted(path string, master []byte) (*sql.DB, error) {
func openEncrypted(p Principal, path string, master []byte) (*sql.DB, error) {
unlock, err := flock(path)
if err != nil {
return nil, err
@@ -305,11 +366,11 @@ func openEncrypted(path string, master []byte) (*sql.DB, error) {
var db *sql.DB
switch classify(path) {
case stateFresh:
db, err = createFresh(path, master)
db, err = createFresh(p, path, master)
case stateEncrypted:
db, err = openExisting(path, master)
db, err = openExisting(p, path, master)
default: // statePlaintext
db, err = migrateThenOpen(path, master)
db, err = migrateThenOpen(p, path, master)
}
if err != nil {
return nil, err
@@ -356,12 +417,12 @@ func isPlaintextHeader(path string) bool {
// mintSidecar generates a fresh fileID + DEK, wraps the DEK under the id-derived
// KEK, and returns the DEK and the sidecar bytes to persist.
func mintSidecar(master []byte) (dek, sidecar []byte, err error) {
func mintSidecar(p Principal, master []byte) (dek, sidecar []byte, err error) {
fileID := make([]byte, fileIDLen)
if _, err = rand.Read(fileID); err != nil {
return nil, nil, fmt.Errorf("cek: generate file id: %w", err)
}
kek, aad, err := deriveFor(master, fileID)
kek, aad, err := deriveFor(p, master, fileID)
if err != nil {
return nil, nil, err
}
@@ -381,12 +442,12 @@ func mintSidecar(master []byte) (dek, sidecar []byte, err error) {
// unwrapSidecar reads the fileID from the sidecar head and unwraps the DEK under
// the id-derived KEK. A wrong master key, tampered blob, or truncated sidecar
// fails the GCM tag and errors — never a partial/garbage key.
func unwrapSidecar(master, sidecar []byte) ([]byte, error) {
func unwrapSidecar(p Principal, master, sidecar []byte) ([]byte, error) {
if len(sidecar) <= fileIDLen {
return nil, fmt.Errorf("cek: sidecar too short (%d bytes)", len(sidecar))
}
fileID, wrapped := sidecar[:fileIDLen], sidecar[fileIDLen:]
kek, aad, err := deriveFor(master, fileID)
kek, aad, err := deriveFor(p, master, fileID)
if err != nil {
return nil, err
}
@@ -398,25 +459,27 @@ func unwrapSidecar(master, sidecar []byte) ([]byte, error) {
return dek, nil
}
// deriveFor derives the KEK and the wrap-AAD for a file id. Both bind to
// hex(fileID) so the id — not any path or config value — is the sole identity.
func deriveFor(master, fileID []byte) (kek, aad []byte, err error) {
id := hex.EncodeToString(fileID)
kek, err = sqlitedrv.DeriveKey(master, principalType, id)
// deriveFor derives the KEK and the wrap-AAD for one file under one principal. Both
// bind to (principal, fileID) — never to a path or a config value — so moving a store
// or renaming the data dir cannot change its key, while carrying it into another
// tenant's directory cannot open it.
func deriveFor(p Principal, master, fileID []byte) (kek, aad []byte, err error) {
id := p.derivationID(fileID)
kek, err = sqlitedrv.DeriveKey(master, p.typ, id)
if err != nil {
return nil, nil, fmt.Errorf("cek: derive KEK: %w", err)
return nil, nil, fmt.Errorf("cek: derive KEK for %s: %w", p, err)
}
return kek, sqlitedrv.PrincipalAAD(principalType, id), nil
return kek, sqlitedrv.PrincipalAAD(p.typ, id), nil
}
// ── open paths ───────────────────────────────────────────────────────────────
func createFresh(path string, master []byte) (*sql.DB, error) {
func createFresh(p Principal, path string, master []byte) (*sql.DB, error) {
dekPath := path + dekSuffix
if fileExists(dekPath) {
return openExisting(path, master) // a concurrent first-touch won the lock
return openExisting(p, path, master) // a concurrent first-touch won the lock
}
dek, sidecar, err := mintSidecar(master)
dek, sidecar, err := mintSidecar(p, master)
if err != nil {
return nil, err
}
@@ -431,13 +494,13 @@ func createFresh(path string, master []byte) (*sql.DB, error) {
return db, nil
}
func openExisting(path string, master []byte) (*sql.DB, error) {
func openExisting(p Principal, path string, master []byte) (*sql.DB, error) {
dekPath := path + dekSuffix
sidecar, err := os.ReadFile(dekPath)
if err != nil {
return nil, fmt.Errorf("cek: read sidecar %q (encrypted db, refusing to open blind): %w", dekPath, err)
}
dek, err := unwrapSidecar(master, sidecar)
dek, err := unwrapSidecar(p, master, sidecar)
if err != nil {
return nil, err
}
@@ -454,7 +517,7 @@ func openExisting(path string, master []byte) (*sql.DB, error) {
// until an atomic rename commits) and fail-secure (the swap happens only after
// the encrypted copy reproduces the source schema + per-table content hash +
// integrity_check, re-opened via the exact keyed path the app uses).
func migrateThenOpen(path string, master []byte) (*sql.DB, error) {
func migrateThenOpen(p Principal, path string, master []byte) (*sql.DB, error) {
// Converting an existing plaintext database to SQLCipher uses libsqlcipher's
// ATTACH ... KEY + sqlcipher_export (see exportPlaintext), which only the live C
// codec provides. The pure-Go envelope opens and creates encrypted stores but
@@ -472,7 +535,7 @@ func migrateThenOpen(path string, master []byte) (*sql.DB, error) {
tmp := path + tmpSuffix
removeDBFiles(tmp)
dek, sidecar, err := mintSidecar(master)
dek, sidecar, err := mintSidecar(p, master)
if err != nil {
return nil, err
}
+14 -14
View File
@@ -144,7 +144,7 @@ func TestMigratePlaintextToCipher(t *testing.T) {
want := makePlaintextDB(t, path, 37, 91)
resetMaster(testMaster(t))
db, err := Open(path)
db, err := Open(Global, path)
if err != nil {
t.Fatalf("cek.Open (migrate): %v", err)
}
@@ -166,7 +166,7 @@ func TestMigratePlaintextToCipher(t *testing.T) {
if err != nil {
t.Fatalf("read sidecar: %v", err)
}
if dek, err := unwrapSidecar(testMaster(t), sidecar); err != nil || len(dek) != 32 {
if dek, err := unwrapSidecar(Global, testMaster(t), sidecar); err != nil || len(dek) != 32 {
t.Fatalf("unwrap sidecar: dek=%d err=%v", len(dek), err)
}
@@ -188,14 +188,14 @@ func TestOpenIdempotent(t *testing.T) {
want := makePlaintextDB(t, path, 5, 5)
resetMaster(testMaster(t))
db1, err := Open(path)
db1, err := Open(Global, path)
if err != nil {
t.Fatalf("first open: %v", err)
}
_ = db1.Close()
hdr1 := firstBytes(t, path, 16)
db2, err := Open(path)
db2, err := Open(Global, path)
if err != nil {
t.Fatalf("second open: %v", err)
}
@@ -218,7 +218,7 @@ func TestFreshCreateEncrypted(t *testing.T) {
requireCipher(t)
path := filepath.Join(t.TempDir(), "wallets.db")
resetMaster(testMaster(t))
db, err := Open(path)
db, err := Open(Global, path)
if err != nil {
t.Fatalf("open fresh: %v", err)
}
@@ -242,7 +242,7 @@ func TestWrongMasterFailsClosed(t *testing.T) {
makePlaintextDB(t, path, 3, 3)
resetMaster(testMaster(t))
db, err := Open(path)
db, err := Open(Global, path)
if err != nil {
t.Fatalf("migrate: %v", err)
}
@@ -253,7 +253,7 @@ func TestWrongMasterFailsClosed(t *testing.T) {
other[i] = 0xAB
}
resetMaster(other)
if _, err := Open(path); err == nil {
if _, err := Open(Global, path); err == nil {
t.Fatalf("SECURITY: opened encrypted db under the WRONG master key")
}
}
@@ -269,7 +269,7 @@ func TestDataDirMoveNoBrick(t *testing.T) {
t.Setenv("CLOUD_DATA_DIR", dir1)
resetMaster(testMaster(t))
db, err := Open(pathA)
db, err := Open(Global, pathA)
if err != nil {
t.Fatalf("migrate under dir1: %v", err)
}
@@ -282,7 +282,7 @@ func TestDataDirMoveNoBrick(t *testing.T) {
copyFile(t, pathA+dekSuffix, pathB+dekSuffix)
t.Setenv("CLOUD_DATA_DIR", "/some/other/root") // the old brick trigger
db2, err := Open(pathB) // KEK from fileID → must still open
db2, err := Open(Global, pathB) // KEK from fileID → must still open
if err != nil {
t.Fatalf("AVAILABILITY: data-dir change bricked the store: %v", err)
}
@@ -302,7 +302,7 @@ func TestMissingKeyFatalOnCapableBuild(t *testing.T) {
resetMaster(nil)
os.Unsetenv(masterKeyEnv)
_, err := Open(path)
_, err := Open(Global, path)
if sqlitedrv.EncryptionAvailable() {
if err == nil {
t.Fatalf("SECURITY: capable build opened the data plane with NO master key (silent plaintext)")
@@ -336,7 +336,7 @@ func TestEnsureDevKeyEncryptsOnPureGo(t *testing.T) {
}
path := filepath.Join(t.TempDir(), "settings.db")
db, err := Open(path)
db, err := Open(Global, path)
if err != nil {
t.Fatalf("open with dev key: %v", err)
}
@@ -395,7 +395,7 @@ func TestCorruptSidecarFailsClosed(t *testing.T) {
makePlaintextDB(t, path, 4, 4)
resetMaster(testMaster(t))
db, err := Open(path)
db, err := Open(Global, path)
if err != nil {
t.Fatalf("migrate: %v", err)
}
@@ -411,7 +411,7 @@ func TestCorruptSidecarFailsClosed(t *testing.T) {
t.Fatalf("write sidecar: %v", err)
}
resetMaster(testMaster(t))
if _, err := Open(path); err == nil {
if _, err := Open(Global, path); err == nil {
t.Fatalf("SECURITY: opened an encrypted db with a corrupted DEK sidecar")
}
@@ -420,7 +420,7 @@ func TestCorruptSidecarFailsClosed(t *testing.T) {
t.Fatalf("rm sidecar: %v", err)
}
resetMaster(testMaster(t))
if _, err := Open(path); err == nil {
if _, err := Open(Global, path); err == nil {
t.Fatalf("SECURITY: opened an encrypted db with NO DEK sidecar")
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ func TestGenerateFrozenFixture(t *testing.T) {
for _, s := range []string{"", dekSuffix, "-wal", "-shm"} {
_ = os.Remove(dbPath + s)
}
dek, sidecar, err := mintSidecar(frozenMaster())
dek, sidecar, err := mintSidecar(Global, frozenMaster())
if err != nil {
t.Fatal(err)
}
@@ -85,7 +85,7 @@ func TestFrozenFixtureOpens(t *testing.T) {
copyFile(t, src+dekSuffix, dst+dekSuffix)
resetMaster(frozenMaster())
db, err := Open(dst)
db, err := Open(Global, dst)
if err != nil {
t.Fatalf("FORMAT DRIFT: frozen fixture failed to open (libsqlcipher format changed?): %v", err)
}
+3 -3
View File
@@ -23,12 +23,12 @@ func TestInMemoryNeverTouchesDisk(t *testing.T) {
t.Cleanup(func() { _ = os.Chdir(wd) })
for _, dsn := range []string{":memory:", "file::memory:", "file:x?mode=memory&cache=shared"} {
db, err := Open(dsn)
db, err := Open(Global, dsn)
if err != nil {
t.Fatalf("Open(%q): %v", dsn, err)
t.Fatalf("Open(Global, %q): %v", dsn, err)
}
if _, err := db.Exec(`CREATE TABLE t (v TEXT)`); err != nil {
t.Fatalf("Open(%q): unusable: %v", dsn, err)
t.Fatalf("Open(Global, %q): unusable: %v", dsn, err)
}
_ = db.Close()
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright © 2026 Hanzo AI. MIT License.
package cek
import (
"encoding/hex"
"os"
"path/filepath"
"testing"
sqlitedrv "github.com/hanzoai/sqlite"
)
// legacyGlobalDerivation is the derivation every store on disk today was written
// under, spelled out independently of the code being tested: type "global", id =
// hex(fileID). It is the reference the platform partition must still match, so that
// adding the principal cannot silently orphan the existing fleet.
func legacyGlobalDerivation(master, fileID []byte) (kek, aad []byte, err error) {
id := hex.EncodeToString(fileID)
kek, err = sqlitedrv.DeriveKey(master, sqlitedrv.PrincipalGlobal, id)
if err != nil {
return nil, nil, err
}
return kek, sqlitedrv.PrincipalAAD(sqlitedrv.PrincipalGlobal, id), nil
}
// The property the principal exists for: a store belongs to its owner, and carrying it
// into another tenant's directory must not open it.
//
// This is what the old derivation could not do. Every store keyed under the single tag
// "global", so the key knew nothing about the owner and a {db,.dek} pair moved between
// orgs opened perfectly — confidentiality held, but nothing bound a file to its tenant.
func TestStoreDoesNotOpenUnderAnotherOrg(t *testing.T) {
EnsureDevKey()
dir := t.TempDir()
path := filepath.Join(dir, "finance.db")
db, err := Open(Org("acme"), path)
if err != nil {
t.Fatalf("open as acme: %v", err)
}
if _, err := db.Exec(`CREATE TABLE ledger(v TEXT)`); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := db.Exec(`INSERT INTO ledger VALUES('acme-money')`); err != nil {
t.Fatalf("insert: %v", err)
}
_ = db.Close()
// The same bytes, claimed by a different tenant. The wrapped DEK is bound to acme
// through both the KEK and the GCM AAD, so this must fail rather than open.
if other, err := Open(Org("evil"), path); err == nil {
_ = other.Close()
t.Fatal("acme's store opened under org 'evil' — the key is not bound to its owner, so a {db,.dek} pair is portable across tenants")
}
// And the owner must still be able to open it: binding that also locks out the
// rightful tenant is not isolation, it is data loss.
back, err := Open(Org("acme"), path)
if err != nil {
t.Fatalf("acme can no longer open its own store: %v", err)
}
defer back.Close()
var got string
if err := back.QueryRow(`SELECT v FROM ledger`).Scan(&got); err != nil {
t.Fatalf("read back: %v", err)
}
if got != "acme-money" {
t.Fatalf("read %q", got)
}
}
// A tenant store must not open under the platform principal either — otherwise any
// platform-scoped opener would be a skeleton key over every org.
func TestOrgStoreDoesNotOpenAsGlobal(t *testing.T) {
EnsureDevKey()
path := filepath.Join(t.TempDir(), "tracker.db")
db, err := Open(Org("acme"), path)
if err != nil {
t.Fatalf("open as acme: %v", err)
}
_ = db.Close()
if g, err := Open(Global, path); err == nil {
_ = g.Close()
t.Fatal("a tenant store opened under the platform principal — Global would be a skeleton key")
}
}
// The platform partition keeps deriving from the file id alone. This is not cosmetic:
// every platform store already on disk was written that way, and a Global open must
// still be the same derivation or the whole fleet's stores stop opening.
func TestGlobalDerivationIsUnchanged(t *testing.T) {
EnsureDevKey()
master, err := resolveMaster()
if err != nil {
t.Fatalf("master: %v", err)
}
fileID := make([]byte, fileIDLen)
for i := range fileID {
fileID[i] = byte(i)
}
kek, aad, err := deriveFor(Global, master, fileID)
if err != nil {
t.Fatalf("deriveFor: %v", err)
}
// The pre-principal derivation: type "global", id = hex(fileID), nothing else.
wantKEK, wantAAD, err := legacyGlobalDerivation(master, fileID)
if err != nil {
t.Fatalf("reference derivation: %v", err)
}
if string(kek) != string(wantKEK) {
t.Fatal("Global no longer derives the KEK every existing platform store was written under — those stores would stop opening")
}
if string(aad) != string(wantAAD) {
t.Fatal("Global's wrap AAD changed — existing platform sidecars would fail their GCM tag check")
}
}
// Two orgs must never share a key for the same file id.
func TestDistinctOrgsDeriveDistinctKeys(t *testing.T) {
EnsureDevKey()
master, err := resolveMaster()
if err != nil {
t.Fatalf("master: %v", err)
}
fileID := make([]byte, fileIDLen)
a, _, err := deriveFor(Org("acme"), master, fileID)
if err != nil {
t.Fatalf("acme: %v", err)
}
b, _, err := deriveFor(Org("beta"), master, fileID)
if err != nil {
t.Fatalf("beta: %v", err)
}
if string(a) == string(b) {
t.Fatal("two orgs derived the same KEK for the same file id")
}
}
// A store must survive a move, which is only true while the path stays out of the
// derivation. The owner is in the key; the location is not.
func TestOwnerBoundStoreStillSurvivesRename(t *testing.T) {
EnsureDevKey()
dir := t.TempDir()
from := filepath.Join(dir, "a.db")
db, err := Open(Org("acme"), from)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := db.Exec(`CREATE TABLE t(v TEXT)`); err != nil {
t.Fatalf("create: %v", err)
}
_ = db.Close()
to := filepath.Join(dir, "b.db")
for _, sfx := range []string{"", ".dek"} {
if err := os.Rename(from+sfx, to+sfx); err != nil && !os.IsNotExist(err) {
t.Fatalf("rename: %v", err)
}
}
moved, err := Open(Org("acme"), to)
if err != nil {
t.Fatalf("owner-bound store did not survive a rename — the path leaked into the derivation: %v", err)
}
_ = moved.Close()
}
+10 -10
View File
@@ -6,9 +6,9 @@ package admin
// the drift verdict.
//
// SOURCE — reuse, never fork. The inventory is the SAME observation the native PaaS control
// plane already computes for /v1/paas/apps (clients/paas: observeFleet → observeCR →
// plane already computes for /v1/platform/fleet (clients/platform: observeFleet → observeCR →
// drift.go, one k8s dynamic client, one drift model). paas publishes it as an in-process
// seam (paas.CurrentFleet, fleet.go); admin RESOLVES that seam and projects each AppView
// seam (platform.CurrentFleet, fleet.go); admin RESOLVES that seam and projects each AppView
// onto the productRow the SPA decodes. There is no second k8s client and no second drift
// definition — the admin board and the PaaS board can never disagree about what the fleet is
// or what "drift" means. When the PaaS plane is not co-resident, or its k8s client did not
@@ -19,15 +19,15 @@ import (
"strings"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/paas"
"github.com/hanzoai/cloud/clients/platform"
)
// products lists the fleet workload registry: every operator App CR across the platform
// namespaces with its declared vs running image tag, reconciled health/phase and drift
// verdict. Optionally narrowed by kind, tier or env, each an exact match.
//
// The rows are the SAME observation /v1/paas/apps renders — read through the in-process
// paas seam, not a second k8s client — so the two boards can never disagree about what
// The rows are the SAME observation /v1/platform/fleet renders — read through the in-process
// platform seam, not a second k8s client — so the two boards can never disagree about what
// the fleet is. A PaaS plane that is not co-resident yields an honestly empty registry,
// never a fabricated row.
//
@@ -67,13 +67,13 @@ func products(ctx context.Context, in *productsIn) (*productsOut, error) {
// are healthy (green), and how many are drifting.
type productRollup struct{ Total, Active, Drift int }
// fleetProducts observes the platform fleet through the paas seam and projects it onto the
// fleetProducts observes the platform fleet through the platform seam and projects it onto the
// productRow board shape, returning the rows plus the rollup the overview KPIs read. A nil
// seam (PaaS not co-resident) or an unready k8s client yields an honest-empty registry with a
// nil error, so both the board and the KPIs degrade to empty rather than failing; only a hard
// observation error (e.g. an RBAC denial listing apps.hanzo.ai) surfaces as an error.
func fleetProducts(ctx context.Context) ([]productRow, productRollup, error) {
fleet := paas.CurrentFleet()
fleet := platform.CurrentFleet()
if fleet == nil {
return []productRow{}, productRollup{}, nil
}
@@ -103,7 +103,7 @@ func fleetProducts(ctx context.Context) ([]productRow, productRollup, error) {
// productFromView projects a paas fleet AppView onto a productRow: the declared/running tags
// + operator-reconciled health/phase verbatim, the drift verdict rolled to a boolean +
// severity, and the derived infra tier for the board's grouping.
func productFromView(v paas.AppView) productRow {
func productFromView(v platform.AppView) productRow {
return productRow{
Name: v.App,
Kind: v.Role, // the operator's OWN declared class (sql|kv|generic|ingress) or ""
@@ -118,7 +118,7 @@ func productFromView(v paas.AppView) productRow {
RunningTag: v.RunningTag,
LatestTag: v.LatestTag,
Health: healthLabel(v.Health),
Drift: v.Drift.Severity != paas.SeverityOK,
Drift: v.Drift.Severity != platform.SeverityOK,
DriftSeverity: string(v.Drift.Severity),
Updated: "", // the CR carries no per-row reconcile timestamp; observation is live
}
@@ -133,7 +133,7 @@ func productFromView(v paas.AppView) productRow {
// for sql/kv/generic/ingress), so the board groups on this derivation. A declarative
// `hanzo.ai/tier` label on the App CRs would make it authoritative — a universe/operator
// follow-up; until then this stays the single, documented classifier (one place, no fork).
func tierOf(v paas.AppView) string {
func tierOf(v platform.AppView) string {
// A workload in a tenant namespace is a customer / PaaS deployment, not platform infra.
// (Today the paas observer scans only the platform namespaces, so this is future-proofing
// for when the scan federates tenant/other clusters.)
+1 -1
View File
@@ -29,7 +29,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -16,7 +16,7 @@ func TestMigrateFromPreReferrerOrgSchema(t *testing.T) {
// 1) Stand up the OLD schema: affiliate_referrals WITHOUT referrer_org, and an
// affiliates row so the backfill has something to resolve.
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("open: %v", err)
}
+1 -1
View File
@@ -180,7 +180,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -89,7 +89,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -110,7 +110,7 @@ func TestMigrationIdempotentOnLegacyDB(t *testing.T) {
// the fixture with a bare sql.Open would leave a plaintext file, and converting
// one is a production operation that requires the live libsqlcipher codec — so
// the fixture, not the code under test, would fail the build the suite runs on.
legacy, err := cek.Open(path)
legacy, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("open legacy: %v", err)
}
+1 -1
View File
@@ -174,7 +174,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -32,7 +32,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -85,7 +85,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+14 -16
View File
@@ -92,8 +92,8 @@ type Entry struct {
Kind string `json:"kind"` // repo | site
// Origin is WHAT THIS IS TO YOU: template | community | third-party | product
// (origin.go owns the four nouns and derives them). Not omitempty, for the
// same reason Forkable and Official are not: every row has an answer, and a
// missing one is exactly the flattening this field exists to end.
// same reason Forkable is not: every row has an answer, and a missing one is
// exactly the flattening this field exists to end.
Origin string `json:"origin"`
Archetype string `json:"archetype,omitempty"`
Language string `json:"language,omitempty"`
@@ -106,16 +106,17 @@ type Entry struct {
Forkable bool `json:"forkable"`
Stars int `json:"stars,omitempty"`
Updated string `json:"updated,omitempty"`
// Official and Upstream/License are AUTHORSHIP. Official is the platform-gated
// first-party marker (projects.Project.Official, raised only by an admin);
// Upstream/License credit the third-party work an entry was published from.
// Together they are the difference between "we built this" and "somebody else
// built this and we are showing it to you" — which a directory titled with our
// own three orgs has no business leaving to the reader.
// Upstream/License credit the third-party work an entry was published from:
// the difference between "this org built it" and "somebody else built it and
// we are showing it to you".
//
// Official follows Forkable in NOT being omitempty, for the same reason: false
// is an answer, and omitted it could not be told from "nobody said".
Official bool `json:"official"`
// WHO built it is Org, above — the account that paid for the project. There
// was once a separate admin-gated `official` boolean here claiming the same
// thing, and because it was gated it disagreed: apps Hanzo wrote and hosts
// were published by a script holding an ordinary org token, so it stayed
// false on all of them and this directory filed our own work as somebody
// else's. A field that restates an unforgeable fact can only ever be the
// wrong copy of it.
Upstream string `json:"upstream,omitempty"`
License string `json:"license,omitempty"`
// Scope is provenance, not storage: "public" for a row from the published
@@ -237,7 +238,6 @@ func filter(in []Entry, c *zip.Ctx) []Entry {
// turns the community lane from a pile into something you can read.
orig, parent := strings.ToLower(c.Query("origin")), strings.ToLower(c.Query("template"))
fork, forkSet := boolQuery(c, "forkable")
first, firstSet := boolQuery(c, "official")
out := in[:0]
for _, e := range in {
switch {
@@ -247,8 +247,7 @@ func filter(in []Entry, c *zip.Ctx) []Entry {
kind != "" && strings.ToLower(e.Kind) != kind,
orig != "" && strings.ToLower(e.Origin) != orig,
parent != "" && strings.ToLower(e.Template) != parent,
forkSet && e.Forkable != fork,
firstSet && e.Official != first:
forkSet && e.Forkable != fork:
continue
}
out = append(out, e)
@@ -269,13 +268,12 @@ func boolQuery(c *zip.Ctx, name string) (v, ok bool) {
// there was none.
func facet(in []Entry) map[string]counts {
f := map[string]counts{"org": {}, "archetype": {}, "language": {}, "kind": {},
"origin": {}, "template": {}, "forkable": {}, "official": {}}
"origin": {}, "template": {}, "forkable": {}}
for _, e := range in {
for dim, v := range map[string]string{
"org": e.Org, "archetype": e.Archetype, "language": e.Language,
"kind": e.Kind, "origin": e.Origin, "template": e.Template,
"forkable": strconv.FormatBool(e.Forkable),
"official": strconv.FormatBool(e.Official),
} {
if v != "" {
f[dim][v]++
+25 -23
View File
@@ -284,16 +284,18 @@ func mustGetH(t *testing.T, app *zip.App, url string, hdr map[string]string) str
return body
}
// TestProvenanceReachesTheAPI is the surface half of the fix: a marker the store
// gates but the API never emits protects nothing a reader can see. It also pins
// official as a tri-state browse axis, because "show me what is NOT ours" is the
// very next question a person asks once they learn the catalog carries other
// people's work.
// TestProvenanceReachesTheAPI is the surface half: a credit the store carries but
// the API never emits protects nothing a reader can see. It also pins that "show
// me what is NOT ours" \u2014 the very next question a person asks once they learn the
// catalog carries other people's work \u2014 is answered by ORG, the account that paid
// for a project, and not by a badge that could disagree with it.
func TestProvenanceReachesTheAPI(t *testing.T) {
app := mount(t)
seed(t,
Entry{ID: "hanzo/ex-kanban", Org: "hanzo", Name: "ex-kanban", Kind: "site",
Official: true, Forkable: true, Updated: "2026-07-01"},
Forkable: true, Updated: "2026-07-01"},
Entry{ID: "acme/board", Org: "acme", Name: "board", Kind: "site",
Forkable: true, Updated: "2026-07-03"},
Entry{ID: "hanzo/kinetic", Org: "hanzo", Name: "kinetic", Kind: "site",
Upstream: "UI8 \u2014 Fitness Pro: Website UI Kit", License: "UI8 commercial licence",
Updated: "2026-07-02"},
@@ -304,23 +306,23 @@ func TestProvenanceReachesTheAPI(t *testing.T) {
for _, e := range all.Data {
got[e.Name] = e
}
if !got["ex-kanban"].Official {
t.Error("the first-party marker never reached the API")
if e := got["kinetic"]; e.Upstream == "" || e.License == "" {
t.Errorf("a third-party kit must read as credited: %+v", e)
}
if e := got["kinetic"]; e.Official || e.Upstream == "" || e.License == "" {
t.Errorf("a third-party kit must read as credited, not first-party: %+v", e)
// Ours vs somebody else's, off the one unforgeable fact.
if all.Facets["org"]["hanzo"] != 2 || all.Facets["org"]["acme"] != 1 {
t.Errorf("authorship must be countable from the org rail, got %v", all.Facets["org"])
}
if all.Facets["official"]["true"] != 1 || all.Facets["official"]["false"] != 1 {
t.Errorf("official must be faceted both ways, got %v", all.Facets["official"])
}
for q, want := range map[string]string{
"official=true": "hanzo/ex-kanban", "official=false": "hanzo/kinetic",
} {
r := decode(t, mustGet(t, app, "/v1/catalog?"+q))
if r.Total != 1 || r.Data[0].ID != want {
t.Errorf("%s: want [%s], got %+v", q, want, r.Data)
for q, want := range map[string]int{"org=hanzo": 2, "org=acme": 1} {
if r := decode(t, mustGet(t, app, "/v1/catalog?"+q)); r.Total != want {
t.Errorf("%s: want %d, got %d (%+v)", q, want, r.Total, r.Data)
}
}
// And the deleted badge is not merely unset \u2014 it is gone from the wire, so no
// client can revive a field the platform no longer stands behind.
if strings.Contains(string(mustGet(t, app, "/v1/catalog")), `"official"`) {
t.Error("the removed authorship badge is still on the wire")
}
}
// TestTwoLanesOneCorpus is the information-architecture bug this axis fixes:
@@ -334,7 +336,7 @@ func TestTwoLanesOneCorpus(t *testing.T) {
Entry{ID: "hanzo/folio", Org: "hanzo", Name: "folio", Kind: "site", Origin: OriginTemplate,
URL: "https://folio.hanzo.app", Forkable: true, Updated: "2026-07-01"},
Entry{ID: "hanzo/ex-kanban", Org: "hanzo", Name: "ex-kanban", Kind: "site", Origin: OriginCommunity,
URL: "https://ex-kanban.hanzo.app", Template: "folio", Official: true, Updated: "2026-07-02"},
URL: "https://ex-kanban.hanzo.app", Template: "folio", Updated: "2026-07-02"},
Entry{ID: "acme/board", Org: "acme", Name: "board", Kind: "site", Origin: OriginCommunity,
URL: "https://board.hanzo.app", Template: "folio", Updated: "2026-07-03"},
Entry{ID: "hanzo/ui", Org: "hanzo", Name: "ui", Kind: "repo", Origin: OriginThirdParty,
@@ -356,9 +358,9 @@ func TestTwoLanesOneCorpus(t *testing.T) {
if com.Total != 2 {
t.Fatalf("/community browses what people built, got %d: %+v", com.Total, com.Data)
}
// Ours in the community lane are the ones carrying the marker — that is the
// whole reason origin and official are two fields and not one value.
mine := decode(t, mustGet(t, app, "/v1/catalog?origin=community&official=true"))
// Ours in the community lane are the ones in our org — that is the whole
// reason origin and authorship stay two separate axes rather than one value.
mine := decode(t, mustGet(t, app, "/v1/catalog?origin=community&org=hanzo"))
if mine.Total != 1 || mine.Data[0].ID != "hanzo/ex-kanban" {
t.Errorf("a seeded example is community AND ours: %+v", mine.Data)
}
+13 -8
View File
@@ -29,14 +29,19 @@ package catalog
// three facts cannot drift, because they are the same facts the fork flow and
// the sites edge already run on.
//
// # Orthogonal to Official
// # Orthogonal to authorship
//
// Origin says which LANE a row belongs in. Official (projects.Project.Official,
// admin-gated) says WHOSE work it is. They are deliberately two fields: our
// seeded examples are community entries that carry Official, and folding that
// into one "official-example" value would make the two unaskable separately —
// while "show me community apps that are NOT ours" is the entire point of
// having a community lane at all.
// Origin says which LANE a row belongs in. Org says WHOSE work it is — the
// account that paid for it, which the tenancy boundary enforces and no request
// can forge. Keeping them separate is what makes "show me community apps that
// are NOT ours" askable, which is the entire point of having a community lane.
//
// That question is answered by Org, not by a badge. An admin-gated `official`
// boolean used to sit alongside this field trying to answer it, and it got the
// answer backwards on our own apps: they were published by a script holding an
// ordinary org token, so the gate refused them and the directory filed Hanzo's
// work as somebody else's. Deriving authorship from the paying org cannot fail
// that way, because there is no second copy of the fact to be stale.
import (
"strings"
@@ -49,7 +54,7 @@ import (
const (
// OriginTemplate is one of OUR curated starters — the thing you fork FROM.
OriginTemplate = "template"
// OriginCommunity is something somebody BUILT. Ours carry Official.
// OriginCommunity is something somebody BUILT — whose, is its Org.
OriginCommunity = "community"
// OriginThirdParty is somebody ELSE's work, shown only with its credit.
OriginThirdParty = "third-party"
+4 -4
View File
@@ -20,8 +20,8 @@ func TestOriginSeparatesTheNouns(t *testing.T) {
restore(t, []projects.LiveSite{
// the curated starter's own demo
{Org: "hanzo", Slug: "folio", Name: "Folio", URL: "https://folio.hanzo.app"},
// one of our seeded examples: built ON the platform, badged ours
{Org: "hanzo", Slug: "ex-kanban", Name: "Kanban", URL: "https://ex-kanban.hanzo.app", Official: true},
// one of our seeded examples: built ON the platform, in our own org
{Org: "hanzo", Slug: "ex-kanban", Name: "Kanban", URL: "https://ex-kanban.hanzo.app"},
// somebody else's kit we host and credit
{Org: "hanzo", Slug: "kinetic", Name: "Fitness Pro", URL: "https://kinetic.hanzo.app",
Upstream: "UI8 — Fitness Pro", License: "UI8 commercial licence"},
@@ -103,8 +103,8 @@ func TestThirdPartyIsCreditedOrNotListed(t *testing.T) {
if e.Origin != OriginThirdParty || e.Upstream != "frappe/ui" || e.License != "MIT" {
t.Errorf("a credited fork must carry whose it is: %+v", e)
}
if e.Official || e.Forkable {
t.Errorf("somebody else's work is never ours to badge or hand out: %+v", e)
if e.Forkable {
t.Errorf("somebody else's work is never ours to hand out: %+v", e)
}
// GitHub could not be read, or names no parent: not listed.
+4 -10
View File
@@ -270,10 +270,10 @@ func fromSite(s projects.LiveSite, starter map[string]bool) Entry {
// from the fact that we happen to be the ones hosting it (origin.go).
Origin: siteOrigin(s, starter),
Updated: time.Unix(s.UpdatedAt, 0).UTC().Format(time.RFC3339),
// Authorship is CARRIED, never re-derived: the store already gates Official
// behind an admin, so the corpus repeats that answer instead of forming an
// opinion of its own.
Official: s.Official, Upstream: s.Upstream, License: s.License,
// Credit is CARRIED, never re-derived: the publisher declared it, and the
// corpus repeats that answer instead of forming an opinion of its own.
// Authorship needs no field at all — it is Org, above.
Upstream: s.Upstream, License: s.License,
}
// You may fork it if there is a source to fork AND nobody has declared the
// work somebody else's. "Fork this" is an invitation, and a credited kit is
@@ -324,7 +324,6 @@ func fold(repo, site Entry) Entry {
if site.Upstream != "" {
out.Upstream, out.License = site.Upstream, site.License
}
out.Official = (repo.Official || site.Official) && site.Upstream == ""
// Forkable stays the REPO's answer, minus the same veto: hosting a demo of
// someone else's fork does not make it ours to hand over.
out.Forkable = repo.Forkable && out.Repo != "" && site.Upstream == ""
@@ -362,11 +361,6 @@ func fromRepo(r ghRepo, src source) Entry {
// of a third-party upstream is not: its license and its lineage belong
// to that upstream, and the honest fork button for it points there.
Forkable: !r.Fork, Stars: r.Stars, Updated: r.PushedAt,
// Authorship rests on the same one fact, for the same reason: a repo in an
// org we own is ours, unless GitHub itself says it is a fork — in which
// case the code is upstream's and the badge would be a lie. Derived from a
// fact GitHub already asserts, never a guess about what is inside.
Official: !r.Fork,
}
}
+12 -13
View File
@@ -191,7 +191,7 @@ func TestProvenanceSurvivesTheSync(t *testing.T) {
t.Setenv(platformOrgEnv, "hanzo")
t.Setenv(sourceOrgsEnv, "-")
restore(t, []projects.LiveSite{
{Org: "hanzo", Slug: "ex-kanban", Name: "Kanban", Official: true,
{Org: "hanzo", Slug: "ex-kanban", Name: "Kanban",
Repo: "https://github.com/hanzo-templates/ex-kanban"},
{Org: "hanzo", Slug: "kinetic", Name: "Fitness Pro",
Repo: "https://github.com/hanzo-templates/kinetic",
@@ -205,11 +205,8 @@ func TestProvenanceSurvivesTheSync(t *testing.T) {
for _, e := range publish(t, []Entry{kitRepo}) {
got[e.ID] = e
}
if e := got["hanzo/ex-kanban"]; !e.Official || !e.Forkable {
t.Errorf("a first-party example must keep its marker and its fork invite: %+v", e)
}
if e := got["hanzo/kinetic"]; e.Official {
t.Errorf("a credited third-party kit must never read as first-party: %+v", e)
if e := got["hanzo/ex-kanban"]; !e.Forkable {
t.Errorf("our own example must keep its fork invite: %+v", e)
}
if e := got["hanzo/kinetic"]; e.Upstream == "" || e.License == "" {
t.Errorf("a third-party kit must carry its credit: %+v", e)
@@ -219,14 +216,16 @@ func TestProvenanceSurvivesTheSync(t *testing.T) {
}
}
// TestRepoOfficialFollowsGitHubsOwnFork keeps the repo half honest with one fact
// GitHub already asserts: our org's repo is ours, a fork holds upstream's code.
func TestRepoOfficialFollowsGitHubsOwnFork(t *testing.T) {
if e := fromRepo(ghRepo{Name: "node"}, lx); !e.Official {
t.Error("a repo we authored in our own org is first-party")
// TestRepoForkableFollowsGitHubsOwnFork keeps the repo half honest with one fact
// GitHub already asserts. Forkable is the axis with teeth — "this is yours to
// take" — so a repo that is itself a fork of somebody else's upstream must say
// no: its licence and its lineage belong to that upstream.
func TestRepoForkableFollowsGitHubsOwnFork(t *testing.T) {
if e := fromRepo(ghRepo{Name: "node"}, lx); !e.Forkable {
t.Error("a repo we authored in our own org is yours to take")
}
if e := fromRepo(ghRepo{Name: "go-ethereum", Fork: true}, lx); e.Official {
t.Error("a fork is upstream's work; badging it first-party is the lie")
if e := fromRepo(ghRepo{Name: "go-ethereum", Fork: true}, lx); e.Forkable {
t.Error("a fork is upstream's work; handing it out is the lie")
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ type store struct {
}
func openStore(path string) (*store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -27,7 +27,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -25,7 +25,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -33,7 +33,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -25,7 +25,7 @@ func openLinkIndex(dataDir string) (*linkIndex, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("dataroom: mkdir %s: %w", dir, err)
}
db, err := cek.Open(filepath.Join(dir, "link_index.db"))
db, err := cek.Open(cek.Global, filepath.Join(dir, "link_index.db"))
if err != nil {
return nil, fmt.Errorf("dataroom: open link index: %w", err)
}
+2 -2
View File
@@ -17,7 +17,7 @@ import (
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/paas"
"github.com/hanzoai/cloud/clients/platform"
"github.com/zap-proto/zip"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -45,7 +45,7 @@ func rollback(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusBadRequest, "invalid JSON body: %v", err)
}
tag := strings.TrimSpace(body.Tag)
if !paas.IsSemverTag(tag) {
if !platform.IsSemverTag(tag) {
return zip.ErrBadRequest("'tag' must be a clean semver (vX.Y.Z) — the prior release to pin")
}
// The release seam owns the CR patch (one way). It requires the paas control
+1 -1
View File
@@ -26,7 +26,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -40,7 +40,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -117,7 +117,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+5 -1
View File
@@ -23,6 +23,7 @@ package framework
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
@@ -61,9 +62,12 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// cek is CLOUD's storage policy — encrypted at rest under a KMS-held master
// key. The engine takes it as an opener rather than importing it, so the
// same engine runs unencrypted in a test or a standalone app.
// The engine opens the deployment's own DocType stores under deps.DataDir, not a
// tenant's, so they key under the platform principal. A per-org DocType store would
// come through OrgDB, which names its owner.
eng, err := engine.Open(engine.Config{
Dir: deps.DataDir,
OpenDB: cek.Open,
OpenDB: func(path string) (*sql.DB, error) { return cek.Open(cek.Global, path) },
Logger: log,
})
if err != nil {
+1 -1
View File
@@ -184,7 +184,7 @@ func New(dataDir, adminOrg string, static Policy) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return s, fmt.Errorf("edge: mkdir %s: %w", dataDir, err)
}
db, err := cek.Open(filepath.Join(dataDir, "gateway.db"))
db, err := cek.Open(cek.Global, filepath.Join(dataDir, "gateway.db"))
if err != nil {
return s, fmt.Errorf("edge: open: %w", err)
}
+103
View File
@@ -0,0 +1,103 @@
package git
import (
"context"
"errors"
"fmt"
"time"
"github.com/hanzoai/cloud"
)
// community.go — git's half of the visibility seam.
//
// clients/projects decides whether a project may be seen (public by default,
// private is paid, hidden is moderation) and EMITS that as one fact. This
// subscriber applies it to the canonical repo at git.hanzo.ai/<org>/<slug>:
//
// Listed ⇒ the repo exists and allows anonymous read
// !Listed ⇒ the repo exists and does NOT
//
// The repo is created on the FIRST event either way, so a private project still
// has somewhere for its code to live and going public later is a flag flip
// rather than a migration. That is what makes "share it" instant and, more
// importantly, what makes un-sharing instant too.
//
// ONE way a repo comes into being: provision(), the same call the REST create
// handler uses. This file adds no second construction path — an already-existing
// repo is not an error here, it is the steady state.
// publishCommunity applies one project's visibility to its canonical repo. It is
// IDEMPOTENT by construction: an existing repo is reconciled to the event rather
// than rejected, so projects can fire on every create, visibility change and
// moderation without tracking transitions. A missed transition would leave a
// private project world-readable, which is the one failure here that cannot be
// taken back — so the cheap redundant write is the right trade.
func publishCommunity(ctx context.Context, ev cloud.CommunityEvent) error {
s := mounted.Load()
if s == nil {
return nil // git plane not mounted (or shutting down): nothing to apply
}
store, err := storeFor(s, ev.Org)
if err != nil {
return fmt.Errorf("community: open %s store: %w", ev.Org, err)
}
// Name/Description seed the repo only at creation. Re-imposing them on every
// event would overwrite an author who edited their own repo description —
// visibility is ours to enforce, their prose is not.
id, err := genID("repo")
if err != nil {
return fmt.Errorf("community: id: %w", err)
}
now := time.Now().Unix()
err = provision(s, ctx, store, Repo{
ID: id, Org: ev.Org, Name: ev.Slug,
Description: ev.Description, DefaultBranch: defaultBranchName,
Public: ev.Listed,
CreatedAt: now, UpdatedAt: now,
})
switch {
case err == nil:
// Created with the right visibility already on it; still attach (or skip)
// the replica, so a brand-new public project is mirrored like any other.
return mirrorCommunity(ctx, ev)
case !errors.Is(err, errConflict):
return fmt.Errorf("community: provision %s/%s: %w", ev.Org, ev.Slug, err)
}
// Already there: reconcile the one field this seam owns.
if err := store.SetPublic(ctx, ev.Org, "", ev.Slug, ev.Listed, now); err != nil {
return fmt.Errorf("community: set visibility %s/%s: %w", ev.Org, ev.Slug, err)
}
return mirrorCommunity(ctx, ev)
}
// mirrorCommunity gives the project a REAL GitHub repo under the community org
// and keeps its visibility in step with the canonical one, so a public project
// is public in both places and a private one is private in both.
//
// Visibility lives on the REPO, not on the mirror registration, so the mirror
// stays enabled either way and a project that goes private keeps receiving its
// own pushes — just where nobody else can read them. Deregistering instead would
// silently stop replicating, and the day it went public again the GitHub side
// would be stale by however long it was private.
//
// The registration itself routes through gitMirrorController.EnsureMirror — the
// SAME idempotent, host-allowlisted path the sync engine and the /mirror
// endpoint use — so there is one outbound target list and no second way to add
// to it. No credential ⇒ ensureGitHubRepo returns "" and the whole replica is
// skipped, rather than registering a push that could never land.
func mirrorCommunity(ctx context.Context, ev cloud.CommunityEvent) error {
url, err := ensureGitHubRepo(ctx, ev.Org, ev.Slug, ev.Description, ev.Listed)
if err != nil {
return err
}
if url == "" {
return nil
}
if err := (gitMirrorController{}).EnsureMirror(ctx, ev.Org, "", ev.Slug, url, true); err != nil {
return fmt.Errorf("community: mirror %s/%s: %w", ev.Org, ev.Slug, err)
}
return nil
}
+166
View File
@@ -0,0 +1,166 @@
package git
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// community_github.go — the GitHub replica of a community project.
//
// git.hanzo.ai is canonical; GitHub is a mirror. But a mirror nobody can find is
// not marketing, so a public project gets a real repo at
// github.com/<communityOrg>/<org>-<slug> — a link its author can hand out, star,
// and be found through — and its visibility is kept in step on BOTH hosts. One
// switch in the console, two hosts follow.
//
// We hold admin on the community org, so this creates the far-side repo rather
// than assuming somebody provisioned it. That matters: mirror_out force-pushes
// to a target that must already exist, so without this the mirror would fail on
// every project forever.
//
// PRIVATE IS HONOURED, not approximated. Flipping a project private flips the
// GitHub repo private in the same call. The alternative — deleting the replica —
// would destroy stars, forks and issue history for what the author intended as a
// temporary change, so the repo persists and only its visibility moves.
//
// Credentials are the SAME KMS-injected GIT_MIRROR_TOKEN mirror_out pushes with.
// No token ⇒ every call here is a no-op, so a dev or test deployment runs the
// whole publish path without reaching for the network.
// ghAPIBase is the GitHub API root. A package var, not a const, for the same
// reason clients/platform does it: tests point it at an httptest server so the
// create/patch decisions are proven without a network or a live org.
var ghAPIBase = "https://api.github.com"
// communityOrgEnv overrides the GitHub org community projects are replicated
// into. It exists for staging (point it at a scratch org), not as an on/off
// switch — the default is the real one, because appearing in the community is
// the opt-OUT default the platform wants.
const communityOrgEnv = "GIT_COMMUNITY_ORG"
// defaultCommunityOrg is where public projects land on GitHub.
const defaultCommunityOrg = "hanzo-community"
// communityOrg resolves the GitHub org, trimmed of anything path-like so it can
// only ever name an org.
func communityOrg() string {
if v := strings.Trim(strings.TrimSpace(os.Getenv(communityOrgEnv)), "/"); v != "" {
return v
}
return defaultCommunityOrg
}
// communityRepoName is the far-side repo name for one project. A single GitHub
// org is a flat namespace and tenant slugs collide across orgs, so the tenant org
// is part of the name. It is an identifier, derived identically every time —
// never a display name.
func communityRepoName(org, slug string) string { return org + "-" + slug }
// ghVisibility is GitHub's name for what we call listed. The CREATE endpoint
// takes a `private` boolean and the PATCH endpoint takes this string; they are
// not interchangeable (see ensureGitHubRepo), so the mapping lives here once.
func ghVisibility(listed bool) string {
if listed {
return "public"
}
return "private"
}
// githubToken is the shared mirror credential, or "" when unconfigured.
func githubToken() string { return strings.TrimSpace(os.Getenv(mirrorEnvToken)) }
// ghDo performs one authenticated GitHub API call and returns the status code.
// Bodies are read and discarded except on the decode path, so a caller never
// leaks a connection. The token rides the Authorization header only — never a
// URL, never argv, the same discipline as gitexec.
func ghDo(ctx context.Context, method, endpoint string, body any) (int, error) {
var rdr *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, rdr)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+githubToken())
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode, nil
}
// ensureGitHubRepo makes github.com/<communityOrg>/<org>-<slug> exist and match
// the project's visibility. Idempotent, and cheapest in the steady state: it
// PATCHes first (the common case is a project that already has a replica) and
// only creates on a 404.
//
// Returns the clone URL so the caller can register the mirror against exactly
// what it just ensured, and "" when the token is unconfigured — which is the
// signal to skip the mirror too, rather than register a push that cannot land.
func ensureGitHubRepo(ctx context.Context, org, slug, description string, listed bool) (string, error) {
if githubToken() == "" {
return "", nil
}
ghOrg, name := communityOrg(), communityRepoName(org, slug)
repoURL := fmt.Sprintf("https://github.com/%s/%s.git", ghOrg, name)
api := fmt.Sprintf("%s/repos/%s/%s", ghAPIBase,
url.PathEscape(ghOrg), url.PathEscape(name))
// Visibility is the ONE field this owns. Description is sent only at create
// (below) so an author who edits it on GitHub keeps their edit.
//
// The field is `visibility`, NOT `private`. They look interchangeable and are
// not: PATCHing {"private":true} on an org repo is rejected 422 with an empty
// error list, while {"visibility":"private"} succeeds. Verified against the
// live hanzo-community org — with `private` here, creates would have worked
// and every RETRACTION would have silently failed, which is precisely the
// direction that cannot be allowed to fail.
code, err := ghDo(ctx, http.MethodPatch, api, map[string]any{"visibility": ghVisibility(listed)})
if err != nil {
return "", fmt.Errorf("github: patch %s/%s: %w", ghOrg, name, err)
}
if code == http.StatusOK {
return repoURL, nil
}
if code != http.StatusNotFound {
return "", fmt.Errorf("github: patch %s/%s: status %d", ghOrg, name, code)
}
// Not there yet: create it, born with the right visibility so a private
// project is never briefly public. auto_init stays false — the first mirror
// push carries the real history, and an initial commit would collide with it.
create := fmt.Sprintf("%s/orgs/%s/repos", ghAPIBase, url.PathEscape(ghOrg))
code, err = ghDo(ctx, http.MethodPost, create, map[string]any{
"name": name, "description": description,
"private": !listed, "auto_init": false, "has_wiki": false,
})
if err != nil {
return "", fmt.Errorf("github: create %s/%s: %w", ghOrg, name, err)
}
// 422 is GitHub's "name already exists" — a concurrent publish won the race,
// which is exactly the state we wanted.
if code != http.StatusCreated && code != http.StatusUnprocessableEntity {
return "", fmt.Errorf("github: create %s/%s: status %d", ghOrg, name, code)
}
return repoURL, nil
}
+159
View File
@@ -0,0 +1,159 @@
package git
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// community_github_test.go proves the GitHub replica's decisions without a
// network or a live org: an httptest server stands in for api.github.com.
// ghCall is one request the fake API saw.
type ghCall struct {
Method, Path string
Body map[string]any
}
// fakeGitHub serves the two endpoints ensureGitHubRepo uses. `exists` decides
// whether the repo is already there, which is the whole branch under test.
func fakeGitHub(t *testing.T, exists bool) (*[]ghCall, func()) {
t.Helper()
var mu sync.Mutex
calls := []ghCall{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(b, &body)
mu.Lock()
calls = append(calls, ghCall{Method: r.Method, Path: r.URL.Path, Body: body})
mu.Unlock()
switch {
case r.Method == http.MethodPatch && !exists:
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPatch:
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost:
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusInternalServerError)
}
}))
old := ghAPIBase
ghAPIBase = srv.URL
t.Setenv(mirrorEnvToken, "test-token")
t.Cleanup(func() { ghAPIBase = old; srv.Close() })
return &calls, srv.Close
}
// TestCommunityRepoIsCreatedWhenMissing: we hold admin on the community org, so
// a first publish CREATES the far-side repo rather than assuming somebody
// provisioned it. Without this the mirror would force-push at a target that does
// not exist, and fail for every project forever.
func TestCommunityRepoIsCreatedWhenMissing(t *testing.T) {
calls, _ := fakeGitHub(t, false)
url, err := ensureGitHubRepo(context.Background(), "acme", "board", "a board", true)
if err != nil {
t.Fatalf("ensure: %v", err)
}
if want := "https://github.com/hanzo-community/acme-board.git"; url != want {
t.Fatalf("clone url = %q, want %q", url, want)
}
if len(*calls) != 2 {
t.Fatalf("want probe-then-create, got %d calls: %+v", len(*calls), *calls)
}
create := (*calls)[1]
if create.Method != http.MethodPost || !strings.HasSuffix(create.Path, "/orgs/hanzo-community/repos") {
t.Fatalf("second call must create in the community org: %+v", create)
}
if create.Body["name"] != "acme-board" {
t.Fatalf("repo name = %v, want acme-board (org-qualified: one flat namespace)", create.Body["name"])
}
if create.Body["private"] != false {
t.Fatalf("a public project must be born public, got private=%v", create.Body["private"])
}
}
// TestCommunityRepoIsBornPrivate: a private project's replica must never be
// briefly public. Visibility is set AT creation, not patched afterwards.
func TestCommunityRepoIsBornPrivate(t *testing.T) {
calls, _ := fakeGitHub(t, false)
if _, err := ensureGitHubRepo(context.Background(), "acme", "secret", "", false); err != nil {
t.Fatalf("ensure: %v", err)
}
create := (*calls)[len(*calls)-1]
if create.Body["private"] != true {
t.Fatal("a private project's replica was created public, even momentarily")
}
if create.Body["auto_init"] != false {
t.Fatal("auto_init must stay false: the first mirror push carries the real history")
}
}
// TestVisibilityStaysInStepOnBothHosts is the user-visible promise: one switch
// in the console, both hosts follow. An existing replica is PATCHed — never
// re-created and never deleted, so stars, forks and issues survive a project
// going private and coming back.
func TestVisibilityStaysInStepOnBothHosts(t *testing.T) {
for _, tc := range []struct {
name string
listed bool
wantPrivate bool
}{
{"public", true, false},
{"private", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
calls, _ := fakeGitHub(t, true)
if _, err := ensureGitHubRepo(context.Background(), "acme", "board", "", tc.listed); err != nil {
t.Fatalf("ensure: %v", err)
}
if len(*calls) != 1 {
t.Fatalf("an existing replica needs ONE patch, got %d: %+v", len(*calls), *calls)
}
c := (*calls)[0]
if c.Method != http.MethodPatch {
t.Fatalf("existing repo must be patched, not %s", c.Method)
}
// `visibility`, not `private`: GitHub rejects {"private":bool} on an org
// repo with a 422 and an EMPTY error list, so a wrong field here fails
// silently in exactly the retraction direction that must not fail.
if _, wrong := c.Body["private"]; wrong {
t.Fatal(`PATCH must send "visibility", not "private" — GitHub 422s the latter`)
}
want := "public"
if tc.wantPrivate {
want = "private"
}
if c.Body["visibility"] != want {
t.Fatalf("visibility = %v, want %v", c.Body["visibility"], want)
}
if _, sent := c.Body["description"]; sent {
t.Fatal("description must not be re-imposed: an author's own edit has to survive")
}
})
}
}
// TestNoCredentialMeansNoReplica: a dev or test deployment must run the whole
// publish path without reaching for the network, and must NOT register a mirror
// push that could never land.
func TestNoCredentialMeansNoReplica(t *testing.T) {
t.Setenv(mirrorEnvToken, "")
url, err := ensureGitHubRepo(context.Background(), "acme", "board", "", true)
if err != nil {
t.Fatalf("unconfigured must be a no-op, got %v", err)
}
if url != "" {
t.Fatalf("no credential must yield no mirror target, got %q", url)
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ func (s *stores) openLocked(ctx context.Context, tenant, seg string) (*sql.DB, e
return nil, fmt.Errorf("gojabase[%s]: mkdir %q: %w", s.name, s.dir, err)
}
path := filepath.Join(s.dir, seg+".db")
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("gojabase[%s]: open %q: %w", s.name, path, err)
}
+1 -1
View File
@@ -36,7 +36,7 @@ type BlueprintStore struct {
// openBlueprintStore opens the shared blueprint DB at path (a cek-sealed SQLite file,
// the house pattern) and migrates. MaxOpenConns(1) serializes writes.
func openBlueprintStore(path string) (*BlueprintStore, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open blueprint store %q: %w", path, err)
}
+3 -57
View File
@@ -107,16 +107,6 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return nil
}
// Carry a store written under the old name forward before opening, so the rename
// is a one-time move on the volume rather than a fork in the path logic.
if moved, merr := adoptLegacyStore(filepath.Dir(dbPath)); merr != nil {
log.Error("iam store adopt failed — serving fail-closed 503 (cloud stays up)", "err", merr)
mountFailClosed(app)
return nil
} else if moved != "" {
log.Info("iam store adopted under its current name", "from", moved, "to", storeFile)
}
db, err := openStore(dbPath)
if err != nil {
log.Error("iam store open failed — serving fail-closed 503 (cloud stays up; standalone iam pod unaffected)", "err", err, "path", dbPath)
@@ -165,64 +155,20 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// PrincipalOrg/PrincipalUser and be named for THAT, so the split is visible on disk
// instead of inferred.
//
// The name it replaces, iam2.db, carried a version number that stopped meaning anything
// when the Casdoor iam-v1 embed was retired — a "2" that exists only to not be a "1" is
// A version number never appears here. One that exists only to not be a lower one is
// scar tissue, and a version in a filename is a migration waiting to be mistaken for an
// identity.
const (
storeDir = "iam"
storeFile = "global.db"
// legacyStoreFile is what the store was called while iam-v1 still existed. The
// only reason to still know the name is to move it.
legacyStoreFile = "iam2.db"
)
// adoptLegacyStore renames a pre-existing iam2.db (and the cek sidecar and lock that
// belong to it) to the current name, once, before the store is opened. It returns the
// name it moved, or "" when there was nothing to do.
//
// A rename is safe here for a specific reason, not by assumption: cek derives the KEK
// from a random file id stored INSIDE the sidecar, never from the path, so a store
// carried to a new name reopens under the same key — which cek's own doc promises and
// TestStoreSurvivesRename pins. Moving the sidecar alongside the database is therefore
// the whole migration; no page is rewritten and no key is re-derived.
//
// It refuses rather than overwrites if both names exist. Two identity stores in one
// directory is not a case to resolve automatically: picking either one silently serves
// a set of identities somebody did not choose.
func adoptLegacyStore(dir string) (string, error) {
legacy := filepath.Join(dir, legacyStoreFile)
current := filepath.Join(dir, storeFile)
// cek.Exists, not os.Stat: a store is present when EITHER the database or its
// wrapped-DEK sidecar is there, and on a pure-Go build the codec envelope keys the
// file out of band so the sidecar can be the only thing on disk. Statting the
// database alone reports "no legacy store" for exactly those builds, and the store
// is then silently abandoned while a fresh empty one is created beside it — which
// is what this function exists to prevent, so it must not be how it looks.
if !cek.Exists(legacy) {
return "", nil // nothing to adopt — the normal path
}
if cek.Exists(current) {
return "", fmt.Errorf("both %s and %s exist in %s; refusing to guess which identity store is authoritative — move the one you do not want aside", legacyStoreFile, storeFile, dir)
}
// The database and the two files cek keeps beside it. The sidecar is the only one
// that MUST travel (it holds the wrapped DEK and the file id the KEK derives from);
// the lock is advisory and simply belongs with it.
for _, suffix := range []string{"", ".dek", ".cek.lock"} {
if err := os.Rename(legacy+suffix, current+suffix); err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("adopt %s%s: %w", legacyStoreFile, suffix, err)
}
}
return legacyStoreFile, nil
}
// openStore opens IAM's store through cek — the SAME encryption-at-rest gate every
// other cloud store opens through — and layers the ORM over that handle.
//
// It replaces iamserver.OpenSQLite, which builds its own pool from a plain path and
// has no key to give it: orm's SQLiteDBConfig carries no master key, so that path
// wrote `iam/iam2.db` with the literal `SQLite format 3` header — every identity, org
// wrote the store with the literal `SQLite format 3` header — every identity, org
// membership, and credential hash readable from a lifted PV snapshot or an in-cluster
// volume read. That is precisely the exposure cek exists to remove, and cek's own doc
// claims "encrypted at rest is a property of the open path"; this store was the
@@ -238,7 +184,7 @@ func adoptLegacyStore(dir string) (string, error) {
// rather than merely tidy: on a pure-Go build the codec envelope is single-writer, so
// a second pool over the same keyed file is not an option to begin with.
func openStore(path string) (orm.DB, error) {
conn, err := cek.Open(path)
conn, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("iam: open store: %w", err)
}
-130
View File
@@ -1,130 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
package iam
import (
"os"
"path/filepath"
"testing"
"github.com/hanzoai/cloud/cek"
)
// A store written under the old name must come forward with its CONTENTS, not merely
// with its filename. The rename moves an encrypted file away from the sidecar that
// holds its wrapped DEK unless the sidecar travels too, and that failure would look
// like an empty identity store rather than an error — so the row written before the
// move is the assertion.
func TestLegacyStoreIsAdoptedWithItsData(t *testing.T) {
cek.EnsureDevKey()
dir := t.TempDir()
legacy := filepath.Join(dir, legacyStoreFile)
db, err := cek.Open(legacy)
if err != nil {
t.Fatalf("seed legacy store: %v", err)
}
if _, err := db.Exec(`CREATE TABLE identities(name TEXT)`); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := db.Exec(`INSERT INTO identities VALUES('ada')`); err != nil {
t.Fatalf("insert: %v", err)
}
_ = db.Close()
moved, err := adoptLegacyStore(dir)
if err != nil {
t.Fatalf("adoptLegacyStore: %v", err)
}
if moved != legacyStoreFile {
t.Fatalf("adopted %q, want %q", moved, legacyStoreFile)
}
got, err := openStore(filepath.Join(dir, storeFile))
if err != nil {
t.Fatalf("open adopted store: %v", err)
}
t.Cleanup(func() { _ = got.Close() })
// Read through cek at the new name — the identity written before the move must
// still be there, which is only true if the wrapped DEK came with it.
conn, err := cek.Open(filepath.Join(dir, storeFile))
if err != nil {
t.Fatalf("reopen adopted store: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
var name string
if err := conn.QueryRow(`SELECT name FROM identities`).Scan(&name); err != nil {
t.Fatalf("the adopted store lost its data (the sidecar did not travel): %v", err)
}
if name != "ada" {
t.Fatalf("read %q, want ada", name)
}
}
// A legacy store can be present as its SIDECAR ALONE — the pure-Go codec envelope keys
// the file out of band, so `iam2.db` need not exist while `iam2.db.dek` does. A live
// boot proved that statting the database alone reports "nothing to adopt", abandons the
// real store, and creates an empty one beside it. Presence must be answered the way cek
// answers it.
func TestLegacyStorePresentAsSidecarOnlyIsAdopted(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, legacyStoreFile+".dek"), []byte("wrapped"), 0o600); err != nil {
t.Fatalf("seed sidecar: %v", err)
}
moved, err := adoptLegacyStore(dir)
if err != nil {
t.Fatalf("adoptLegacyStore: %v", err)
}
if moved != legacyStoreFile {
t.Fatalf("a sidecar-only legacy store was not adopted (it would be silently abandoned and replaced by an empty store); moved=%q", moved)
}
if _, err := os.Stat(filepath.Join(dir, storeFile+".dek")); err != nil {
t.Fatalf("the wrapped DEK did not arrive at the current name: %v", err)
}
}
// Nothing to adopt is the normal path and must be silent, not an error.
func TestAdoptIsANoOpWithoutALegacyStore(t *testing.T) {
moved, err := adoptLegacyStore(t.TempDir())
if err != nil {
t.Fatalf("adopt on a fresh dir: %v", err)
}
if moved != "" {
t.Fatalf("adopted %q from an empty dir", moved)
}
}
// NEGATIVE, and the one that matters: two identity stores side by side must stop the
// boot. Choosing either silently serves a set of identities nobody selected, and
// overwriting destroys the other.
func TestAdoptRefusesWhenBothNamesExist(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{legacyStoreFile, storeFile} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
if _, err := adoptLegacyStore(dir); err == nil {
t.Fatal("adopt silently picked one of two identity stores")
}
// And it must not have destroyed either one while refusing.
for _, name := range []string{legacyStoreFile, storeFile} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("%s was removed by a refused adopt: %v", name, err)
}
}
}
// The name carries no version number. This is the rule being enforced, so it is worth
// stating as a test rather than leaving to review.
func TestStoreNameCarriesNoVersion(t *testing.T) {
for _, r := range storeFile {
if r >= '0' && r <= '9' {
t.Fatalf("store filename %q contains a digit — a version in a filename is a migration waiting to be mistaken for an identity", storeFile)
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ func migrateStore(dir, previous, current string) error {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -40,7 +40,7 @@ type Store struct {
var ErrHostTaken = errors.New("host already claimed by another route")
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -60,7 +60,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+24 -7
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/hanzoai/cloud/credz"
"github.com/hanzoai/cloud/credz/launch"
luxlog "github.com/luxfi/log"
)
@@ -46,8 +47,8 @@ type childView struct {
// TestMain gives this package the plugin-child half. The child runs the REAL
// boot path — credz.Boot, exactly as cloud.Serve calls it — rather than a
// test-only accessor, so what this proves is what production does. A child is
// identified by the kernel from its argv, which is why it has to be a real
// process really named after an app (see bootAs).
// identified by the token its LAUNCHER stamped on it, which is why it has to be
// a real process this test really started (see bootAs).
func TestMain(m *testing.M) {
// The PARENT is the broker, so it boots the way the live broker does: the root
// key in its own environment, taken by credz.Boot into cek before any store
@@ -89,7 +90,8 @@ func TestEmbeddedClientIsACredzSource(t *testing.T) {
// TestBrokerServesTheSealedStoreScoped is the acceptance test for the whole
// mechanism: the app that owns a secret reads it out of the sealed store through
// the broker, and the app that does not is refused — not by an ACL it could argue
// with, but because the store path is built from who the kernel says it is.
// with, but because the store path is built from the app its launcher stamped on
// it, which it cannot choose.
func TestBrokerServesTheSealedStoreScoped(t *testing.T) {
dir := t.TempDir()
c, err := New(Config{DataDir: dir, MasterKeyB64: base64.StdEncoding.EncodeToString(testMaster)}, luxlog.New("test"))
@@ -148,6 +150,13 @@ func TestBrokerServesTheSealedStoreScoped(t *testing.T) {
if strings.Contains(ai.Proc, "sk-live-ai-provider-key") {
t.Fatal("the provider key is readable in the child's /proc/self/environ")
}
// The launch token is single-use: Boot reads it and takes it out, so anything
// this child execs inherits an environment that cannot answer for it. It is
// still in the child's /proc/self/environ — that is the honest limit stated in
// credz/launch, and it is why this asserts the scrub and not secrecy.
if v, ok := ai.Getenv[launch.TokenEnv]; ok {
t.Fatalf("%s survived Boot as %q — every process this child starts could present it", launch.TokenEnv, v)
}
billing := bootAs(t, "billing", dir)
if billing.Getenv["COMMERCE_SERVICE_TOKEN"] != "svc-billing-token" {
@@ -186,8 +195,15 @@ func publishFor(t *testing.T, c *Client, dir string) {
t.Cleanup(func() { _ = closer.Close() })
}
// bootAs boots a child the kernel will name `app`, through the real credz.Boot,
// and returns what that child can see.
// bootAs boots a child THIS PROCESS LAUNCHED as `app`, through the real
// credz.Boot, and returns what that child can see.
//
// The stamp is what makes it that app — credz.LaunchSecret() is the same secret
// this process handed its broker in publishFor, so this test binary is playing
// the launcher exactly as cloud.PluginSpec and cmd/host do. Without it every
// child is refused, which is the correct failure and the reason this line is not
// optional. The binary is still symlinked to <app> so a regression that started
// reading argv again would be visible rather than harmless.
func bootAs(t *testing.T, app, dir string) childView {
t.Helper()
self, err := os.Executable()
@@ -201,8 +217,9 @@ func bootAs(t *testing.T, app, dir string) childView {
cmd := exec.Command(bin)
// No root key and no credentials in the child's environment: everything it
// ends up with, it got from the broker. This is the spawn shape zip produces
// minus the one variable a launcher must no longer be carrying.
cmd.Env = append(scrubbed(os.Environ()), bootInEnv+"="+dir)
// minus the one variable a launcher must no longer be carrying, plus the one
// a launcher must now be stamping.
cmd.Env = append(scrubbed(os.Environ()), bootInEnv+"="+dir, launch.Env(credz.LaunchSecret(), app))
out, err := cmd.Output()
if err != nil {
t.Fatalf("%s boot: %v", app, err)
+50 -9
View File
@@ -218,8 +218,21 @@ func TestSignFailsClosedNoMPC(t *testing.T) {
}
// TestRESTRoundtripOrgScoped: the /v1/kms REST surface upserts + reads a secret
// for the caller's own org (simulated validated principal), and rejects a
// cross-org read with 403 — the org-isolation boundary.
// for the caller's own org (simulated validated principal), and hides another
// org's secret behind a 404 — the org-isolation boundary.
//
// WHY 404 AND NOT 403. The org used to be a path segment that had to equal the
// caller's, so a mismatch was a FORBIDDEN request for a nameable resource. The org
// is now read from the validated principal and is UNSPELLABLE in a URL
// (mount.go reqOrg), and it is folded into a per-org store partition. A caller
// therefore cannot form a request for another org's secret at all: the request it
// does form resolves inside its OWN namespace, where the record is simply absent.
// NOT-FOUND is the correct — and strictly stronger — signal: 403 conceded that the
// resource existed and merely refused it, which is an existence oracle across
// tenants (pinned closed by TestVector4_NoCrossOrgExistenceOracle). Every
// assertion below reads the BODY as well as the status, because after the reshape
// both tenants spell the identical URL and a status alone cannot distinguish
// "refused" from "served the caller its own record".
func TestRESTRoundtripOrgScoped(t *testing.T) {
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
@@ -239,23 +252,51 @@ func TestRESTRoundtripOrgScoped(t *testing.T) {
t.Errorf("GET secret value = %q, want hk-abc123", v)
}
// A different org (evil) may NOT read hanzo's secret.
// A different org (evil) sends the IDENTICAL request and gets not-found: its
// org resolves it into evil's own namespace, which holds nothing. The body
// check is the isolation assertion — the status only says "no record here".
resp = do(t, app, "GET", "/v1/kms/secrets/API_KEY?env=main", "evil", "", false, nil)
if resp.StatusCode != 403 {
t.Errorf("cross-org GET = %d, want 403 (org isolation)", resp.StatusCode)
if resp.StatusCode != 404 {
t.Errorf("cross-org GET = %d, want 404 (org unspellable in URL ⇒ resolves in the "+
"caller's own namespace ⇒ not-found, which hides existence rather than confirming it)", resp.StatusCode)
}
if b := readAll(resp.Body); strings.Contains(b, "hk-abc123") {
t.Fatalf("LEAK: cross-org GET returned hanzo's secret: %s", b)
}
// A SuperAdmin may read any org.
// A SuperAdmin gets the SAME answer here, and that is the whole point: this
// harness omits SanitizeIdentity, so `admin`+isAdmin is just an org header, and
// there is no URL in which an admin can name another org.
//
// DECISION PENDING — the admin cross-org READ was not deleted, it MOVED. This
// line used to assert 200 via URL traversal (/v1/kms/orgs/{org}/…), a route
// that no longer exists. The capability survives on the identity instead:
// SanitizeIdentity's SuperAdmin arm honors X-Org-Id as the effective org
// (middleware_identity.go `effOrg = cliOrg`), gated on membership of the
// reserved admin org AND !isMachinePrincipal. That claim-bound org-switch is
// pinned end-to-end, WITH the switched-into tenant's plaintext asserted, by
// TestRedIso_C_AdminCrossOrg. Flagged for z: keep the org-switch as the one
// admin impersonation path (current behavior), or remove platform sudo from
// KMS entirely? Until that is answered this asserts what the code does.
resp = do(t, app, "GET", "/v1/kms/secrets/API_KEY?env=main", "admin", "", true, nil)
if resp.StatusCode != 200 {
t.Errorf("admin cross-org GET = %d, want 200", resp.StatusCode)
if resp.StatusCode != 404 {
t.Errorf("admin cross-org GET = %d, want 404 (no URL names an org, admin or not; "+
"the retained admin path is the claim-bound org-switch — see TestRedIso_C_AdminCrossOrg)", resp.StatusCode)
}
if b := readAll(resp.Body); strings.Contains(b, "hk-abc123") {
t.Fatalf("LEAK: admin URL-traversal GET returned hanzo's secret: %s", b)
}
// An unauthenticated caller (no org, not admin) is refused.
// An unauthenticated caller (no principal) is refused BEFORE the store is
// touched — 403, distinct from the 404 above, because the failure is the
// credential, not the coordinate.
resp = do(t, app, "GET", "/v1/kms/secrets/API_KEY?env=main", "", "", false, nil)
if resp.StatusCode != 403 {
t.Errorf("anonymous GET = %d, want 403", resp.StatusCode)
}
if b := readAll(resp.Body); strings.Contains(b, "hk-abc123") {
t.Fatalf("LEAK: anonymous GET returned hanzo's secret: %s", b)
}
}
// TestRESTSecretOpsFailClosedWithoutKey: without a master key, an authorized
+60 -15
View File
@@ -24,6 +24,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/hanzoai/cloud"
@@ -68,31 +69,75 @@ func TestPaaSSecretSealReadAlignment(t *testing.T) {
}
// TestPaaSSecretCrossTenantDenied is the NON-NEGOTIABLE proof: tenant-B, presenting
// a validated principal for its OWN org, cannot read tenant-A's platform secret
// the guard denies 403 before the store is touched. The denial is the ORG boundary
// (B reading its own absent scope is 404, not 403), not a blanket failure.
// a validated principal for its OWN org, never obtains tenant-A's platform secret.
//
// ONE PATH, TWO TENANTS. The KMSSecret CR points every operator at the SAME URL —
// /v1/kms/secrets/platform/<app>/<KEY> — because the org is no longer in it; each
// operator's own token supplies the tenant. So "B reading A's path" and "B reading
// its own path" are now the SAME REQUEST, distinguished only by the credential and
// answered only in the body. That is why this test asserts VALUES, not just codes:
// a status-only check cannot tell a refusal from B being served B's own record, and
// the earlier `want 403` was in fact firing on exactly that.
//
// The signal is 404, not 403, and it is stronger: B cannot express a request for
// A's record at all (the org is unspellable), so what B gets is the honest answer
// for B's own namespace — absent, or B's own secret — and A's existence is never
// confirmed. 403 is reserved for the case that really is a refusal: no principal.
func TestPaaSSecretCrossTenantDenied(t *testing.T) {
app, deps := newApp(t, baseCfg(t, masterKeyB64(t)))
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // only A's secret exists
aPath := "/v1/kms" + paasEnvPath
if resp := do(t, app, "GET", aPath, paasOrgB, "", false, nil); resp.StatusCode != 403 {
t.Fatalf("cross-tenant read (B→A) = %d, want 403 DENIED", resp.StatusCode)
path := "/v1/kms" + paasEnvPath
// B, at the operator's URL, with nothing of its own there: not-found, and — the
// actual isolation assertion — A's plaintext is nowhere in the response.
resp := do(t, app, "GET", path, paasOrgB, "", false, nil)
if resp.StatusCode != 404 {
t.Fatalf("cross-tenant read (B at A's URL) = %d, want 404 (B's token resolves the "+
"path inside B's own namespace, where the record is absent)", resp.StatusCode)
}
// Same request from A itself succeeds — the credential, not the path, is the gate.
if resp := do(t, app, "GET", aPath, paasOrgA, "", false, nil); resp.StatusCode != 200 {
if b := readAll(resp.Body); strings.Contains(b, paasValueA) {
t.Fatalf("LEAK: tenant B received tenant A's platform secret: %s", b)
}
// Same request from A itself succeeds and returns A's value — the credential,
// not the path, selects the tenant.
resp = do(t, app, "GET", path, paasOrgA, "", false, nil)
if resp.StatusCode != 200 {
t.Fatalf("A→A = %d, want 200 (the boundary is the caller's org, not the path)", resp.StatusCode)
}
// B reading its OWN (absent) scope is 404, not 403 — the denial is org-scoped.
bPath := "/v1/kms" + paasEnvPath
if resp := do(t, app, "GET", bPath, paasOrgB, "", false, nil); resp.StatusCode != 404 {
t.Fatalf("B→B (absent) = %d, want 404 (boundary is org, not blanket-deny)", resp.StatusCode)
if got := decode(t, resp.Body)["value"]; got != paasValueA {
t.Fatalf("A read value=%v, want its own sealed secret", got)
}
// A forged X-Org-Id is irrelevant here because the guard also requires a
// VALIDATED principal (X-User-Id); an org with no principal is refused.
if resp := do(t, app, "GET", aPath, "", "", false, nil); resp.StatusCode != 403 {
// Now give B a secret of its own at the IDENTICAL coordinate. Both tenants
// issue byte-identical requests; each must receive its own plaintext. This is
// the case a status-only test is blind to, and the one that would actually
// catch a partition break.
const valueB = "s3kr3t-of-acme"
sealPlatformSecret(t, deps.KMS, paasOrgB, valueB)
resp = do(t, app, "GET", path, paasOrgB, "", false, nil)
if resp.StatusCode != 200 {
t.Fatalf("B→B = %d, want 200 once B has its own record", resp.StatusCode)
}
if got := decode(t, resp.Body)["value"]; got != valueB {
t.Fatalf("PARTITION BREAK: B read value=%v, want %q (B must never see A's record)", got, valueB)
}
// …and A is unaffected by B's record existing at the same coordinate.
if got := decode(t, do(t, app, "GET", path, paasOrgA, "", false, nil).Body)["value"]; got != paasValueA {
t.Fatalf("PARTITION BREAK: A read value=%v, want %q", got, paasValueA)
}
// A forged X-Org-Id is irrelevant because the guard also requires a VALIDATED
// principal (X-User-Id); an org with no principal is refused 403 — the one
// genuine FORBIDDEN on this surface, and it never reaches the store.
resp = do(t, app, "GET", path, "", "", false, nil)
if resp.StatusCode != 403 {
t.Fatalf("unauthenticated read = %d, want 403", resp.StatusCode)
}
if b := readAll(resp.Body); strings.Contains(b, paasValueA) {
t.Fatalf("LEAK: unauthenticated read returned A's secret: %s", b)
}
}
// TestPaaSLoginBrokerHappyPath proves /v1/kms/auth/login brokers the operator's
+113 -36
View File
@@ -45,31 +45,67 @@ func TestVector1_OrgCaseFoldCollision(t *testing.T) {
t.Logf("cross-case GET blocked with status=%d (breach requires IAM to issue case-distinct owners)", resp.StatusCode)
}
// TestVector1b: the guard is EXACT-match (==), NOT EqualFold — so a tenant whose
// validated owner is "AcmeCorp" is REFUSED on any casing-mismatched :org param
// (403), because the store path keys on :org verbatim and a case-insensitive
// authz check would let "Acme" reach "acme"'s namespace. Confirm the exact-match
// closes the split-namespace hazard: a lowercased :org for a mixed-case owner is
// denied outright (not silently split into a second bucket).
// TestVector1b: case-distinct owners are DISTINCT TENANTS, end to end.
//
// The hazard this vector names — "Acme" reaching "acme"'s namespace — used to live
// in the guard, which compared a caller's owner against an :org PATH PARAM and so
// had to get the comparison's case-sensitivity exactly right. That comparison is
// gone: there is no :org to mismatch, and the org folded into the store path is the
// caller's own, taken VERBATIM (never lowercased — principal.Org, mount.go orgPath).
//
// The hazard did not vanish with it, it MOVED DOWN a layer: two case-distinct
// owners must still land on two different store partitions, or the fold itself
// re-creates the collision with no forged header required. cloud.SanitizeOrg is the
// injective map that closes it (a non-lowercase owner is not the identity — it gets
// a SHA-256-derived suffix — so "AcmeCorp" and "acmecorp" never share a file). So
// this test now asserts the PROPERTY (distinct tenants, distinct data) rather than
// the old mechanism's 403, and it asserts VALUES: the only honest evidence that two
// namespaces are separate is that each caller gets its own bytes.
func TestVector1b_ExactOrgMatchNoSplit(t *testing.T) {
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
const owner = "AcmeCorp"
const lower = "acmecorp"
// Owner uses its exact casing — allowed.
// Mixed-case owner writes in its own namespace.
body, _ := json.Marshal(map[string]string{"name": "K", "value": "written-mixedcase", "env": "default"})
if r := do(t, app, "POST", "/v1/kms/secrets", owner, string(body), false, nil); r.StatusCode != 200 {
t.Fatalf("POST exact-case = %d, want 200", r.StatusCode)
}
// Same owner, lowercased :org — EXACT match fails → 403. No second bucket.
// …and reads it back. There is no casing to mismatch: the org came from the
// principal, so the write and the read address one namespace by construction.
r := do(t, app, "GET", "/v1/kms/secrets/K", owner, "", false, nil)
if r.StatusCode != 403 {
t.Fatalf("BREACH: lowercased :org for owner %q = %d, want 403 (exact-match guard)", owner, r.StatusCode)
if r.StatusCode != 200 {
t.Fatalf("owner %q reading its OWN secret = %d, want 200", owner, r.StatusCode)
}
r = do(t, app, "POST", "/v1/kms/secrets", owner, string(body), false, nil)
if r.StatusCode != 403 {
t.Fatalf("BREACH: owner %q could write a lowercased bucket = %d, want 403", owner, r.StatusCode)
if v, _ := decode(t, r.Body)["value"].(string); v != "written-mixedcase" {
t.Fatalf("owner %q read %q, want its own value", owner, v)
}
t.Logf("exact-match guard: owner %q cannot touch /orgs/acmecorp (403) — no case-split namespace", owner)
// THE ATTACK: a SEPARATE tenant whose owner is the lowercase spelling issues the
// identical request. It must see its own (empty) namespace — never the
// mixed-case tenant's record.
r = do(t, app, "GET", "/v1/kms/secrets/K", lower, "", false, nil)
if r.StatusCode != 404 {
t.Fatalf("CASE-FOLD BREACH: tenant %q reading tenant %q's coordinate = %d, want 404 "+
"(distinct owners must be distinct namespaces)", lower, owner, r.StatusCode)
}
if b := readAll(r.Body); strings.Contains(b, "written-mixedcase") {
t.Fatalf("CASE-FOLD BREACH: tenant %q received tenant %q's value: %s", lower, owner, b)
}
// The converse: the lowercase tenant's own write must not overwrite or shadow
// the mixed-case tenant's record at the same coordinate.
lowBody, _ := json.Marshal(map[string]string{"name": "K", "value": "written-lowercase", "env": "default"})
if r := do(t, app, "POST", "/v1/kms/secrets", lower, string(lowBody), false, nil); r.StatusCode != 200 {
t.Fatalf("POST lowercase tenant = %d, want 200", r.StatusCode)
}
for _, tc := range []struct{ org, want string }{{owner, "written-mixedcase"}, {lower, "written-lowercase"}} {
got, _ := decode(t, do(t, app, "GET", "/v1/kms/secrets/K", tc.org, "", false, nil).Body)["value"].(string)
if got != tc.want {
t.Fatalf("CASE-FOLD BREACH: tenant %q read %q, want %q (namespaces collided)", tc.org, got, tc.want)
}
}
t.Logf("case-distinct owners %q and %q hold distinct records — SanitizeOrg fold is injective", owner, lower)
}
// ── VECTOR 2: AAD relocation — name-only DEK-wrap AAD ──────────────────────────
@@ -227,10 +263,17 @@ func TestDeepB_SiblingOrgListPrefix(t *testing.T) {
t.Logf("no sibling prefix leak: org x sees exactly its own secret")
}
// TestDeepC: does the REST list endpoint honor the org guard for a sibling-prefix
// attacker? Attacker org "x" tries to list victim "xy" by exploiting that "x" is
// a string-prefix of "xy" — but the guard is EXACT (==), so :org=xy with org=x
// caller is 403, and :org=x only lists /orgs/x.
// TestDeepC: does the REST list endpoint leak to a sibling-prefix attacker?
// Attacker org "x" wants victim "xy"'s secret names, exploiting that "x" is a
// string-prefix of "xy".
//
// The old escalation route — naming :org=xy in the URL — is gone, so the attacker
// can only list, and the list it gets is whatever its OWN org resolves to. The
// question therefore stops being "what STATUS does a cross-org list get" (it is a
// perfectly ordinary 200 for the attacker's own org) and becomes "what is IN it".
// That is the only assertion that can still catch a prefix leak, so the check moved
// from the status to the CONTENTS: org "x" must see exactly its own records, and
// never a name belonging to "xy".
func TestDeepC_RESTListNoPrefixEscalation(t *testing.T) {
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
// victim xy stores a secret.
@@ -238,17 +281,35 @@ func TestDeepC_RESTListNoPrefixEscalation(t *testing.T) {
if r := do(t, app, "POST", "/v1/kms/secrets", "xy", string(body), false, nil); r.StatusCode != 200 {
t.Fatalf("seed = %d", r.StatusCode)
}
// attacker "x" tries to list xy → 403 (exact org mismatch).
// attacker "x" lists — it can only ever list /orgs/x, and that is empty.
r := do(t, app, "GET", "/v1/kms/secrets", "x", "", false, nil)
if r.StatusCode != 403 {
t.Fatalf("BREACH: prefix-attacker x listed xy = %d, want 403", r.StatusCode)
if r.StatusCode != 200 {
t.Fatalf("prefix-attacker listing its OWN org = %d, want 200", r.StatusCode)
}
// attacker "x" lists its OWN org with a crafted ?path= trying to climb — validSubpath blocks "..".
listed := readAll(r.Body)
if strings.Contains(listed, "VICT") {
t.Fatalf("PREFIX BREACH: org x's list surfaced victim xy's secret name: %s", listed)
}
if !strings.Contains(listed, `"total":0`) {
t.Fatalf("PREFIX BREACH: org x's list is not empty: %s", listed)
}
// The victim still sees its own — the empty list above is isolation, not a
// broken endpoint.
if v := readAll(do(t, app, "GET", "/v1/kms/secrets", "xy", "", false, nil).Body); !strings.Contains(v, "VICT") {
t.Fatalf("victim xy cannot see its OWN secret: %s", v)
}
// attacker "x" lists its OWN org with a crafted ?path= trying to climb — ValidSubpath blocks "..".
r = do(t, app, "GET", "/v1/kms/secrets?path=../xy", "x", "", false, nil)
if r.StatusCode != 400 {
t.Fatalf("BREACH: ?path=../xy climb = %d, want 400", r.StatusCode)
}
t.Logf("REST list: prefix-attacker blocked (403 cross-org, 400 on ?path climb)")
// …and a climb-free subpath naming the victim lands strictly UNDER x
// (/orgs/x/xy), because orgPath prefixes the caller's org unconditionally.
r = do(t, app, "GET", "/v1/kms/secrets?path=xy", "x", "", false, nil)
if v := readAll(r.Body); r.StatusCode != 200 || strings.Contains(v, "VICT") {
t.Fatalf("PREFIX BREACH: ?path=xy reached the victim: %d %s", r.StatusCode, v)
}
t.Logf("REST list: prefix-attacker sees only its own namespace (400 on ?path climb, empty otherwise)")
}
// TestDeepD: legitimate same-org, same-name, DIFFERENT-path relocation. Because
@@ -274,12 +335,21 @@ func TestDeepD_IntraOrgPathBinding(t *testing.T) {
}
}
// ── VECTOR 4: enumeration oracle — 404 vs 403 vs 503 across orgs ───────────────
// ── VECTOR 4: enumeration oracle — is any answer different across orgs? ────────
//
// Does the response code distinguish "secret exists in another org" from "does
// not exist"? The guard 403s a cross-org caller BEFORE the store is touched, so
// existence should be indistinguishable. Probe: cross-org GET of an existing vs
// non-existing secret must return the SAME status (403), leaking nothing.
// Does the response distinguish "this secret NAME exists in another org" from "it
// exists nowhere"? If it does, an attacker enumerates every tenant's key names for
// free. The two probes must be INDISTINGUISHABLE.
//
// The uniform answer is now 404, not 403, and the change is a strengthening rather
// than a regression. 403 was only ever safe here because the guard refused before
// touching the store — it was a promise about ORDER OF OPERATIONS, and any future
// handler that read first would have re-opened the oracle. The reshape replaces
// that promise with a structural fact: the attacker's org is folded into the
// coordinate, so its probe addresses ITS OWN namespace and the store honestly
// answers "not here". There is nothing left for the response to be a function of
// except the attacker's own data. Bodies are compared too, not just codes — a
// differing error string is an oracle exactly as much as a differing status.
func TestVector4_NoCrossOrgExistenceOracle(t *testing.T) {
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
@@ -289,19 +359,26 @@ func TestVector4_NoCrossOrgExistenceOracle(t *testing.T) {
t.Fatalf("seed = %d", r.StatusCode)
}
// attacker org probes an EXISTING secret in victim's org.
// attacker org probes a name that EXISTS in the victim's org.
rExist := do(t, app, "GET", "/v1/kms/secrets/REAL", "attacker", "", false, nil)
// attacker org probes a NON-EXISTING secret in victim's org.
bExist := readAll(rExist.Body)
// attacker org probes a name that exists NOWHERE.
rMiss := do(t, app, "GET", "/v1/kms/secrets/NOPE", "attacker", "", false, nil)
bMiss := readAll(rMiss.Body)
if rExist.StatusCode != rMiss.StatusCode {
t.Fatalf("EXISTENCE ORACLE: existing→%d vs missing→%d differ (attacker learns victim's keys)",
rExist.StatusCode, rMiss.StatusCode)
if rExist.StatusCode != rMiss.StatusCode || bExist != bMiss {
t.Fatalf("EXISTENCE ORACLE: existing→(%d %s) vs missing→(%d %s) differ (attacker learns victim's key names)",
rExist.StatusCode, bExist, rMiss.StatusCode, bMiss)
}
if rExist.StatusCode != 403 {
t.Errorf("cross-org probe status=%d, want 403 (touch store only after authz)", rExist.StatusCode)
if rExist.StatusCode != 404 {
t.Errorf("cross-org probe status=%d, want 404 (the org is unspellable, so the probe resolves "+
"in the attacker's own namespace — existence elsewhere is not merely refused, it is unobservable)",
rExist.StatusCode)
}
t.Logf("no existence oracle: both cross-org probes = %d", rExist.StatusCode)
if strings.Contains(bExist, `"v"`) {
t.Fatalf("LEAK: cross-org probe echoed the victim's value: %s", bExist)
}
t.Logf("no existence oracle: both cross-org probes = %d %s", rExist.StatusCode, bExist)
}
// ── VECTOR 7: input validation at the boundary ─────────────────────────────────
+559
View File
@@ -0,0 +1,559 @@
package kms_test
// RED — ADVERSARIAL ISOLATION PROOF for the org-in-token reshape.
//
// The org left the URL and became a property of the validated principal
// (mount.go reqOrg → ctx.Org(), minted only by SanitizeIdentity from a signed
// claim). Every pre-reshape isolation test asserted a STATUS on a URL that named
// the victim; those URLs no longer exist, so those assertions now describe
// nothing. This file re-proves the isolation property itself, from scratch,
// against the shape that actually ships.
//
// WHY BODY, NOT STATUS. A status-only assertion cannot tell "acme was refused
// maxpower's secret" from "acme read its OWN secret at the same URL" — after the
// reshape both callers spell the identical path, so the stale tests' `want 403`
// was firing on a 200 that returned the CALLER'S OWN value. Isolation is a claim
// about PLAINTEXT, so every probe here asserts the body: no response to an
// unauthorized principal may contain a foreign org's secret, whatever its status.
// Two orgs are seeded with DISTINCT values at the IDENTICAL coordinate so a leak
// is unambiguous — the returned string names its owner.
//
// The pipeline is the real one (cloud.IdentityMiddleware → the real /v1/kms
// guard), so the org under test is derived from a signed claim exactly as in
// production. Header-injection harnesses (kms_test's do()) cannot probe the
// identity boundary itself and are not used here.
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
gojose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
model "github.com/hanzoai/iam/pkg/model"
"github.com/zap-proto/zip"
)
// isoValueB is acme's secret, sealed at the coordinate paasEnvPath addresses —
// the SAME coordinate paasValueA occupies in maxpower's store. Two distinct
// plaintexts at one coordinate is what makes a leak self-identifying: whichever
// string comes back names the org it belongs to.
const isoValueB = "s3kr3t-of-acme-ISO"
// isoClaims is the full claim surface the identity boundary reads: owner, the
// SIGNED membership set, isAdmin, and IAM's account `type`. redClaims (the V6
// file) pins Orgs to [owner] and carries no `type`, so it cannot express the
// three shapes this file attacks with — a NON-MEMBER org selection, a multi-org
// member, and a client_credentials MACHINE identity.
type isoClaims struct {
jwt.Claims
Owner string `json:"owner"`
IsAdmin bool `json:"isAdmin"`
Type string `json:"type"`
Orgs []model.OrgRef `json:"orgs"`
}
// isoTok is one attacker-chosen token shape. Every field is a lever the attacker
// controls in the threat model: it may ask IAM for any app (aud), may hold any
// membership (orgs), and — for the residual cases — the test grants it claims IAM
// would not mint, to prove cloud denies on its own rather than on IAM's restraint.
type isoTok struct {
owner string // the `owner` claim (the APP's org)
orgs []string // signed membership set, home first; nil ⇒ [owner]
aud []string // audience set; nil ⇒ none
isAdmin bool
typ string // IAM account kind; "application" ⇒ machine principal
expired bool
}
// mint signs tok against the same kid=test-key JWKS the e2e harness serves.
func (tk isoTok) mint(t *testing.T, key *rsa.PrivateKey) string {
t.Helper()
signer, err := gojose.NewSigner(
gojose.SigningKey{Algorithm: gojose.RS256, Key: key},
(&gojose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test-key"),
)
if err != nil {
t.Fatalf("signer: %v", err)
}
orgs := tk.orgs
if orgs == nil {
orgs = []string{tk.owner}
}
refs := make([]model.OrgRef, 0, len(orgs))
for _, o := range orgs {
refs = append(refs, model.OrgRef{Org: o})
}
exp := time.Now().Add(time.Hour)
if tk.expired {
exp = time.Now().Add(-time.Hour)
}
raw, err := jwt.Signed(signer).Claims(isoClaims{
Claims: jwt.Claims{
Issuer: e2eIssuer,
Subject: tk.owner + "/principal", // non-empty ⇒ X-User-Id set ⇒ principal.Validated
Audience: jwt.Audience(tk.aud),
Expiry: jwt.NewNumericDate(exp),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Owner: tk.owner,
IsAdmin: tk.isAdmin,
Type: tk.typ,
Orgs: refs,
}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return raw
}
// isoProbe is one attack's FULL observable — status AND body. Isolation is a
// statement about the body, so both are captured together and asserted together;
// a status-only observable is precisely what let a caller reading its OWN secret
// be scored as a cross-org 200.
type isoProbe struct {
what string
status int
body string
}
// isoGet issues one probe against the real pipeline with attacker-chosen headers.
func isoGet(t *testing.T, app *zip.App, what, path, token string, hdr map[string]string) isoProbe {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s: GET %s: %v", what, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
p := isoProbe{what: what, status: resp.StatusCode, body: strings.TrimSpace(string(b))}
t.Logf("PROBE %-58s → %d %s", what, p.status, p.body)
return p
}
// noLeak is the ONE assertion this file's isolation claim rests on: the response
// carries none of the named foreign plaintexts. A status is not a proof — a 403
// with the secret in the body is still a breach, and a 200 that returns the
// caller's OWN secret is not one.
func (p isoProbe) noLeak(t *testing.T, foreign ...string) isoProbe {
t.Helper()
for _, f := range foreign {
if strings.Contains(p.body, f) {
t.Fatalf("LEAK [%s]: status=%d body contains foreign plaintext %q: %s", p.what, p.status, f, p.body)
}
}
return p
}
// isValue asserts the response IS a 200 carrying exactly want in the `value`
// field — the positive half, so a test that proves "no leak" by breaking the
// endpoint outright cannot pass silently.
func (p isoProbe) isValue(t *testing.T, want string) isoProbe {
t.Helper()
if p.status != 200 {
t.Fatalf("[%s]: status=%d, want 200 carrying %q: %s", p.what, p.status, want, p.body)
}
var m map[string]any
if err := json.Unmarshal([]byte(p.body), &m); err != nil {
t.Fatalf("[%s]: body is not JSON: %v: %s", p.what, err, p.body)
}
if got, _ := m["value"].(string); got != want {
t.Fatalf("[%s]: value=%q, want %q", p.what, got, want)
}
return p
}
// isStatus asserts the refusal code, so a reconciled expectation stays pinned and
// a future change from 404 back to 200 fails loudly rather than silently.
func (p isoProbe) isStatus(t *testing.T, want int) isoProbe {
t.Helper()
if p.status != want {
t.Fatalf("[%s]: status=%d, want %d: %s", p.what, p.status, want, p.body)
}
return p
}
// isoWorld stands up the real pipeline with maxpower and acme seeded at the SAME
// coordinate with DISTINCT values, plus the admin org's own secret. Returned
// alongside the one URL every caller — victim, attacker, admin — must spell,
// because the org is no longer nameable in a URL.
func isoWorld(t *testing.T) (*zip.App, *rsa.PrivateKey, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL)) // AdminOrg="admin"
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // maxpower
sealPlatformSecret(t, deps.KMS, paasOrgB, isoValueB) // acme, SAME coordinate
sealPlatformSecret(t, deps.KMS, "admin", "s3kr3t-of-admin-ISO")
return app, key, "/v1/kms" + paasEnvPath
}
// ── (a) a valid token for org B asking for the NAME org A used ─────────────────
//
// The post-reshape cross-org attempt: acme cannot SPELL maxpower's secret, so the
// strongest thing it can do is present its own valid credential at the exact URL
// maxpower uses. The org folded into the store path comes from acme's claim, so
// the request resolves to acme's OWN record — same URL, different tenant, and the
// body proves which one answered.
func TestRedIso_A_SameNameOtherOrg(t *testing.T) {
app, key, path := isoWorld(t)
isoGet(t, app, "(a) acme token, maxpower's URL", path, isoTok{owner: paasOrgB}.mint(t, key), nil).
noLeak(t, paasValueA).
isValue(t, isoValueB)
// The victim itself still reads its own — the boundary admits, it does not
// blanket-deny (a test that broke the endpoint would fail here).
isoGet(t, app, "(a) maxpower token, own URL", path, isoTok{owner: paasOrgA}.mint(t, key), nil).
isValue(t, paasValueA)
// A third org with NO secret at that coordinate gets not-found — the same
// answer a cross-org attempt gets, which is the point: absence and
// unauthorized-elsewhere are indistinguishable.
isoGet(t, app, "(a) third org, no such secret", path, isoTok{owner: "nobody"}.mint(t, key), nil).
noLeak(t, paasValueA, isoValueB).
isStatus(t, 404)
}
// ── (b) forged X-Org-Id with no matching validated principal ───────────────────
//
// X-Org-Id is a client header. SanitizeIdentity strips it on ingress and restores
// it un-validated on the bearer-less path, so an off-gateway caller can still
// present `X-Org-Id: maxpower`. Two defenses must hold: the guard refuses a
// request with no validated principal at all, and a VALIDATED principal's
// selection is honored only inside its signed membership set.
func TestRedIso_B_ForgedOrgHeader(t *testing.T) {
app, key, path := isoWorld(t)
// No credential, forged org — the anonymous-forge signature.
isoGet(t, app, "(b) forged X-Org-Id, NO bearer", path, "", map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isStatus(t, 403)
// Forged org + forged AUTHORITY headers. These are authorityHeaders: stripped
// on ingress and re-minted only from validated claims, so asserting them
// client-side grants nothing.
isoGet(t, app, "(b) forged org + forged X-User-Id + IsAdmin", path, "", map[string]string{
"X-Org-Id": paasOrgA, "X-User-Id": "u-attacker", "X-User-IsAdmin": "true", "X-User-IsOrgAdmin": "true",
}).noLeak(t, paasValueA).isStatus(t, 403)
// A VALIDATED acme principal selecting maxpower. isMember(claims.Orgs, "maxpower")
// is false, so the selection is DISCARDED (not honored, not refused) and the
// request continues in acme's own org — the body is acme's secret.
isoGet(t, app, "(b) acme token + X-Org-Id:maxpower (non-member)", path,
isoTok{owner: paasOrgB}.mint(t, key), map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isValue(t, isoValueB)
// An expired credential is anonymous, so the forged org has no principal to
// ride on — the same refusal as no credential at all.
isoGet(t, app, "(b) EXPIRED acme token + X-Org-Id:maxpower", path,
isoTok{owner: paasOrgB, expired: true}.mint(t, key), map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA, isoValueB).
isStatus(t, 403)
// A garbage bearer never validates, so it never mints a principal.
isoGet(t, app, "(b) junk bearer + X-Org-Id:maxpower", path, "hk-not-a-jwt",
map[string]string{"X-Org-Id": paasOrgA}).noLeak(t, paasValueA).isStatus(t, 403)
}
// ── (c) admin / superadmin attempting cross-org traversal ──────────────────────
//
// TWO DISTINCT MECHANISMS, one removed and one retained — they must not be
// conflated, and this test pins both.
//
// URL TRAVERSAL — REMOVED. No route accepts an org, so no spelling of one
// reaches another tenant. Admin or not, the router answers 404/400.
//
// CLAIM-BOUND ORG-SWITCH — RETAINED. SanitizeIdentity's SuperAdmin arm honors
// X-Org-Id as the effective org (middleware_identity.go: `effOrg = cliOrg`),
// gated on home-org membership of the reserved admin org AND !isMachinePrincipal.
// That is platform sudo, and it is exactly the "explicit impersonation via a
// token claim, never URL traversal" shape — see the decision note on
// TestRESTRoundtripOrgScoped. This test proves the gate is the CLAIM: every
// principal that is not a human SuperAdmin is refused the switch.
func TestRedIso_C_AdminCrossOrg(t *testing.T) {
app, key, path := isoWorld(t)
superAdmin := isoTok{owner: "admin", isAdmin: true}.mint(t, key)
// URL traversal, admin credential — every spelling of the victim's org. The
// dotted forms are refused by ValidSubpath; the DOTLESS ones (an absolute
// "/orgs/maxpower/…" injected into the wildcard, raw or percent-encoded) carry
// nothing for ValidSubpath to reject and are the sharper attack. They fail
// structurally instead: orgPath ALWAYS prefixes "/orgs/{caller-org}", so the
// injected text can only ever extend the caller's OWN subtree
// ("/orgs/admin/orgs/maxpower/…"), never replace it.
for _, p := range []string{
"/v1/kms/orgs/" + paasOrgA + paasEnvPath,
"/v1/kms/admin/orgs/" + paasOrgA + paasEnvPath,
"/v1/kms/secrets/../orgs/" + paasOrgA + "/platform/api/DB_PASSWORD?env=default",
"/v1/kms/secrets/%2e%2e%2forgs%2f" + paasOrgA + "%2fplatform%2fapi%2fDB_PASSWORD?env=default",
"/v1/kms/secrets/..%2f..%2forgs%2f" + paasOrgA + "%2fplatform%2fapi%2fDB_PASSWORD?env=default",
"/v1/kms/secrets//orgs/" + paasOrgA + "/platform/api/DB_PASSWORD?env=default",
"/v1/kms/secrets/%2forgs%2f" + paasOrgA + "%2fplatform%2fapi%2fDB_PASSWORD?env=default",
"/v1/kms/secrets/orgs/" + paasOrgA + "/platform/api/DB_PASSWORD?env=default",
} {
isoGet(t, app, "(c) SuperAdmin URL traversal "+p, p, superAdmin, nil).noLeak(t, paasValueA)
}
// SuperAdmin with NO switch header reads its OWN org, like anyone else.
isoGet(t, app, "(c) SuperAdmin, no switch header", path, superAdmin, nil).
noLeak(t, paasValueA).
isValue(t, "s3kr3t-of-admin-ISO")
// A MACHINE principal in the admin org, even asserting isAdmin=true, is denied
// the SuperAdmin arm (isMachinePrincipal) and so cannot switch — it stays
// pinned to its own org. Both machine discriminators are exercised: IAM's
// `type` claim, and the owner-bound <owner>-platform-kms audience.
for _, tk := range []isoTok{
{owner: "admin", isAdmin: true, typ: "application"},
{owner: "admin", isAdmin: true, aud: []string{"admin-platform-kms"}},
{owner: "admin", isAdmin: true, aud: []string{"hanzo-console", "admin-platform-kms"}},
{owner: "admin", isAdmin: true, aud: []string{"admin-platform-kms", "hanzo-console"}},
} {
isoGet(t, app, "(c) admin-org MACHINE + X-Org-Id:maxpower", path, tk.mint(t, key),
map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isValue(t, "s3kr3t-of-admin-ISO")
}
// An ORG admin (isAdmin=true) of a NON-admin org is not platform sudo: the
// switch is not offered to it, and its non-member selection is discarded.
isoGet(t, app, "(c) acme ORG-admin + X-Org-Id:maxpower", path,
isoTok{owner: paasOrgB, isAdmin: true}.mint(t, key), map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isValue(t, isoValueB)
// A principal whose `owner` claim says "admin" but whose SIGNED membership set
// says otherwise is NOT a SuperAdmin: the gate reads the membership set
// (claims.homeOrg), never the app-selected `owner`.
isoGet(t, app, "(c) owner=admin but orgs=[acme] + X-Org-Id:maxpower", path,
isoTok{owner: "admin", orgs: []string{paasOrgB}, isAdmin: true}.mint(t, key),
map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isValue(t, isoValueB)
// THE RETAINED CAPABILITY, pinned. A HUMAN member of the reserved admin org
// switches into maxpower and reads it. This is platform sudo by design
// (principal.go: "a SuperAdmin acting in another org"), it is claim-bound, and
// it is the answer to "where did admin cross-org read go?" — it did not
// disappear with the URL, it moved onto the identity. Pinned so that if the
// product decision is to REMOVE it, this test fails and forces the choice to
// be made explicitly rather than drifting.
isoGet(t, app, "(c) HUMAN SuperAdmin + X-Org-Id:maxpower [RETAINED CAPABILITY]", path,
superAdmin, map[string]string{"X-Org-Id": paasOrgA}).
isValue(t, paasValueA)
}
// ── (d) mismatched / absent aud ────────────────────────────────────────────────
//
// A DIFFERENT AXIS from org scoping. Audience is deliberately NOT an access gate
// (auth_identity.go validate: trust is signature + issuer + expiry; `aud` merely
// names which of IAM's own apps minted the token). The security property that
// therefore MUST hold is that aud never WIDENS reach: whatever an attacker puts
// in the audience set — the victim's machine aud, a console aud, nothing at all —
// the reachable org stays the one the signed claims name.
func TestRedIso_D_AudNeverWidensReach(t *testing.T) {
app, key, path := isoWorld(t)
for _, tc := range []struct {
what string
aud []string
}{
{"victim's machine aud", []string{paasOrgA + "-platform-kms"}},
{"multi-value w/ victim machine aud", []string{"hanzo-console", paasOrgA + "-platform-kms"}},
{"victim machine aud FIRST", []string{paasOrgA + "-platform-kms", "hanzo-console"}},
{"absent aud", nil},
{"empty-string aud", []string{""}},
{"never-registered aud", []string{"a-brand-new-app-never-registered"}},
{"admin-org machine aud", []string{"admin-platform-kms"}},
} {
// acme presents it: reach stays acme's, never maxpower's.
isoGet(t, app, "(d) acme aud="+tc.what, path,
isoTok{owner: paasOrgB, aud: tc.aud}.mint(t, key), nil).
noLeak(t, paasValueA).
isValue(t, isoValueB)
// …and with a forged org selection stacked on top, still acme's.
isoGet(t, app, "(d) acme aud="+tc.what+" + X-Org-Id:maxpower", path,
isoTok{owner: paasOrgB, aud: tc.aud}.mint(t, key), map[string]string{"X-Org-Id": paasOrgA}).
noLeak(t, paasValueA).
isValue(t, isoValueB)
}
// The converse, so this is a proof about reach and not about the endpoint
// being broken: maxpower carrying ACME's machine aud still reads maxpower.
isoGet(t, app, "(d) maxpower carrying acme's machine aud", path,
isoTok{owner: paasOrgA, aud: []string{paasOrgB + "-platform-kms"}}.mint(t, key), nil).
isValue(t, paasValueA)
}
// ── (e) case-fold and unsafe-rune org folding ──────────────────────────────────
//
// The org is folded into a store PATH and, one layer down, into a per-org FILE via
// cloud.SanitizeOrg. Two distinct IAM owners that fold onto one namespace would be
// a cross-tenant break with no forged header required, so the fold must be
// injective end to end.
func TestRedIso_E_OrgFoldIsInjective(t *testing.T) {
app, key, path := isoWorld(t)
// Case-distinct owners are DISTINCT tenants: "Maxpower" cannot reach
// "maxpower". (SanitizeOrg is the identity only on clean lowercase labels;
// anything else gets a SHA-256-derived suffix, so the files never alias.)
for _, o := range []string{"Maxpower", "MAXPOWER", "maxPower"} {
isoGet(t, app, "(e) case-variant owner "+o, path, isoTok{owner: o}.mint(t, key), nil).
noLeak(t, paasValueA).
isStatus(t, 404)
}
// Trim-collapsible / zero-width owners must not fold onto the victim.
// OrgHasUnsafeRune zeroes such an owner at the boundary, so the request
// resolves org-less and the guard refuses it — 400 (the org fails the
// DNS-1123 edge check) or 403 (no org at all), never the victim's value.
for _, o := range []string{"maxpower ", " maxpower", "maxpower\t", "maxpower", "maxpower "} {
p := isoGet(t, app, "(e) unsafe-rune owner "+strings.ReplaceAll(o, "", "<zwsp>"), path,
isoTok{owner: o}.mint(t, key), nil).noLeak(t, paasValueA)
if p.status != 400 && p.status != 403 {
t.Fatalf("[%s]: status=%d, want 400 or 403 (org-less fail-closed)", p.what, p.status)
}
}
// The reserved platform partition ("_platform" routes to PlatformDB, where
// cloud's own org-less facade secrets live) must not be reachable as a tenant
// org. It shares a FILE, but the row's `path` column is the boundary and it is
// unspellable: an org-less facade ref keys at "/", a tenant keys at
// "/orgs/_platform", so no query crosses.
isoGet(t, app, "(e) org=_platform reaching the reserved partition", path,
isoTok{owner: "_platform"}.mint(t, key), nil).
noLeak(t, paasValueA, isoValueB, "s3kr3t-of-admin-ISO").
isStatus(t, 404)
}
// ── (f) the write + list + delete faces, same boundary ─────────────────────────
//
// GET is not the whole surface. A write that lands in another org's namespace, a
// list that enumerates it, or a delete that destroys it are all cross-org breaks;
// each folds the org through the same orgPath, and each is probed here.
func TestRedIso_F_WriteListDeleteAreScopedToo(t *testing.T) {
app, key, path := isoWorld(t)
acme := isoTok{owner: paasOrgB}.mint(t, key)
post := func(what, body string, hdr map[string]string) isoProbe {
t.Helper()
req := httptest.NewRequest("POST", "/v1/kms/secrets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+acme)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s: %v", what, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
p := isoProbe{what: what, status: resp.StatusCode, body: strings.TrimSpace(string(b))}
t.Logf("PROBE %-58s → %d %s", what, p.status, p.body)
return p
}
// A subpath that tries to climb out of acme's namespace into maxpower's.
for _, sub := range []string{
"../" + paasOrgA + "/platform/api",
"../../orgs/" + paasOrgA + "/platform/api",
"./../" + paasOrgA,
} {
body, _ := json.Marshal(map[string]string{
"name": "DB_PASSWORD", "value": "OVERWRITTEN-BY-ACME", "env": "default", "path": sub,
})
post("(f) acme POST path="+sub, string(body), nil).isStatus(t, 400)
}
// A subpath naming the victim WITHOUT a climb lands strictly under acme —
// "/orgs/acme/orgs/maxpower/..." — so it can never overwrite maxpower's record.
body, _ := json.Marshal(map[string]string{
"name": "DB_PASSWORD", "value": "OVERWRITTEN-BY-ACME", "env": "default",
"path": "orgs/" + paasOrgA + "/platform/api",
})
post("(f) acme POST path=orgs/maxpower/... (no climb)", string(body), nil).isStatus(t, 200)
// The victim's record is untouched — the write went to acme's own subtree.
isoGet(t, app, "(f) maxpower reads its own after acme's write", path,
isoTok{owner: paasOrgA}.mint(t, key), nil).isValue(t, paasValueA)
// LIST: acme enumerating maxpower's path is impossible to spell; its own list
// at the victim's coordinate is empty, and a climbing ?path= is refused.
isoGet(t, app, "(f) acme LIST at maxpower's coordinate", "/v1/kms/secrets?path=platform/api&env=default",
acme, nil).noLeak(t, paasValueA).isStatus(t, 200)
isoGet(t, app, "(f) acme LIST ?path=../maxpower climb", "/v1/kms/secrets?path=../"+paasOrgA+"&env=default",
acme, nil).noLeak(t, paasValueA).isStatus(t, 400)
// DELETE is destructive, so its scoping is proven in three steps rather than by
// a single status. acme DELETEs the shared coordinate: it succeeds, because
// acme HAS a record there — that 200 is acme destroying its OWN secret, not
// maxpower's. The next two steps are what make it a proof: maxpower's record
// survives, and acme's SECOND delete is 404 because acme's reach is now empty
// while maxpower's record sits at the same URL, untouched and invisible.
del := func(what, p string) isoProbe {
t.Helper()
req := httptest.NewRequest("DELETE", p, nil)
req.Header.Set("Authorization", "Bearer "+acme)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s: %v", what, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
pr := isoProbe{what: what, status: resp.StatusCode, body: strings.TrimSpace(string(b))}
t.Logf("PROBE %-58s → %d %s", what, pr.status, pr.body)
return pr
}
del("(f) acme DELETE shared coordinate (hits acme's OWN)", "/v1/kms"+paasEnvPath).isStatus(t, 200)
isoGet(t, app, "(f) maxpower reads its own after acme's DELETE", path,
isoTok{owner: paasOrgA}.mint(t, key), nil).isValue(t, paasValueA)
del("(f) acme DELETE again — own gone, maxpower's unreachable", "/v1/kms"+paasEnvPath).isStatus(t, 404)
isoGet(t, app, "(f) maxpower STILL reads its own after acme's 2nd DELETE", path,
isoTok{owner: paasOrgA}.mint(t, key), nil).isValue(t, paasValueA)
}
// ── (g) no cross-org existence oracle ──────────────────────────────────────────
//
// The reshape's isolation signal is NOT-FOUND, which is strictly stronger than the
// FORBIDDEN it replaced — but only if it is uniform. If "exists in another org"
// answered differently from "does not exist anywhere", the 404 would itself be the
// oracle it is supposed to close. Probe both and require them identical, in status
// AND in body.
func TestRedIso_G_NoExistenceOracle(t *testing.T) {
app, key, path := isoWorld(t)
attacker := isoTok{owner: "attacker"}.mint(t, key)
exists := isoGet(t, app, "(g) probe a name that EXISTS in maxpower", path, attacker, nil).
noLeak(t, paasValueA)
missing := isoGet(t, app, "(g) probe a name that exists NOWHERE",
"/v1/kms/secrets/platform/api/NO_SUCH_SECRET?env=default", attacker, nil)
if exists.status != missing.status || exists.body != missing.body {
t.Fatalf("EXISTENCE ORACLE: existing→(%d %s) vs missing→(%d %s) differ — the attacker learns "+
"which secret NAMES other tenants use", exists.status, exists.body, missing.status, missing.body)
}
if exists.status != http.StatusNotFound {
t.Fatalf("cross-org probe status=%d, want 404 (org unspellable ⇒ not-found, existence hidden)", exists.status)
}
t.Logf("no existence oracle: both cross-org probes = %d %s", exists.status, exists.body)
}
+106 -78
View File
@@ -7,95 +7,128 @@ package kms_test
// AND the owner's machine aud could "slip" back to SuperAdmin (because the static
// member is what let it validate), the fix would have a hole.
//
// The end-to-end oracle is crisp because svc.guard() (mount.go) grants a
// SuperAdmin cross-org reads: `if !ctx.IsAdmin() && ctx.Org() != org → 403`.
// - a REAL SuperAdmin reading the victim's path → 200 (cross-org allowed)
// - a machine principal DENIED admin (org-pinned) → 403 (cross-org denied)
// So a 200 on victimPath == "the token got SuperAdmin"; 403 == "admin denied".
// The whole test reduces the slip question to a single observable status code.
// THE ORACLE CHANGED WITH THE ORG RESHAPE; THE QUESTION DID NOT. This file used to
// read "did the token get SuperAdmin?" off a cross-org URL — a real admin reading
// /v1/kms/orgs/{victim}/… got 200, a pinned machine principal got 403. No route
// names an org any more, so that oracle returns nothing about admin at all: every
// caller now spells one path and is served ITS OWN org, which is why the old
// assertions were firing 403-expected on a 200 that carried the ADMIN'S OWN secret.
//
// Harness (e2eCfg): AdminOrg="admin"; audience is not gated (trust = signature+issuer+expiry).
// Reuses mintRed / getWithBearer / getBearerHdr / sealPlatformSecret from the e2e +
// red_v6_adversarial files (same kms_test package).
// What SuperAdmin actually confers today is the CLAIM-BOUND ORG-SWITCH:
// SanitizeIdentity's admin arm — and only that arm — honors X-Org-Id as the
// effective org (middleware_identity.go `effOrg = cliOrg`), gated on membership of
// the reserved admin org AND !isMachinePrincipal. So the probe is the same request
// with `X-Org-Id: maxpower` attached, and the observable is the VALUE returned:
//
// victim's plaintext ⟹ the token wields platform sudo
// admin's own plaintext ⟹ admin was denied; the switch was inert
//
// That is strictly sharper than the status it replaces — a 403 could have come from
// any refusal, while a returned secret names exactly which tenant was selected.
//
// Harness (e2eCfg): AdminOrg="admin"; audience is not gated (trust = signature +
// issuer + expiry). Reuses mintRed / getWithBearer / getBearerHdr /
// sealPlatformSecret from the e2e + red_v6_adversarial files (same kms_test package).
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"testing"
"time"
"github.com/zap-proto/zip"
)
// adminSlipWorld seals the victim's and the admin org's secrets at the IDENTICAL
// coordinate — the one path every caller spells — and returns it alongside the
// org-switch header the SuperAdmin arm consumes. Distinct plaintexts at one
// coordinate are what make the oracle unambiguous.
const (
slipVictimValue = paasValueA // maxpower's
slipAdminValue = "s3kr3t-of-admin" // the admin org's own
slipStaticAud = "hanzo-console" // a static-allowlist member: makes the token validate
slipOwnMachAud = "admin-platform-kms" // the OWNER's machine aud: must strip admin
)
func adminSlipWorld(t *testing.T) (app *zip.App, key *rsa.PrivateKey, path string, sw map[string]string) {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := e2eJWKS(t, &k.PublicKey)
a, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL)) // AdminOrg="admin", allowlist=["hanzo-console"]
sealPlatformSecret(t, deps.KMS, paasOrgA, slipVictimValue)
sealPlatformSecret(t, deps.KMS, "admin", slipAdminValue)
return a, k, "/v1/kms" + paasEnvPath, map[string]string{"X-Org-Id": paasOrgA}
}
// slipValue reads the plaintext a probe was served — the whole observable.
func slipValue(t *testing.T, resp *http.Response) any {
t.Helper()
return decode(t, resp.Body)["value"]
}
// TestRed_MultiValueAud_AdminSlip is the focus-fire: owner==adminOrg, isAdmin=true,
// aud = [ <static allowlist member>, <owner machine aud> ]. The token VALIDATES via
// the static member (AnyAudience OR), so the fix cannot rely on validation rejecting
// it — it must rely on isKMSMachinePrincipal firing on the machine aud's PRESENCE and
// stripping admin. Proven by contrast with a real admin (no machine aud) that KEEPS
// its cross-org read.
// its cross-org switch.
func TestRed_MultiValueAud_AdminSlip(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL)) // AdminOrg="admin", allowlist=["hanzo-console"]
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // victim maxpower secret
sealPlatformSecret(t, deps.KMS, "admin", "s3kr3t-of-admin") // admin org's own secret
victimPath := "/v1/kms" + paasEnvPath
adminPath := "/v1/kms/orgs/admin" + paasEnvPath
app, key, path, switchToVictim := adminSlipWorld(t)
future := time.Now().Add(time.Hour)
// ── BASELINE: a REAL SuperAdmin — isAdmin=true, aud=[hanzo-console] ONLY (no
// machine aud). isKMSMachinePrincipal("admin")=false → SuperAdmin GRANTED →
// the guard admits the cross-org read → 200. This pins the oracle: 200 here
// means "SuperAdmin reaches a foreign org", so a 200 on the attack tokens
// below would be the SLIP, and it also proves the fix does NOT over-block a
// legitimate admin that merely carries other audiences.
realAdmin := mintRed(t, key, "admin", []string{"hanzo-console"}, true, future)
if resp := getWithBearer(t, app, victimPath, realAdmin); resp.StatusCode != 200 {
t.Fatalf("REAL admin (aud=[hanzo-console], isAdmin=true) → victim = %d, want 200 "+
"(SuperAdmin must still cross-org read; a 403 here would be an over-block regression)", resp.StatusCode)
// the org-switch is honored → the VICTIM'S value comes back. This pins the
// oracle: the victim's plaintext here means "SuperAdmin reaches a foreign
// org", so the same plaintext on the attack tokens below would be the SLIP,
// and it also proves the fix does NOT over-block a legitimate admin that
// merely carries other audiences.
realAdmin := mintRed(t, key, "admin", []string{slipStaticAud}, true, future)
if got := slipValue(t, getBearerHdr(t, app, path, realAdmin, switchToVictim)); got != slipVictimValue {
t.Fatalf("REAL admin (aud=[%s], isAdmin=true) + org-switch read %v, want %q "+
"(SuperAdmin must still reach a foreign org; anything else is an over-block regression)",
slipStaticAud, got, slipVictimValue)
}
// ── ATTACK (the focus-fire): owner=admin, isAdmin=true, aud = [ hanzo-console
// (STATIC allowlist), admin-platform-kms (the OWNER machine aud) ]. AnyAudience
// OR-matches "hanzo-console" so the token VALIDATES (the machine widening was not
// even needed). The presence of the owner machine aud MUST still trip
// isKMSMachinePrincipal → deny SuperAdmin → org-pinned to "admin" → the guard
// denies the cross-org read → 403. A 200 would mean the static co-member let it
// slip back to admin: the fix would be BYPASSED.
slip := mintRed(t, key, "admin", []string{"hanzo-console", "admin-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, slip); resp.StatusCode != 403 {
t.Fatalf("ADMIN-SLIP: aud=[hanzo-console, admin-platform-kms] isAdmin=true owner=admin → victim = %d, "+
"want 403 (machine-aud presence must deny SuperAdmin even with a static-allowlist co-member)", resp.StatusCode)
// isKMSMachinePrincipal → deny SuperAdmin → org-pinned to "admin" → the switch is
// inert and the ADMIN'S OWN value comes back. The victim's value would mean the
// static co-member let it slip back to admin: the fix would be BYPASSED.
slip := mintRed(t, key, "admin", []string{slipStaticAud, slipOwnMachAud}, true, future)
if got := slipValue(t, getBearerHdr(t, app, path, slip, switchToVictim)); got != slipAdminValue {
t.Fatalf("ADMIN-SLIP: aud=[%s, %s] isAdmin=true owner=admin + org-switch read %v, want %q "+
"(machine-aud presence must deny SuperAdmin even with a static-allowlist co-member)",
slipStaticAud, slipOwnMachAud, got, slipAdminValue)
}
// Prove the slip token DID validate (so the 403 is the admin-deny + org-pin, NOT a
// validation reject): the SAME token reads its OWN org (admin) → 200. This is the
// data-plane-intact half — the fix gates ONLY the admin grant.
if resp := getWithBearer(t, app, adminPath, slip); resp.StatusCode != 200 {
t.Fatalf("multi-value machine token → admin own secret = %d, want 200 "+
"(token must have validated; org-scoped data access must remain intact)", resp.StatusCode)
// Prove the slip token DID validate (so the denial above is the admin-deny + org-pin,
// NOT a validation reject): the SAME token, with no switch header, reads its OWN org.
// This is the data-plane-intact half — the fix gates ONLY the admin grant.
if got := slipValue(t, getWithBearer(t, app, path, slip)); got != slipAdminValue {
t.Fatalf("multi-value machine token → own org read %v, want %q "+
"(token must have validated; org-scoped data access must remain intact)", got, slipAdminValue)
}
// Order-independence: reverse the aud so the machine aud is FIRST. isKMSMachinePrincipal
// scans the whole set, so the deny must not depend on aud ordering.
slipRev := mintRed(t, key, "admin", []string{"admin-platform-kms", "hanzo-console"}, true, future)
if resp := getWithBearer(t, app, victimPath, slipRev); resp.StatusCode != 403 {
t.Fatalf("ADMIN-SLIP (reversed aud order [admin-platform-kms, hanzo-console]) → victim = %d, want 403", resp.StatusCode)
// Order-independence: reverse the aud so the machine aud is FIRST.
// isKMSMachinePrincipal scans the whole set, so the deny must not depend on ordering.
slipRev := mintRed(t, key, "admin", []string{slipOwnMachAud, slipStaticAud}, true, future)
if got := slipValue(t, getBearerHdr(t, app, path, slipRev, switchToVictim)); got != slipAdminValue {
t.Fatalf("ADMIN-SLIP (reversed aud order [%s, %s]) read %v, want %q",
slipOwnMachAud, slipStaticAud, got, slipAdminValue)
}
// The admin org-SWITCH header must also be inert for the machine principal (the
// switch is honored ONLY inside the admin-grant case, which the machine principal
// no longer enters). Explicit X-Org-Id:maxpower must NOT redirect it to the victim.
if resp := getBearerHdr(t, app, victimPath, slip, map[string]string{"X-Org-Id": paasOrgA}); resp.StatusCode != 403 {
t.Fatalf("ADMIN-SLIP + X-Org-Id:maxpower org-switch → victim = %d, want 403 (machine principal cannot org-switch)", resp.StatusCode)
}
// Even switching to admin's own org via header changes nothing about cross-org:
// the machine principal reading the victim path is still 403 regardless of header.
if resp := getBearerHdr(t, app, adminPath, slip, map[string]string{"X-Org-Id": paasOrgA}); resp.StatusCode != 200 {
t.Fatalf("machine principal + X-Org-Id:maxpower reading its OWN adminPath = %d, want 200 "+
"(header switch is inert; own-org data access unaffected)", resp.StatusCode)
// The victim's record is intact and still reachable BY THE VICTIM — so the denials
// above are the admin gate, not a broken endpoint.
victimTok := mintRed(t, key, paasOrgA, []string{slipStaticAud}, false, future)
if got := slipValue(t, getWithBearer(t, app, path, victimTok)); got != slipVictimValue {
t.Fatalf("victim reading its OWN secret got %v, want %q", got, slipVictimValue)
}
}
@@ -108,31 +141,26 @@ func TestRed_MultiValueAud_AdminSlip(t *testing.T) {
// foreign machine aud in the set never strips a bona-fide admin. Denying it would be
// an over-block that breaks multi-aud admin tokens.
func TestRed_ForeignMachineAudInSet_RealAdminKept(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL))
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA)
victimPath := "/v1/kms" + paasEnvPath
app, key, path, switchToVictim := adminSlipWorld(t)
future := time.Now().Add(time.Hour)
// owner=admin, isAdmin=true, aud=[hanzo-console (static), maxpower-platform-kms (FOREIGN machine aud)].
// Not the owner's machine aud → isKMSMachinePrincipal(admin)=false → real SuperAdmin → 200.
fa := mintRed(t, key, "admin", []string{"hanzo-console", paasOrgA + "-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, fa); resp.StatusCode != 200 {
t.Fatalf("real admin carrying a FOREIGN machine aud → victim = %d, want 200 "+
"(a foreign machine aud must NOT strip admin; that would be an over-block)", resp.StatusCode)
// owner=admin, isAdmin=true, aud=[hanzo-console (static), maxpower-platform-kms (FOREIGN
// machine aud)]. Not the owner's machine aud → isKMSMachinePrincipal(admin)=false →
// real SuperAdmin → the switch is honored → the victim's value.
fa := mintRed(t, key, "admin", []string{slipStaticAud, paasOrgA + "-platform-kms"}, true, future)
if got := slipValue(t, getBearerHdr(t, app, path, fa, switchToVictim)); got != slipVictimValue {
t.Fatalf("real admin carrying a FOREIGN machine aud + org-switch read %v, want %q "+
"(a foreign machine aud must NOT strip admin; that would be an over-block)", got, slipVictimValue)
}
// Contrast — the DISCRIMINATOR is the owner's OWN machine aud, not any machine aud:
// swap the foreign maxpower-platform-kms for admin's OWN admin-platform-kms and the
// SAME shape becomes a machine principal → isKMSMachinePrincipal fires → admin stripped
// → victim read 403. So a FOREIGN machine aud keeps admin (fa above); the OWN machine
// aud strips it — the gate is owner-bound.
ownMach := mintRed(t, key, "admin", []string{"hanzo-console", "admin-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, ownMach); resp.StatusCode != 403 {
t.Fatalf("owner=admin carrying its OWN machine aud → victim = %d, want 403 (own machine aud strips admin)", resp.StatusCode)
// SAME shape becomes a machine principal → isKMSMachinePrincipal fires → admin
// stripped → the switch is inert and the ADMIN'S own value comes back. So a FOREIGN
// machine aud keeps admin (fa above); the OWN machine aud strips it — owner-bound.
ownMach := mintRed(t, key, "admin", []string{slipStaticAud, slipOwnMachAud}, true, future)
if got := slipValue(t, getBearerHdr(t, app, path, ownMach, switchToVictim)); got != slipAdminValue {
t.Fatalf("owner=admin carrying its OWN machine aud + org-switch read %v, want %q "+
"(own machine aud strips admin)", got, slipAdminValue)
}
}
+117 -48
View File
@@ -23,8 +23,10 @@ package kms_test
import (
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -102,6 +104,15 @@ func getBearerHdr(t *testing.T, app *zip.App, path, token string, hdr map[string
// aud)]. The token validates (audience is not a gate). The attack: the presence of the
// victim-bound machine aud in the set must NOT let acme reach maxpower. Owner (=acme,
// signed) governs.
//
// THE ORACLE MOVED FROM STATUS TO VALUE. This vector used to read the victim by
// naming it in the URL, so "denied" was observable as a 403. There is no such URL:
// both tenants spell one path and the org comes from the signed claim, so the
// request ALWAYS succeeds — the only question is WHOSE record it returns. Both orgs
// are seeded at the identical coordinate with distinct plaintexts, and the
// assertion is that acme's token yields acme's bytes. That is a strictly sharper
// test of "owner governs, not aud": the old 403 could have come from any refusal,
// while a returned plaintext names exactly which tenant the boundary selected.
func TestRed_MultiValueAud_OwnerStillGoverns(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -110,22 +121,40 @@ func TestRed_MultiValueAud_OwnerStillGoverns(t *testing.T) {
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL)) // allowlist = ["hanzo-console"]
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // maxpower's secret
sealPlatformSecret(t, deps.KMS, paasOrgB, "s3kr3t-of-acme") // acme's own secret
maxpowerPath := "/v1/kms" + paasEnvPath
acmePath := "/v1/kms" + paasEnvPath
const acmeValue = "s3kr3t-of-acme"
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // maxpower's secret
sealPlatformSecret(t, deps.KMS, paasOrgB, acmeValue) // acme's own, SAME coordinate
path := "/v1/kms" + paasEnvPath // the ONE path both tenants use
future := time.Now().Add(time.Hour)
multi := mintRed(t, key, paasOrgB, []string{"hanzo-console", paasOrgA + "-platform-kms"}, false, future)
// Cross-tenant: acme (with maxpower's machine aud in its aud SET) must NOT read maxpower.
if resp := getWithBearer(t, app, maxpowerPath, multi); resp.StatusCode != 403 {
t.Fatalf("multi-value aud [hanzo-console, maxpower-platform-kms] owner=acme → maxpower = %d, want 403", resp.StatusCode)
// acme, carrying maxpower's machine aud, is served ACME's record.
resp := getWithBearer(t, app, path, multi)
if resp.StatusCode != 200 {
t.Fatalf("multi-value aud owner=acme = %d, want 200 (the token must validate; aud is not a gate)", resp.StatusCode)
}
// Proves the token DID validate (so the 403 above is the org boundary, not a
// validation reject): the same token reads ACME's own secret → 200.
if resp := getWithBearer(t, app, acmePath, multi); resp.StatusCode != 200 {
t.Fatalf("multi-value aud owner=acme → acme (own) = %d, want 200 (token must have validated)", resp.StatusCode)
if got := decode(t, resp.Body)["value"]; got != acmeValue {
t.Fatalf("AUD WIDENED REACH: aud [hanzo-console, maxpower-platform-kms] owner=acme read %v, "+
"want %q — the victim's machine aud must not select the victim's record", got, acmeValue)
}
// Order-independence: the victim's machine aud FIRST changes nothing.
rev := mintRed(t, key, paasOrgB, []string{paasOrgA + "-platform-kms", "hanzo-console"}, false, future)
if got := decode(t, getWithBearer(t, app, path, rev).Body)["value"]; got != acmeValue {
t.Fatalf("AUD WIDENED REACH (reversed aud order): read %v, want %q", got, acmeValue)
}
// Nor can the aud be combined with an org SELECTION: acme is not a member of
// maxpower, so SanitizeIdentity discards the switch and acme stays in acme.
if got := decode(t, getBearerHdr(t, app, path, multi, map[string]string{"X-Org-Id": paasOrgA}).Body)["value"]; got != acmeValue {
t.Fatalf("AUD WIDENED REACH (aud + X-Org-Id switch): read %v, want %q", got, acmeValue)
}
// The converse pins that this is about the owner and not about acme being
// somehow special: maxpower's token still reads maxpower.
if got := decode(t, getWithBearer(t, app, path, mintRed(t, key, paasOrgA, []string{"hanzo-console"}, false, future)).Body)["value"]; got != paasValueA {
t.Fatalf("owner=maxpower read %v, want %q", got, paasValueA)
}
}
@@ -143,6 +172,14 @@ func TestRed_MultiValueAud_OwnerStillGoverns(t *testing.T) {
// principal — identified by its OWN <owner>-platform-kms aud — is DENIED SuperAdmin
// by isKMSMachinePrincipal and pinned to its own org. A client_credentials machine
// identity must never wield platform-admin.
//
// THE ORACLE FOR "GOT SUPERADMIN" IS THE ORG-SWITCH, NOT A URL. Reaching a foreign
// org used to mean naming it in the path; that route is gone. What SuperAdmin
// actually confers now is the CLAIM-BOUND org-switch — SanitizeIdentity's admin arm
// alone honors X-Org-Id as the effective org (middleware_identity.go `effOrg =
// cliOrg`). So the test presents the switch header and reads the VALUE: maxpower's
// plaintext means the token got platform sudo, admin's own means it did not. Same
// question, an oracle that still exists, and a sharper answer than a status.
func TestRed_AdminOrgMachineToken(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -151,49 +188,53 @@ func TestRed_AdminOrgMachineToken(t *testing.T) {
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL)) // AdminOrg="admin", allowlist=["hanzo-console"]
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // victim maxpower secret
sealPlatformSecret(t, deps.KMS, "admin", "s3kr3t-of-admin") // admin org's own secret
victimPath := "/v1/kms" + paasEnvPath
adminPath := "/v1/kms/orgs/admin" + paasEnvPath
const adminValue = "s3kr3t-of-admin"
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // victim maxpower secret
sealPlatformSecret(t, deps.KMS, "admin", adminValue) // admin org's own, SAME coordinate
path := "/v1/kms" + paasEnvPath // the ONE path; the token picks the tenant
switchToVictim := map[string]string{"X-Org-Id": paasOrgA}
future := time.Now().Add(time.Hour)
// value reads one probe's plaintext — the observable that says which tenant the
// boundary selected.
value := func(resp *http.Response) any { return decode(t, resp.Body)["value"] }
// (a) isAdmin=FALSE — the real client_credentials machine token.
ccAdmin := mintRed(t, key, "admin", []string{"admin-platform-kms"}, false, future)
// It CAN read the admin org's OWN secrets (legit principal for its own org).
if resp := getWithBearer(t, app, adminPath, ccAdmin); resp.StatusCode != 200 {
t.Fatalf("admin-org machine token → admin-org own secret = %d, want 200", resp.StatusCode)
if got := value(getWithBearer(t, app, path, ccAdmin)); got != adminValue {
t.Fatalf("admin-org machine token → own org read %v, want %q", got, adminValue)
}
// It must NOT read a DIFFERENT tenant — owner==adminOrg without isAdmin is not SuperAdmin.
if resp := getWithBearer(t, app, victimPath, ccAdmin); resp.StatusCode != 403 {
t.Fatalf("admin-org MACHINE token (isAdmin=false) → victim = %d, want 403 (no SuperAdmin from owner alone)", resp.StatusCode)
}
// Not even with an explicit org-switch header (admin org-switch is honored ONLY for isAdmin=true).
if resp := getBearerHdr(t, app, victimPath, ccAdmin, map[string]string{"X-Org-Id": paasOrgA}); resp.StatusCode != 403 {
t.Fatalf("admin-org machine token + X-Org-Id:maxpower switch = %d, want 403", resp.StatusCode)
// It must NOT reach a DIFFERENT tenant — owner==adminOrg without isAdmin is not
// SuperAdmin, so the switch is not offered to it and it stays in the admin org.
if got := value(getBearerHdr(t, app, path, ccAdmin, switchToVictim)); got != adminValue {
t.Fatalf("admin-org MACHINE token (isAdmin=false) + X-Org-Id:maxpower read %v, want %q "+
"(no SuperAdmin from owner alone ⇒ no org-switch)", got, adminValue)
}
// (b) The DISCRIMINATOR is isKMSMachinePrincipal, not the audience. A REAL admin
// (b) The DISCRIMINATOR is isMachinePrincipal, not the audience. A REAL admin
// (isAdmin=true) gets SuperAdmin whatever app minted the token — audience is not a
// gate, owner==adminOrg + isAdmin is the authority — so an admin token with an
// arbitrary aud reads the victim cross-org → 200.
// gate, admin-org membership + human is the authority — so an admin token with an
// arbitrary aud CAN switch into the victim and read it.
arbAdminTrue := mintRed(t, key, "admin", []string{"some-random-app"}, true, future)
if resp := getWithBearer(t, app, victimPath, arbAdminTrue); resp.StatusCode != 200 {
t.Fatalf("isAdmin=true + arbitrary aud → victim = %d, want 200 (a real admin is admin from any app)", resp.StatusCode)
if got := value(getBearerHdr(t, app, path, arbAdminTrue, switchToVictim)); got != paasValueA {
t.Fatalf("isAdmin=true + arbitrary aud + org-switch read %v, want %q "+
"(a real admin is admin from any app)", got, paasValueA)
}
// The ONE exception: a MACHINE principal (its OWN <owner>-platform-kms aud present)
// is DENIED SuperAdmin by isKMSMachinePrincipal even with isAdmin=true, so it is
// pinned to owner=admin and CANNOT read the victim → 403 (a machine identity must
// never wield platform-admin).
// is DENIED SuperAdmin by isKMSMachinePrincipal even with isAdmin=true, so the
// switch is inert and it stays pinned to owner=admin — a machine identity must
// never wield platform-admin.
machAdminTrue := mintRed(t, key, "admin", []string{"admin-platform-kms"}, true, future)
if resp := getWithBearer(t, app, victimPath, machAdminTrue); resp.StatusCode != 403 {
t.Fatalf("machine principal (isAdmin=true + own machine aud) → victim = %d, want 403 "+
"(a machine principal must NEVER receive SuperAdmin)", resp.StatusCode)
if got := value(getBearerHdr(t, app, path, machAdminTrue, switchToVictim)); got != adminValue {
t.Fatalf("machine principal (isAdmin=true + own machine aud) + org-switch read %v, want %q "+
"(a machine principal must NEVER receive SuperAdmin)", got, adminValue)
}
// The fix gates ONLY the admin grant: the machine principal still reads its OWN
// org (admin), so data-plane access is intact.
if resp := getWithBearer(t, app, adminPath, machAdminTrue); resp.StatusCode != 200 {
t.Fatalf("admin-org machine principal → admin-org own secret = %d, want 200 (data access intact)", resp.StatusCode)
// The gate covers ONLY the admin grant: the machine principal still reads its OWN
// org, so data-plane access is intact.
if got := value(getWithBearer(t, app, path, machAdminTrue)); got != adminValue {
t.Fatalf("admin-org machine principal → own org read %v, want %q (data access intact)", got, adminValue)
}
}
@@ -201,7 +242,16 @@ func TestRed_AdminOrgMachineToken(t *testing.T) {
//
// owner "maxpower " (trailing space) with a matching machine aud so validate()
// passes. SanitizeIdentity must REFUSE to fold "maxpower " onto tenant "maxpower":
// OrgHasUnsafeRune zeroes the owner → org-less → the guard 403s the victim read.
// OrgHasUnsafeRune zeroes the owner → the request is org-less → the guard refuses.
//
// THE REFUSAL CODE IS NOW 400, NOT 403, AND BOTH ARE THE SAME FAIL-CLOSED. An
// org-less request no longer reaches the old `ctx.Org() != :org` comparison (403,
// "you are not that org"); it reaches the edge validator first, which rejects the
// zeroed org as not a DNS-1123 label (400) before any store access. The test
// therefore accepts either refusal and — the part that actually matters and was
// never asserted before — requires the VICTIM'S PLAINTEXT to be absent from the
// response. A collapse onto "maxpower" would show up as a 200 carrying
// paasValueA, which no status assertion alone would have caught.
func TestRed_TrimCollapseOwner_FailsClosed(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -210,22 +260,41 @@ func TestRed_TrimCollapseOwner_FailsClosed(t *testing.T) {
jwks := e2eJWKS(t, &key.PublicKey)
app, deps := newAppWithIdentity(t, e2eCfg(t, jwks.URL))
sealPlatformSecret(t, deps.KMS, paasOrgA, paasValueA) // maxpower
victimPath := "/v1/kms" + paasEnvPath
path := "/v1/kms" + paasEnvPath
future := time.Now().Add(time.Hour)
// refused asserts the one property both codes share: no tenancy was granted, so
// no tenant's bytes came back.
refused := func(what string, resp *http.Response) {
t.Helper()
body := readAll(resp.Body)
if strings.Contains(body, paasValueA) {
t.Fatalf("FOLD BREACH: %s received maxpower's secret: %s", what, body)
}
if resp.StatusCode != 400 && resp.StatusCode != 403 {
t.Fatalf("%s = %d, want 400 or 403 (org-less fail-closed): %s", what, resp.StatusCode, body)
}
}
for _, owner := range []string{"maxpower ", "maxpower", " maxpower", "maxpower\t"} {
// aud is bound to the RAW owner so validate()'s machine-aud check passes; the
// defense must be SanitizeIdentity refusing tenancy for the unsafe owner.
tok := mintRed(t, key, owner, []string{owner + "-platform-kms"}, false, future)
if resp := getWithBearer(t, app, victimPath, tok); resp.StatusCode != 403 {
t.Fatalf("unsafe/trim owner %q reading maxpower = %d, want 403 (no fold onto victim)", owner, resp.StatusCode)
}
refused(fmt.Sprintf("unsafe/trim owner %q", owner), getWithBearer(t, app, path, tok))
// …and it cannot recover tenancy by SELECTING the victim either: an org-less
// principal never enters an arm of SanitizeIdentity that honors X-Org-Id.
refused(fmt.Sprintf("unsafe/trim owner %q + X-Org-Id:maxpower", owner),
getBearerHdr(t, app, path, tok, map[string]string{"X-Org-Id": paasOrgA}))
}
// Empty owner with the bare-suffix aud: the token validates (audience is not a gate)
// but owner is empty → no org scope → the guard 403s the victim read (fail closed).
// Empty owner with the bare-suffix aud: the token validates (audience is not a
// gate) but owner is empty → no org scope → fail closed.
empty := mintRed(t, key, "", []string{"-platform-kms"}, false, future)
if resp := getWithBearer(t, app, victimPath, empty); resp.StatusCode != 403 {
t.Fatalf("empty-owner bare-suffix aud = %d, want 403 (fail closed)", resp.StatusCode)
refused("empty-owner bare-suffix aud", getWithBearer(t, app, path, empty))
// The victim is untouched throughout — proof the refusals above are the fold
// being rejected, not the endpoint being broken.
if got := decode(t, getWithBearer(t, app, path, mintRed(t, key, paasOrgA, []string{"hanzo-console"}, false, future)).Body)["value"]; got != paasValueA {
t.Fatalf("maxpower read %v, want %q", got, paasValueA)
}
}
+33 -30
View File
@@ -45,9 +45,14 @@ import (
kmsstore "github.com/luxfi/kms/pkg/store"
)
// reservedPlatformSlug mirrors cloud.PlatformDB's reserved partition: a path that
// names no tenant org routes here. It carries '_', which SanitizeOrg never emits,
// so it can never alias a real org's file.
// reservedPlatformSlug names cloud.PlatformDB's reserved partition — the file that
// holds deployment-wide, org-less facade secrets. It is reached ONLY when fileOrg
// signals facade=true (an empty / non-"/orgs/" path), never by an org STRING. A
// tenant path that literally spells "/orgs/_platform/…" keys on SanitizeOrg's slug,
// which carries no '_' and so can never equal this reserved slug — that path opens a
// distinct (empty) tenant store and reads a plain 404, indistinguishable from any
// missing secret. (The earlier code compared the RAW pre-slug org and would have
// aliased the two; the comment claimed a guarantee the code did not have.)
const reservedPlatformSlug = "_platform"
// errReadOnly is returned by a reader-mode store when a mutation is attempted. A
@@ -63,7 +68,7 @@ type secretStore struct {
readOnly bool
mu sync.Mutex
dbs map[string]*sql.DB // key: fileOrg(path) → open handle for that org's kms.db
dbs map[string]*sql.DB // key: the file-identity slug (SanitizeOrg, or the reserved facade slug)
}
func newSecretStore(dataDir string, readOnly bool) *secretStore {
@@ -75,16 +80,16 @@ func newSecretStore(dataDir string, readOnly bool) *secretStore {
// lands in the reserved platform partition. The returned value is the RAW org (or
// the reserved sentinel); cloud.OrgDB folds a raw org through SanitizeOrg, so
// distinct raw orgs stay on distinct files.
func fileOrg(path string) string {
func fileOrg(path string) (org string, facade bool) {
p := strings.Trim(strings.TrimSpace(path), "/")
if p == "" {
return reservedPlatformSlug
return reservedPlatformSlug, true
}
segs := strings.SplitN(p, "/", 3)
if segs[0] == "orgs" && len(segs) >= 2 && segs[1] != "" {
return segs[1]
return segs[1], false
}
return reservedPlatformSlug
return reservedPlatformSlug, true
}
// dbFor resolves (opening + migrating + caching on first use) the SQLite handle
@@ -93,6 +98,7 @@ func fileOrg(path string) string {
// - create=false (read/list/delete) → return (nil, nil); the caller treats
// absence as "no such secret" and NEVER litters an empty store shell for an
// org that only had a read attempted.
//
// A reader (s.readOnly) never creates: create is forced false, and it performs no
// DDL (the writer already migrated). The org is folded through the injective
// slugger inside cloud.OrgDB, so a path can never traverse out of {DataDir}/orgs
@@ -101,14 +107,28 @@ func (s *secretStore) dbFor(path string, create bool) (*sql.DB, error) {
if s.readOnly {
create = false
}
org := fileOrg(path)
org, facade := fileOrg(path)
// key is the FILE identity, not the raw org: the reserved slug for the facade,
// else the SanitizeOrg slug that OrgDB actually opens. This is what the cache and
// the existence check MUST key on — the raw org does not, and that mismatch was a
// real aliasing: a tenant path "/orgs/_platform/…" (raw "_platform") shared a cache
// slot with the facade (also "_platform"), so whichever opened first served the
// other. SanitizeOrg NEVER emits "_platform" (it carries '_'), so a real tenant
// slug can never collide with the reserved one — the boundary is now structural.
key := reservedPlatformSlug
if !facade {
key = cloud.SanitizeOrg(org)
if key == "" {
return nil, nil // unsluggable org → not found (same 404 as any miss; no oracle)
}
}
s.mu.Lock()
defer s.mu.Unlock()
if db, ok := s.dbs[org]; ok {
if db, ok := s.dbs[key]; ok {
return db, nil
}
if !create && !s.orgFileExists(org) {
if !create && !cek.Exists(filepath.Join(s.dataDir, "orgs", key, "kms.db")) {
return nil, nil // nothing to open; caller returns not-found / empty
}
@@ -116,7 +136,7 @@ func (s *secretStore) dbFor(path string, create bool) (*sql.DB, error) {
db *sql.DB
err error
)
if org == reservedPlatformSlug {
if facade {
db, err = cloud.PlatformDB(s.dataDir, "kms")
} else {
// OrgDB SanitizeOrg-slugs the org, creates {DataDir}/orgs/{slug} 0700, opens
@@ -132,27 +152,10 @@ func (s *secretStore) dbFor(path string, create bool) (*sql.DB, error) {
return nil, fmt.Errorf("kms: migrate org store %q: %w", org, err)
}
}
s.dbs[org] = db
s.dbs[key] = db
return db, nil
}
// orgFileExists reports whether the on-disk kms.db for org already exists — the
// reader's "is this store hydrated?" check. Best-effort: an unreadable path is
// treated as absent (fail closed).
func (s *secretStore) orgFileExists(org string) bool {
var path string
if org == reservedPlatformSlug {
path = filepath.Join(s.dataDir, "orgs", reservedPlatformSlug, "kms.db")
} else {
slug := cloud.SanitizeOrg(org)
if slug == "" {
return false
}
path = filepath.Join(s.dataDir, "orgs", slug, "kms.db")
}
return cek.Exists(path)
}
// migrateSecrets creates the sealed-secret table. Idempotent (IF NOT EXISTS), so
// a reopen or a reader (opening an already-migrated file) is a no-op. The primary
// key (path, env, name) is the exact coordinate the ZapDB key encoded
+33
View File
@@ -0,0 +1,33 @@
package kms
import "testing"
// TestDBFor_TenantCannotSpellReservedPartition pins the defense-in-depth the red
// team flagged: a tenant path literally spelling "/orgs/_platform/…" must be
// refused, never routed to cloud.PlatformDB (the deployment-wide facade store).
// The facade is reachable ONLY by the empty/non-"/orgs" route, and this holds
// even without validOrg having run first.
func TestDBFor_TenantCannotSpellReservedPartition(t *testing.T) {
s := newSecretStore(t.TempDir(), false)
// The facade partition is chosen by the boolean, never by a tenant org string.
_, facade := fileOrg("/orgs/" + reservedPlatformSlug + "/secrets/x")
if facade {
t.Fatal("a tenant path /orgs/_platform routed to the facade partition — must not")
}
if _, f := fileOrg("/facade-secret"); !f {
t.Fatal("a non-/orgs path must route to the facade partition")
}
// The tenant _platform path opens a DISTINCT store (not PlatformDB) and its DB
// pointer differs from the real facade store — proof they never alias.
tenantDB, err := s.dbFor("/orgs/"+reservedPlatformSlug+"/secrets/x", true)
if err != nil || tenantDB == nil {
t.Fatalf("tenant _platform path must open its own store: db=%v err=%v", tenantDB, err)
}
facadeDB, err := s.dbFor("/facade-secret", true)
if err != nil || facadeDB == nil {
t.Fatalf("facade route must open PlatformDB: db=%v err=%v", facadeDB, err)
}
if tenantDB == facadeDB {
t.Fatal("tenant _platform store and the facade store are the SAME handle — they must never alias")
}
}
+46 -9
View File
@@ -21,6 +21,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -132,6 +133,15 @@ func getWithBearer(t *testing.T, app *zip.App, path, token string) *http.Respons
return resp
}
// TestPaaSSyncMachineTokenEndToEnd drives the real signed-token pipeline.
//
// THE PATH NO LONGER NAMES A TENANT, so "acme's path" and "maxpower's path" are one
// string and every cross-tenant assertion here has to be made on the VALUE returned,
// not the status. Both orgs are therefore seeded at the identical coordinate with
// distinct plaintexts: whichever string comes back names the org that was actually
// served, which is the only unambiguous evidence of scope. A cross-tenant attempt
// answers 404 (or the caller's own record), never 403 — the caller cannot express a
// request for another tenant's secret, so there is nothing to forbid.
func TestPaaSSyncMachineTokenEndToEnd(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -155,11 +165,30 @@ func TestPaaSSyncMachineTokenEndToEnd(t *testing.T) {
t.Fatalf("read value=%v, want the sealed secret", got)
}
// (2) Cross-tenant: acme's REAL machine token cannot read maxpower's secret → 403.
// SanitizeIdentity pins owner=acme from the signed claim; the guard denies.
// (2) Cross-tenant: acme's REAL machine token, at the SAME URL, gets acme's own
// namespace — empty — and never maxpower's plaintext. SanitizeIdentity pins
// owner=acme from the signed claim, so the coordinate resolves under acme.
cross := mintMachineToken(t, key, paasOrgB, paasOrgB+"-platform-kms", future) // paasOrgB == "acme"
if resp := getWithBearer(t, app, aPath, cross); resp.StatusCode != 403 {
t.Fatalf("cross-tenant machine token (acme→maxpower) = %d, want 403", resp.StatusCode)
resp := getWithBearer(t, app, aPath, cross)
if resp.StatusCode != 404 {
t.Fatalf("cross-tenant machine token (acme at maxpower's URL) = %d, want 404 "+
"(the org rides on the token, so acme's request resolves inside acme)", resp.StatusCode)
}
if b := readAll(resp.Body); strings.Contains(b, paasValueA) {
t.Fatalf("LEAK: acme's machine token received maxpower's secret: %s", b)
}
// (2b) Give acme its OWN secret at the identical coordinate and re-issue the
// byte-identical request: each machine token must receive its own tenant's
// plaintext. This is the assertion a status-only check cannot make, and the one
// that fails if the per-org partition ever breaks.
const valueB = "s3kr3t-of-acme-e2e"
sealPlatformSecret(t, deps.KMS, paasOrgB, valueB)
if got := decode(t, getWithBearer(t, app, aPath, cross).Body)["value"]; got != valueB {
t.Fatalf("PARTITION BREAK: acme's token read %v, want %q", got, valueB)
}
if got := decode(t, getWithBearer(t, app, aPath, own).Body)["value"]; got != paasValueA {
t.Fatalf("PARTITION BREAK: maxpower's token read %v, want %q", got, paasValueA)
}
// (3) Audience is not a gate: a token with a brand-new, never-registered aud still
@@ -169,11 +198,19 @@ func TestPaaSSyncMachineTokenEndToEnd(t *testing.T) {
t.Fatalf("never-registered aud reading OWN org = %d, want 200 (aud is not a gate)", resp.StatusCode)
}
// (4) …and the aud still cannot cross tenants: owner=maxpower bearing acme's machine
// aud reading ACME's path is denied by owner-scope → 403 (owner governs, not aud).
bPath := "/v1/kms" + paasEnvPath
if resp := getWithBearer(t, app, bPath, mintMachineToken(t, key, paasOrgA, paasOrgB+"-platform-kms", future)); resp.StatusCode != 403 {
t.Fatalf("owner=maxpower token reading acme path = %d, want 403 (owner scopes, not aud)", resp.StatusCode)
// (4) …and the aud still cannot cross tenants. Carrying the VICTIM's machine aud
// is the sharpest form of the attack, and the answer is decided entirely by the
// owner claim: maxpower bearing ACME's machine aud is served MAXPOWER's value.
// The status is 200 either way, so only the value distinguishes "owner governs"
// from "aud widened reach" — which is why this assertion is on the body.
audCross := mintMachineToken(t, key, paasOrgA, paasOrgB+"-platform-kms", future)
if got := decode(t, getWithBearer(t, app, aPath, audCross).Body)["value"]; got != paasValueA {
t.Fatalf("AUD WIDENED REACH: owner=maxpower bearing acme's machine aud read %v, want %q "+
"(owner scopes, not aud)", got, paasValueA)
}
audCrossB := mintMachineToken(t, key, paasOrgB, paasOrgA+"-platform-kms", future)
if got := decode(t, getWithBearer(t, app, aPath, audCrossB).Body)["value"]; got != valueB {
t.Fatalf("AUD WIDENED REACH: owner=acme bearing maxpower's machine aud read %v, want %q", got, valueB)
}
// (5) An expired machine token is anonymous → 403 (fail closed on expiry).
+1 -1
View File
@@ -52,7 +52,7 @@ type optinStore struct {
}
func openOptinStore(path string) (*optinStore, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -24,7 +24,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -33,7 +33,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -36,7 +36,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func TestMigrateUpgradesOldCampaignsTable(t *testing.T) {
// Seed a prod-shaped OLD DB: marketing_campaigns WITHOUT scheduled_at, plus an
// existing row — exactly what a pre-scheduling prod deployment holds. Written
// through cek so the on-disk format matches what openStore reads back.
raw, err := cek.Open(path)
raw, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("cek.Open (seed old db): %v", err)
}
+1 -1
View File
@@ -42,7 +42,7 @@ type Store struct {
// Open opens (and migrates) the listing store at path.
func Open(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("marketplace: open store %q: %w", path, err)
}
+1 -1
View File
@@ -71,7 +71,7 @@ type annStore struct {
}
func openAnnStore(path string) (*annStore, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
-8
View File
@@ -1,8 +0,0 @@
# Generated by cmd/gen-app-cmds. DO NOT EDIT.
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# cmd/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := paas
include ../../mk/plugin.mk
+1 -1
View File
@@ -10,7 +10,7 @@
// (client-go, the arcd model) to build the repo and push the per-tenant
// image; the deployment lands "building". The build watcher that flips
// "building"→"live" by applying the CR with the built image is phase 2
// (paas-in-cloud.md §4) — until then the git path stops honestly at
// (platform-in-cloud §4) — until then the git path stops honestly at
// "building" with the real Job reference, never a fabricated "live".
//
// Every handler is org-scoped (s.tenant) and every cluster write targets
@@ -1,5 +1,5 @@
// Package paas mounts the native, in-process Hanzo PaaS control plane at
// /v1/paas/*: the "one and only one way to deploy" made native to the cloud
// drift.go — the drift verdict the fleet board attaches to every App CR row:
// the "one and only one way to deploy" made native to the cloud
// binary. It is the Go port of the standalone Dokploy-based platform's
// build→deploy lifecycle (pkg/platform/src/services/ci/deploy-executor.ts +
// services/apps/inventory.ts + db/schema/apps-drift.ts), collapsed into an
@@ -17,9 +17,9 @@
// service row and performs no IO. It is a faithful port of
// `pkg/platform/src/db/schema/apps-drift.ts` so the two implementations can never
// disagree about what "drift" means (one way to compute drift, period). The
// cluster reader (paas.go) owns observing the tags; this file only interprets
// cluster reader (fleet.go) owns observing the tags; this file only interprets
// them.
package paas
package platform
import "regexp"
@@ -91,7 +91,7 @@ type Drift struct {
// Observed is the minimal set of already-observed tag fields the drift derivation
// reads — mirrors the `Pick<App, …>` the TS `computeDrift` accepts. The reader
// (paas.go) fills these from the cluster; the release fields are populated by the
// (fleet.go) fills these from the cluster; the release fields are populated by the
// GH-release reader (a follow-up), so today they are the honest zero value
// (ReleaseURL == "" ⇒ no-release, exactly like the un-populated TS columns).
type Observed struct {
@@ -1,41 +1,46 @@
// paas.go — the cluster-facing half of the native Hanzo PaaS control plane.
// fleet.go — the platform's view of its OWN service tier.
//
// It mounts /v1/paas/* on the unified cloud binary and reads the operator's
// It mounts /v1/platform/fleet on the unified cloud binary and reads the operator's
// `hanzo.ai/v1` `App` CustomResource — the one workload kind the fleet runs on:
//
// GET /v1/paas/apps — the fleet drift board (inventory.ts): list every
// operator App CR across the platform namespaces,
// read declared vs running tag + health from the CR
// (+ its status), and attach the drift verdict
// (drift.go / apps-drift.ts).
// GET /v1/paas/apps/:app — one app row by CR name.
// POST /v1/paas/apps/:app/deploy— zero-downtime ROLLING RESTART of the app's
// Deployment (the `kubectl rollout restart`
// mechanism): re-pulls the DECLARED image, recreates
// pods gracefully. It never changes the declared
// TAG (that stays a git commit, the one thing Hanzo
// CD's selfHeal reconciles), so there is no drift to
// revert. This is `hanzo deploy`.
// GET /v1/paas/health — real k8s reachability + App CRD presence.
// GET /v1/platform/fleet — the fleet drift board: list every operator
// App CR across the platform namespaces, read
// declared vs running tag + health from the CR
// (+ its status), and attach the drift verdict
// (drift.go).
// GET /v1/platform/fleet/:app — one app row by CR name.
// POST /v1/platform/fleet/:app/deploy— zero-downtime ROLLING RESTART of the app's
// Deployment (the `kubectl rollout restart`
// mechanism): re-pulls the DECLARED image,
// recreates pods gracefully. It never changes the
// declared TAG (that stays a git commit, the one
// thing Hanzo CD's selfHeal reconciles), so there
// is no drift to revert. This is `hanzo deploy`.
//
// FLEET vs PROJECTS/:project/APPS — two collections, two names. `fleet` is the
// PLATFORM's own tier (the shared services the platform itself runs on: iam, kms,
// gateway, …), observed from the operator App CRs. `projects/:project/apps` is a
// CUSTOMER's apps, owned by platform's own store. They answer different questions,
// so they carry different names under the one /v1/platform prefix. This file used to
// be a second top-level product (`/v1/paas`) — the same platform under a second
// name, which is exactly the duplicate definition the one-way rule forbids.
//
// SECURITY — every route is authorized off ONE IAM identity, exactly like the
// /v1/runner build path (clients/platform/runner.go): the guard admits a validated
// principal (principal.Validated) who is a SuperAdmin OR an OrgAdmin, and each
// handler then CONFINES a non-super caller to its own org's platform namespaces
// /v1/runner build path (runner.go): the guard admits a validated principal
// (principal.Validated) who is a SuperAdmin OR an OrgAdmin, and each handler then
// CONFINES a non-super caller to its own org's platform namespaces
// (scopedNamespaces, keyed on principal.Org — never a client header). A SuperAdmin
// observes/acts on the whole fleet; an OrgAdmin only on the namespaces its org owns;
// a plain member or an unauthenticated caller is refused 403. So a tenant admin can
// never observe — or restart — another org's, or a platform, app, and the platform
// operator drives the board off a plain `hanzo login` with no shared token. The
// user-facing per-app PaaS view still lives in console; this is the CLI/operator
// surface.
// operator drives the board off a plain `hanzo login` with no shared token.
//
// k8s client: built in-process from the in-cluster service account
// (rest.InClusterConfig) with a KUBECONFIG fallback for local/dev — the identical
// construction clients/ml uses. When no kubeconfig is resolvable the subsystem
// mounts anyway and every endpoint fails closed (503 + the real init error; the
// health route reports "degraded"), never status-theater.
package paas
// construction clients/ml uses. When no kubeconfig is resolvable the board mounts
// anyway and every endpoint fails closed (503 + the real init error; the shared
// /v1/platform/health route reports "degraded"), never status-theater.
package platform
import (
"context"
@@ -129,7 +134,7 @@ const nsScanTTL = 60 * time.Second
// which is narrower than the truth — a tenant admin can lose visibility of its own
// namespace, never gain visibility of someone else's. nsClass still filters, so a
// namespace that is not ours can never enter the set however discovery goes.
func discoverNamespaces(s *cloud.Service[state], ctx context.Context) []string {
func discoverNamespaces(s *cloud.Service[fleetState], ctx context.Context) []string {
cache := s.State.scan
if cache != nil {
cache.mu.Lock()
@@ -190,10 +195,10 @@ var appNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// to a floating tag is possible, but the drift board then flags it loudly).
var imageRepoRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*[a-z0-9]$`)
const userAgent = "hanzo-cloud-paas"
const fleetUserAgent = "hanzo-cloud-platform-fleet"
// state is paas's own data; shared deps live in the embedded cloud.Base.
type state struct {
// fleetState is the fleet board's own data; shared deps live in the embedded cloud.Base.
type fleetState struct {
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
initErr string // why dyn is nil, surfaced by health + ready()
@@ -212,54 +217,51 @@ type nsCache struct {
at time.Time
}
// Mount wires the /v1/paas/* surface onto app. Every handler is behind the IAM
// guard (SuperAdmin or OrgAdmin, org-confined), then reads/patches the operator
// App CRs + their Deployments.
func Mount(app cloud.Router, deps cloud.Deps) error {
return cloud.Mount(app, deps, "paas", build, routes)
}
// build resolves the in-process k8s dynamic client (fail-closed: when no kubeconfig
// resolves the subsystem still mounts and every endpoint 503s honestly).
func build(b cloud.Base) (state, error) {
st := state{scan: &nsCache{}}
if dyn, err := newDynamic(); err != nil {
// buildFleet resolves the in-process k8s dynamic client (fail-closed: when no
// kubeconfig resolves the board still mounts and every endpoint 503s honestly).
func buildFleet(b cloud.Base) fleetState {
st := fleetState{scan: &nsCache{}}
if dyn, err := newFleetDynamic(); err != nil {
st.initErr = err.Error()
b.Log.Warn("kubernetes client unavailable; /v1/paas endpoints will fail closed", "err", err)
b.Log.Warn("kubernetes client unavailable; /v1/platform/fleet will fail closed", "err", err)
} else {
st.dyn = dyn
}
b.Log.Info("paas control plane mounted",
"prefix", "/v1/paas", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
return st, nil
b.Log.Info("fleet board mounted",
"prefix", "/v1/platform/fleet", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
return st
}
// routes registers the /v1/paas/* surface. Every mutating/observing route is behind
// the IAM guard (SuperAdmin or org-confined OrgAdmin); the health probe is public
// (real k8s reachability).
func routes(app cloud.Router, s *cloud.Service[state]) {
g := app.Group("/v1/paas")
g.Get("/apps", guard(s, cloud.Handle(s, listApps)))
g.Get("/apps/:app", guard(s, cloud.Handle(s, getApp)))
// MUTATION is superadmin-only (operatorGuard), NOT the broader read guard: the
// fleetRoutes registers the /v1/platform/fleet surface. Every mutating/observing
// route is behind the IAM guard (SuperAdmin or org-confined OrgAdmin).
//
// It is a SIBLING of the /v1/platform/projects/:project/apps surface, not a second
// copy of it: `fleet` is the platform's OWN service tier (the operator App CRs the
// platform runs on), `projects/:project/apps` is a CUSTOMER's apps. Two different
// collections need two different names — calling both "apps" under one prefix would
// be the duplicate definition this fold exists to remove.
func fleetRoutes(app cloud.Router, s *cloud.Service[fleetState]) {
g := app.Group("/v1/platform/fleet")
g.Get("", fleetGuard(s, cloud.Handle(s, listFleet)))
g.Get("/:app", fleetGuard(s, cloud.Handle(s, getFleetApp)))
// MUTATION is superadmin-only (fleetOperatorGuard), NOT the broader read guard: the
// only namespaces this board scans are the platform's OWN tier (hanzo{,-testnet,
// -devnet}), so a rolling restart here recreates a SHARED platform service
// (iam/kms/gateway/…). Per the 2026-07-08 admin-org P0 a brand-org ("hanzo")
// admin is a CUSTOMER-org admin, not a platform operator — restarting prod iam is
// a platform-operator action. Gating the read board (below) any wider is bounded
// (observe, audit-logged); gating a restart wider is a live DoS lever (RED H1).
g.Post("/apps/:app/deploy", operatorGuard(s, cloud.Handle(s, deploy)))
g.Get("/health", cloud.Handle(s, health))
g.Post("/:app/deploy", fleetOperatorGuard(s, cloud.Handle(s, deployFleet)))
// Native release seam: install the first-party CR-rollout hook (build.go's
// RegisterServiceReleaser inversion) so a proven, clean-semver image rolls onto
// its Service CR here — the direct-CR replacement for the image-update.yml
// GitOps hop (release.go).
// GitOps hop (rollout.go).
registerReleaser(s)
// In-process fleet seam: publish THIS board's observer so the admin god-view
// (/v1/admin/products + the overview drift KPIs) reuses the SAME App-CR + drift
// observation instead of forking a second k8s client (fleet.go).
// observation instead of forking a second k8s client (observer.go).
PublishFleet(fleetObserver{s: s})
}
@@ -277,7 +279,7 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
// This mirrors runner.go's org attribution (default to the caller's org, refuse a
// foreign one) for a READ/RESTART surface. Broadening from SuperAdmin-only lets the
// platform operator drive the board off a plain `hanzo login` with no shared token.
func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
func fleetGuard(s *cloud.Service[fleetState], h zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if !principal.Validated(c) {
return zip.ErrForbidden("authentication required (run `hanzo login`)")
@@ -297,7 +299,7 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
// customer-org admin only"). A validated OrgAdmin who is not a SuperAdmin is refused
// 403 here, closing the fleet-restart DoS lever (RED H1) while the read board stays
// on the broader guard.
func operatorGuard(s *cloud.Service[state], h zip.Handler) zip.Handler {
func fleetOperatorGuard(s *cloud.Service[fleetState], h zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if !principal.Validated(c) {
return zip.ErrForbidden("authentication required (run `hanzo login`)")
@@ -317,7 +319,7 @@ func operatorGuard(s *cloud.Service[state], h zip.Handler) zip.Handler {
// from the validated principal — never a client header — so it cannot be widened by
// a forged X-Org-Id. An OrgAdmin whose org owns no scanned namespace gets an empty
// set (an empty board / a clean 404), never another org's data.
func scopedNamespaces(s *cloud.Service[state], ctx context.Context, c *zip.Ctx) []string {
func scopedNamespaces(s *cloud.Service[fleetState], ctx context.Context, c *zip.Ctx) []string {
all := discoverNamespaces(s, ctx)
if principal.IsSuperAdmin(c) {
return all
@@ -340,7 +342,7 @@ func scopedNamespaces(s *cloud.Service[state], ctx context.Context, c *zip.Ctx)
// in prod-first scan order. It composes the AUTH confinement (scopedNamespaces) with
// the optional env SELECTION — orthogonal: env can only narrow WITHIN the caller's
// own authorized set, never reach outside it.
func targetNamespaces(s *cloud.Service[state], ctx context.Context, c *zip.Ctx) []string {
func targetNamespaces(s *cloud.Service[fleetState], ctx context.Context, c *zip.Ctx) []string {
nss := scopedNamespaces(s, ctx, c)
env := strings.TrimSpace(c.Query("env"))
if env == "" {
@@ -400,8 +402,8 @@ type AppView struct {
// listApps returns the whole fleet's drift board, ordered deterministically
// (org, app, env). Optional narrowing filters mirror the platform board:
// ?env=, ?health=, ?drift=1 (only rows that are actually drifting), ?org=.
func listApps(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
func listFleet(s *cloud.Service[fleetState], c *zip.Ctx) error {
if err := fleetReady(s); err != nil {
return err
}
// Observe only the namespaces this caller is authorized for: the whole fleet for
@@ -414,7 +416,7 @@ func listApps(s *cloud.Service[state], c *zip.Ctx) error {
}
env := strings.TrimSpace(c.Query("env"))
health := strings.TrimSpace(c.Query("health"))
fleetHealth := strings.TrimSpace(c.Query("health"))
org := strings.TrimSpace(c.Query("org"))
driftOnly := c.Query("drift") == "1" || c.Query("drift") == "true"
@@ -424,7 +426,7 @@ func listApps(s *cloud.Service[state], c *zip.Ctx) error {
if env != "" && v.Env != env {
continue
}
if health != "" && v.Health != health {
if fleetHealth != "" && v.Health != fleetHealth {
continue
}
if org != "" && v.Org != org {
@@ -453,11 +455,11 @@ func listApps(s *cloud.Service[state], c *zip.Ctx) error {
// getApp returns one service row by CR name. Scans the platform namespaces in
// env order (main→test→dev) and returns the first match, so the bare app name
// resolves to production by default.
func getApp(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
func getFleetApp(s *cloud.Service[fleetState], c *zip.Ctx) error {
if err := fleetReady(s); err != nil {
return err
}
name := reqApp(c)
name := fleetReqApp(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("app must be a DNS-1123 label")
}
@@ -467,7 +469,7 @@ func getApp(s *cloud.Service[state], c *zip.Ctx) error {
if apierrors.IsNotFound(err) {
continue
}
return k8sErr(s, "get", err)
return fleetK8sErr(s, "get", err)
}
repository, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "repository")
return c.JSON(http.StatusOK, observeCR(obj, ns, envOf(ns), runningTagOf(s, c.Context(), ns, name, repository)))
@@ -479,7 +481,7 @@ func getApp(s *cloud.Service[state], c *zip.Ctx) error {
// authorized set, per scopedNamespaces) and maps each to an AppView. A namespace
// that does not exist / is empty is skipped, never fatal (the board must still
// render the reachable namespaces).
func observeFleet(s *cloud.Service[state], ctx context.Context, namespaces []string) ([]AppView, error) {
func observeFleet(s *cloud.Service[fleetState], ctx context.Context, namespaces []string) ([]AppView, error) {
var views []AppView
for _, ns := range namespaces {
// Running state: one Deployment list per namespace, indexed by name — the
@@ -493,7 +495,7 @@ func observeFleet(s *cloud.Service[state], ctx context.Context, namespaces []str
if apierrors.IsNotFound(err) {
continue
}
return nil, k8sErr(s, "list", err)
return nil, fleetK8sErr(s, "list", err)
}
for i := range list.Items {
cr := &list.Items[i]
@@ -535,11 +537,11 @@ const restartedAtAnnotation = "hanzo.ai/restartedAt"
// Hanzo CD's selfHeal reconciles from universe git, so a tag change is still a git
// commit (the one way to change WHAT runs). A restart re-runs WHAT IS DECLARED with
// no drift for CD to revert — the honest, GitOps-compatible "redeploy this app".
func deploy(s *cloud.Service[state], c *zip.Ctx) error {
if err := ready(s); err != nil {
func deployFleet(s *cloud.Service[fleetState], c *zip.Ctx) error {
if err := fleetReady(s); err != nil {
return err
}
name := reqApp(c)
name := fleetReqApp(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("app must be a DNS-1123 label")
}
@@ -564,9 +566,9 @@ func deploy(s *cloud.Service[state], c *zip.Ctx) error {
if apierrors.IsNotFound(err) {
return zip.ErrNotFound("app " + name + " has no Deployment to restart in " + ns)
}
return k8sErr(s, "restart", err)
return fleetK8sErr(s, "restart", err)
}
s.Log.Info("paas rolling restart", "app", name, "namespace", ns, "restartedAt", restartedAt, "actor", principal.Owner(c))
s.Log.Info("fleet rolling restart", "app", name, "namespace", ns, "restartedAt", restartedAt, "actor", principal.Owner(c))
return c.JSON(http.StatusAccepted, map[string]any{
"ok": true, "app": name, "namespace": ns, "env": envOf(ns), "restartedAt": restartedAt,
})
@@ -577,7 +579,7 @@ func deploy(s *cloud.Service[state], c *zip.Ctx) error {
// release path (release.go) uses this — it is a machine rollout with no per-caller
// identity to confine — so it keeps the full scan. Returns a clean 404 when the App
// exists in none of them.
func resolveTarget(s *cloud.Service[state], ctx context.Context, name string) (string, error) {
func resolveTarget(s *cloud.Service[fleetState], ctx context.Context, name string) (string, error) {
return resolveTargetIn(s, ctx, name, scanOrder())
}
@@ -585,43 +587,22 @@ func resolveTarget(s *cloud.Service[state], ctx context.Context, name string) (s
// identity-scoped deploy/getApp pass the caller's authorized set so an OrgAdmin can
// never resolve (and thus restart/read) an app outside its own org. An empty list
// (an OrgAdmin owning no scanned namespace) resolves to a clean 404, never a leak.
func resolveTargetIn(s *cloud.Service[state], ctx context.Context, name string, namespaces []string) (string, error) {
func resolveTargetIn(s *cloud.Service[fleetState], ctx context.Context, name string, namespaces []string) (string, error) {
for _, ns := range namespaces {
if _, err := s.State.dyn.Resource(k8s.Apps).Namespace(ns).Get(ctx, name, metav1.GetOptions{}); err == nil {
return ns, nil
} else if !apierrors.IsNotFound(err) {
return "", k8sErr(s, "get", err)
return "", fleetK8sErr(s, "get", err)
}
}
return "", zip.ErrNotFound("app " + name + " not found in the platform namespaces")
}
// ── health ────────────────────────────────────────────────────────────────
// health is a REAL probe: it verifies the API server is reachable and that the
// App CRD — the kind the fleet runs on, and the kind the board is useless without
// — is served, and reports the actual state. 200 only when everything is ok; 503
// + the real reason otherwise (never status-theater). Not admin-gated — liveness
// must be probe-able by the platform/operator without a JWT.
func health(s *cloud.Service[state], c *zip.Ctx) error {
res := map[string]any{"service": "paas", "status": "ok"}
if s.State.dyn == nil {
res["status"], res["k8s"], res["error"] = "degraded", false, s.State.initErr
return c.JSON(http.StatusServiceUnavailable, res)
}
if _, err := s.State.dyn.Resource(k8s.Apps).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
res["status"], res["k8s"], res["crd"], res["error"] = "degraded", true, false, err.Error()
return c.JSON(http.StatusServiceUnavailable, res)
}
res["k8s"], res["crd"] = true, true
return c.JSON(http.StatusOK, res)
}
// ── k8s plumbing ────────────────────────────────────────────────────────────
func ready(s *cloud.Service[state]) error {
func fleetReady(s *cloud.Service[fleetState]) error {
if s.State.dyn == nil {
return zip.Errorf(http.StatusServiceUnavailable, "paas: kubernetes client not configured: %s", s.State.initErr)
return zip.Errorf(http.StatusServiceUnavailable, "platform fleet: kubernetes client not configured: %s", s.State.initErr)
}
return nil
}
@@ -629,7 +610,7 @@ func ready(s *cloud.Service[state]) error {
// k8sErr maps a raw API error to an honest gateway-level error. RBAC denials name
// the missing access so the operator knows exactly what to grant the cloud service
// account (get/list on apps.hanzo.ai). Mirrors ml.k8sErr.
func k8sErr(s *cloud.Service[state], op string, err error) error {
func fleetK8sErr(s *cloud.Service[fleetState], op string, err error) error {
s.Log.Error("k8s op failed", "op", op, "resource", k8s.Apps.Resource, "err", err)
if apierrors.IsForbidden(err) {
return zip.Errorf(http.StatusBadGateway,
@@ -642,7 +623,7 @@ func k8sErr(s *cloud.Service[state], op string, err error) error {
// newDynamic builds the dynamic client from the in-cluster service account,
// falling back to KUBECONFIG / ~/.kube/config for local/dev — identical to
// clients/ml.newDynamic.
func newDynamic() (dynamic.Interface, error) {
func newFleetDynamic() (dynamic.Interface, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
@@ -652,17 +633,17 @@ func newDynamic() (dynamic.Interface, error) {
return nil, fmt.Errorf("no in-cluster config and no kubeconfig: %w", err)
}
}
cfg.UserAgent = userAgent
cfg.UserAgent = fleetUserAgent
return dynamic.NewForConfig(cfg)
}
// ── pure mapping helpers (unit-tested without a cluster) ─────────────────────
func reqApp(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("app"))) }
func fleetReqApp(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("app"))) }
// scanOrder returns the platform namespaces in a stable env order (main first),
// so a bare app-name read/deploy resolves to production before test/dev.
// Every entry MUST classify under nsClass (asserted in paas_test.go) — nsClass is
// Every entry MUST classify under nsClass (asserted in fleet_test.go) — nsClass is
// the one place that decides what a namespace means, and a namespace listed here
// but unclassified would render rows with an empty tenant, i.e. rows no OrgAdmin
// could ever be confined to. hanzo-mainnet was missing until 2026-07-25, so its
@@ -720,12 +701,12 @@ func nonEmpty(in []string) []string {
// inventory.ts healthFromDeployment semantics: desired 0 ⇒ yellow (intentionally
// scaled to zero, not unhealthy), ready>=desired ⇒ green, some ready ⇒ yellow,
// none ⇒ red. Empty when the status carries no replica counts yet.
func healthFromStatus(status map[string]any) string {
desired, hasDesired := nestedInt(status, "replicas")
ready, _ := nestedInt(status, "readyReplicas")
func fleetHealthFromStatus(status map[string]any) string {
desired, hasDesired := fleetNestedInt(status, "replicas")
fleetReady, _ := fleetNestedInt(status, "readyReplicas")
if !hasDesired {
// Fall back to availableReplicas if the operator only reports that.
if avail, ok := nestedInt(status, "availableReplicas"); ok {
if avail, ok := fleetNestedInt(status, "availableReplicas"); ok {
if avail > 0 {
return "green"
}
@@ -736,10 +717,10 @@ func healthFromStatus(status map[string]any) string {
if desired == 0 {
return "yellow"
}
if ready >= desired {
if fleetReady >= desired {
return "green"
}
if ready > 0 {
if fleetReady > 0 {
return "yellow"
}
return "red"
@@ -772,7 +753,7 @@ func observeCR(obj *unstructured.Unstructured, namespace, env, runningTag string
DeclaredTag: declaredTag,
RunningTag: runningTag,
LatestTag: "", // GH-release reader is a follow-up phase (release-reader.ts)
Health: healthFromStatus(status),
Health: fleetHealthFromStatus(status),
Phase: phase,
Cluster: "hanzo-k8s",
Namespace: namespace,
@@ -786,7 +767,7 @@ func observeCR(obj *unstructured.Unstructured, namespace, env, runningTag string
// later matches against the CR's declared repo, in runningTagOf; here we index by
// name and keep the first container's tag as the default). Best-effort: any list
// error yields an empty map so the board still renders declared/health/phase.
func runningTagsIn(s *cloud.Service[state], ctx context.Context, namespace string) map[string]string {
func runningTagsIn(s *cloud.Service[fleetState], ctx context.Context, namespace string) map[string]string {
out := map[string]string{}
list, err := s.State.dyn.Resource(k8s.Deployments).Namespace(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
@@ -805,7 +786,7 @@ func runningTagsIn(s *cloud.Service[state], ctx context.Context, namespace strin
// whose image repo equals the CR's declared repo (so a sidecar like replicate/otel
// is never mistaken for the app), falling back to the first container. Mirrors
// inventory.ts runningTagFromDeployment. Best-effort: any error → "".
func runningTagOf(s *cloud.Service[state], ctx context.Context, namespace, name, declaredRepository string) string {
func runningTagOf(s *cloud.Service[fleetState], ctx context.Context, namespace, name, declaredRepository string) string {
d, err := s.State.dyn.Resource(k8s.Deployments).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return ""
@@ -815,7 +796,7 @@ func runningTagOf(s *cloud.Service[state], ctx context.Context, namespace, name,
// nestedInt reads an integer-valued key from an unstructured map, tolerating the
// int64/float64 the k8s decoder may produce.
func nestedInt(m map[string]any, key string) (int, bool) {
func fleetNestedInt(m map[string]any, key string) (int, bool) {
if m == nil {
return 0, false
}
@@ -1,7 +1,7 @@
package paas
package platform
// authz_test.go — the IAM authorization + tenant-confinement contract for the
// /v1/paas fleet board, the twin of clients/platform/runner_test.go. Every route
// /v1/platform/fleet board, the twin of clients/platform/runner_test.go. Every route
// is now authorized off ONE IAM identity (SuperAdmin or org-confined OrgAdmin);
// these tests pin that a plain login is refused, an OrgAdmin sees ONLY its own
// org's namespaces, a SuperAdmin sees the fleet, and the deploy path performs a
@@ -50,17 +50,17 @@ func deploymentObj(name, ns, image string) *unstructured.Unstructured {
// paasApp builds a paas Service over a fake cluster seeded with objs, mounts the
// real routes (so the live guard runs), and returns the app to drive.
func paasApp(t *testing.T, objs ...runtime.Object) (*zip.App, *cloud.Service[state]) {
func paasApp(t *testing.T, objs ...runtime.Object) (*zip.App, *cloud.Service[fleetState]) {
t.Helper()
s := fakeService(objs...)
s.Base.Log = luxlog.New("test")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
routes(app, s)
fleetRoutes(app, s)
return app, s
}
// doAs drives a request as a validated IAM principal with the given role headers.
func doAs(t *testing.T, app *zip.App, method, path, user, org string, orgAdmin, superAdmin bool) (int, []byte) {
func fleetDoAs(t *testing.T, app *zip.App, method, path, user, org string, orgAdmin, superAdmin bool) (int, []byte) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if user != "" {
@@ -116,7 +116,7 @@ func appsTotal(t *testing.T, body []byte) int {
// serves an anonymous caller, exactly as before the broadening.
func TestGuard_Unauthenticated_403(t *testing.T) {
app, _ := paasApp(t, fleet()...)
if code, _ := doAs(t, app, http.MethodGet, "/v1/paas/apps", "", "", false, false); code != http.StatusForbidden {
if code, _ := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "", "", false, false); code != http.StatusForbidden {
t.Fatalf("anonymous: want 403, got %d", code)
}
}
@@ -125,7 +125,7 @@ func TestGuard_Unauthenticated_403(t *testing.T) {
// necessary but not sufficient — the board is an operator surface.
func TestGuard_NonAdminMember_403(t *testing.T) {
app, _ := paasApp(t, fleet()...)
if code, _ := doAs(t, app, http.MethodGet, "/v1/paas/apps", "u-plain", "hanzo", false, false); code != http.StatusForbidden {
if code, _ := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "u-plain", "hanzo", false, false); code != http.StatusForbidden {
t.Fatalf("plain member: want 403, got %d", code)
}
}
@@ -135,7 +135,7 @@ func TestGuard_NonAdminMember_403(t *testing.T) {
// An OrgAdmin of the platform org sees its own org's whole board (all hanzo* ns).
func TestListApps_OrgAdmin_SeesOwnOrg(t *testing.T) {
app, _ := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodGet, "/v1/paas/apps", "z-uuid", "hanzo", true, false)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "z-uuid", "hanzo", true, false)
if code != http.StatusOK {
t.Fatalf("hanzo org-admin: want 200, got %d (%s)", code, body)
}
@@ -148,7 +148,7 @@ func TestListApps_OrgAdmin_SeesOwnOrg(t *testing.T) {
// platform fleet. This is the cross-tenant-leak guard.
func TestListApps_ForeignOrgAdmin_EmptyBoard(t *testing.T) {
app, _ := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodGet, "/v1/paas/apps", "acme-admin", "acme", true, false)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "acme-admin", "acme", true, false)
if code != http.StatusOK {
t.Fatalf("acme org-admin: want 200, got %d (%s)", code, body)
}
@@ -162,7 +162,7 @@ func TestListApps_ForeignOrgAdmin_EmptyBoard(t *testing.T) {
// keyed on the validated org, before the query filter runs.
func TestListApps_ForeignOrgAdmin_QueryCannotWiden(t *testing.T) {
app, _ := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodGet, "/v1/paas/apps?org=hanzoai", "acme-admin", "acme", true, false)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet?org=hanzoai", "acme-admin", "acme", true, false)
if code != http.StatusOK {
t.Fatalf("want 200, got %d (%s)", code, body)
}
@@ -174,7 +174,7 @@ func TestListApps_ForeignOrgAdmin_QueryCannotWiden(t *testing.T) {
// A SuperAdmin sees the whole fleet regardless of its own org.
func TestListApps_SuperAdmin_SeesFleet(t *testing.T) {
app, _ := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodGet, "/v1/paas/apps", "root", "admin", false, true)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "root", "admin", false, true)
if code != http.StatusOK {
t.Fatalf("superadmin: want 200, got %d (%s)", code, body)
}
@@ -189,7 +189,7 @@ func TestListApps_SuperAdmin_SeesFleet(t *testing.T) {
// existence leak.
func TestGetApp_ForeignOrgAdmin_404(t *testing.T) {
app, _ := paasApp(t, fleet()...)
if code, _ := doAs(t, app, http.MethodGet, "/v1/paas/apps/iam", "acme-admin", "acme", true, false); code != http.StatusNotFound {
if code, _ := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet/iam", "acme-admin", "acme", true, false); code != http.StatusNotFound {
t.Fatalf("acme admin reading hanzo/iam: want 404, got %d", code)
}
}
@@ -197,7 +197,7 @@ func TestGetApp_ForeignOrgAdmin_404(t *testing.T) {
// The platform OrgAdmin reads its own app row.
func TestGetApp_OrgAdmin_200(t *testing.T) {
app, _ := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodGet, "/v1/paas/apps/iam", "z-uuid", "hanzo", true, false)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet/iam", "z-uuid", "hanzo", true, false)
if code != http.StatusOK {
t.Fatalf("hanzo admin reading iam: want 200, got %d (%s)", code, body)
}
@@ -207,7 +207,7 @@ func TestGetApp_OrgAdmin_200(t *testing.T) {
// restartedAt reads the pod-template restart annotation off the live Deployment in
// the fake, "" when absent.
func restartedAt(t *testing.T, s *cloud.Service[state], ns, name string) string {
func restartedAt(t *testing.T, s *cloud.Service[fleetState], ns, name string) string {
t.Helper()
obj, err := s.State.dyn.Resource(k8s.Deployments).Namespace(ns).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
@@ -224,7 +224,7 @@ func restartedAt(t *testing.T, s *cloud.Service[state], ns, name string) string
// closed: no hanzo-org-admin JWT can loop-restart prod iam/kms/gateway.
func TestDeploy_OrgAdmin_403_Platform(t *testing.T) {
app, s := paasApp(t, fleet()...)
code, _ := doAs(t, app, http.MethodPost, "/v1/paas/apps/iam/deploy?env=main", "z-uuid", "hanzo", true, false)
code, _ := fleetDoAs(t, app, http.MethodPost, "/v1/platform/fleet/iam/deploy?env=main", "z-uuid", "hanzo", true, false)
if code != http.StatusForbidden {
t.Fatalf("hanzo ORG-admin (not superadmin) deploy iam: want 403, got %d", code)
}
@@ -236,7 +236,7 @@ func TestDeploy_OrgAdmin_403_Platform(t *testing.T) {
// A plain member is refused the mutate too (necessary-but-not-sufficient login).
func TestDeploy_NonAdmin_403(t *testing.T) {
app, _ := paasApp(t, fleet()...)
if code, _ := doAs(t, app, http.MethodPost, "/v1/paas/apps/iam/deploy?env=main", "u", "hanzo", false, false); code != http.StatusForbidden {
if code, _ := fleetDoAs(t, app, http.MethodPost, "/v1/platform/fleet/iam/deploy?env=main", "u", "hanzo", false, false); code != http.StatusForbidden {
t.Fatalf("plain member deploy: want 403, got %d", code)
}
}
@@ -248,7 +248,7 @@ func TestDeploy_SuperAdmin_RollingRestart(t *testing.T) {
if got := restartedAt(t, s, "hanzo", "iam"); got != "" {
t.Fatalf("precondition: iam should have no restart stamp, got %q", got)
}
code, body := doAs(t, app, http.MethodPost, "/v1/paas/apps/iam/deploy?env=main", "root", "admin", false, true)
code, body := fleetDoAs(t, app, http.MethodPost, "/v1/platform/fleet/iam/deploy?env=main", "root", "admin", false, true)
if code != http.StatusAccepted {
t.Fatalf("superadmin deploy iam: want 202, got %d (%s)", code, body)
}
@@ -272,7 +272,7 @@ func TestDeploy_SuperAdmin_RollingRestart(t *testing.T) {
// RED L1: a deploy with NO ?env is refused 400 — it never silently targets prod.
func TestDeploy_RequiresExplicitEnv(t *testing.T) {
app, s := paasApp(t, fleet()...)
code, _ := doAs(t, app, http.MethodPost, "/v1/paas/apps/iam/deploy", "root", "admin", false, true)
code, _ := fleetDoAs(t, app, http.MethodPost, "/v1/platform/fleet/iam/deploy", "root", "admin", false, true)
if code != http.StatusBadRequest {
t.Fatalf("deploy with no env: want 400, got %d", code)
}
@@ -284,7 +284,7 @@ func TestDeploy_RequiresExplicitEnv(t *testing.T) {
// ?env selects the namespace: a superadmin restarts the test-env app by naming it.
func TestDeploy_SuperAdmin_EnvSelectsNamespace(t *testing.T) {
app, s := paasApp(t, fleet()...)
code, body := doAs(t, app, http.MethodPost, "/v1/paas/apps/chat/deploy?env=test", "root", "admin", false, true)
code, body := fleetDoAs(t, app, http.MethodPost, "/v1/platform/fleet/chat/deploy?env=test", "root", "admin", false, true)
if code != http.StatusAccepted {
t.Fatalf("deploy chat @test: want 202, got %d (%s)", code, body)
}
@@ -307,3 +307,25 @@ func TestNsOrg(t *testing.T) {
}
}
}
// TestFleetListWithoutK8sIs503Not500 pins the production bug the paas→platform
// fold carried its fix for: GET /v1/platform/fleet (was /v1/paas/apps) 500'd for a
// valid org-admin when the dynamic client was nil — no kubeconfig, or an apiserver
// the kubeconfig could not reach. listFleet now calls fleetReady first, so an
// unconfigured cluster fails CLOSED with a 503 the caller can read, never a
// nil-deref the caller cannot. Reproduces the exact caller (org-admin, own org).
func TestFleetListWithoutK8sIs503Not500(t *testing.T) {
// A service with NO objects and, deliberately, a nil dyn client — the state a
// pod in a cluster it cannot reach boots into.
s := fakeService()
s.State.dyn = nil
s.State.initErr = "no kubeconfig"
s.Base.Log = luxlog.New("test")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
fleetRoutes(app, s)
code, body := fleetDoAs(t, app, http.MethodGet, "/v1/platform/fleet", "acme-admin", "acme", true, false)
if code != http.StatusServiceUnavailable {
t.Fatalf("nil k8s client: got %d, want 503 (the 500 this fold fixes); body=%s", code, body)
}
}
@@ -1,4 +1,4 @@
package paas
package platform
import (
"context"
@@ -14,7 +14,7 @@ import (
// TestAppsGVR pins the operator App CR identity — the kind the fleet runs on. A
// typo here silently blinds the whole board, exactly as reading only `services`
// did (7 rows rendered for a 69-app fleet).
func TestAppsGVR(t *testing.T) {
func TestFleetAppsGVR(t *testing.T) {
want := schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "apps"}
if k8s.Apps != want {
t.Fatalf("k8s.Apps = %v, want %v", k8s.Apps, want)
@@ -13,7 +13,7 @@
// - an IDEMPOTENT same-image merge-patch on a low-risk service (pricing) proves
// the write path reaches the operator WITHOUT changing what runs (same tag =
// no rollout). It never mutates a tag, so it cannot perturb live state.
package paas
package platform
import (
"context"
@@ -30,18 +30,18 @@ import (
const itService = "pricing" // low-risk service CLAUDE.md already validated
func itClient(t *testing.T) *cloud.Service[state] {
func itClient(t *testing.T) *cloud.Service[fleetState] {
t.Helper()
if os.Getenv("PAAS_IT") != "1" {
t.Skip("set PAAS_IT=1 to run the live-cluster integration probe")
}
dyn, err := newDynamic()
dyn, err := newFleetDynamic()
if err != nil {
t.Fatalf("newDynamic (needs a live KUBECONFIG): %v", err)
}
return &cloud.Service[state]{
return &cloud.Service[fleetState]{
Base: cloud.NewBase(cloud.Deps{Logger: luxlog.New("paas-it")}, "paas"),
State: state{dyn: dyn},
State: fleetState{dyn: dyn},
}
}
@@ -1,4 +1,4 @@
package paas
package platform
import (
"github.com/hanzoai/cloud/clients/k8s"
@@ -167,7 +167,7 @@ func TestRepoFromRepository(t *testing.T) {
// TestHealthFromStatus mirrors inventory.ts healthFromDeployment semantics but
// off the operator-reconciled Service status.
func TestHealthFromStatus(t *testing.T) {
func TestFleetHealthFromStatus(t *testing.T) {
cases := []struct {
name string
status map[string]any
@@ -185,7 +185,7 @@ func TestHealthFromStatus(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := healthFromStatus(tc.status); got != tc.want {
if got := fleetHealthFromStatus(tc.status); got != tc.want {
t.Errorf("healthFromStatus(%v) = %q, want %q", tc.status, got, tc.want)
}
})
+1 -1
View File
@@ -1,7 +1,6 @@
package platform
import (
"sync"
"bytes"
"context"
"encoding/json"
@@ -10,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"github.com/hanzoai/cloud"
+3 -3
View File
@@ -3,7 +3,7 @@
// A user application is deployed by writing an operator hanzo.ai/v1 `App`
// CR into the caller's OWN tenant namespace; the Hanzo operator reconciles it
// into a Deployment + Service + Ingress (+ HPA/PDB) on DOKS. cloud never
// reimplements a deployer — it writes one CR, exactly like clients/paas
// reimplements a deployer — it writes one CR, exactly like the fleet board
// reads system CRs, but here every object lives in `tenant-<org>` where the
// org is the gateway-minted, IAM-VALIDATED tenant (c.Org()), never a value from
// the request body or path. That derivation is the whole cross-tenant isolation
@@ -13,7 +13,7 @@
// Builds (git-source apps) launch an in-cluster BuildKit Job (the arcd model,
// buildkit-job.ts) via client-go — no GitHub builders. When the cluster / CI
// prerequisites are absent the subsystem fails CLOSED with the real reason
// (never status-theater), matching paas.
// (never status-theater), matching the fleet board.
package platform
import (
@@ -176,7 +176,7 @@ type k8sClient struct {
}
// newK8sClient builds the dynamic client from the in-cluster service account,
// falling back to KUBECONFIG for local/dev — identical to paas.newDynamic.
// falling back to KUBECONFIG for local/dev — identical to newFleetDynamic.
func newK8sClient(imagePrefix, buildNS string) *k8sClient {
c := &k8sClient{imagePrefix: imagePrefix, buildNS: buildNS, limits: newResourceLimits(), kmsSync: newKMSSyncConfig()}
cfg, err := rest.InClusterConfig()
@@ -1,11 +1,11 @@
package paas
package platform
// fleet.go — the in-process fleet-observation seam.
//
// The admin god-view (/v1/admin/products + the overview drift KPIs, clients/admin) needs the
// SAME operator-App-CR + Deployment observation the /v1/paas/apps board already computes
// SAME operator-App-CR + Deployment observation the /v1/platform/fleet board already computes
// (observeFleet → observeCR → drift.go). Rather than fork a SECOND k8s dynamic client and a
// SECOND drift model into the admin subsystem, paas PUBLISHES its observer here once at Mount
// SECOND drift model into the admin subsystem, the board PUBLISHES its observer here once at Mount
// and admin RESOLVES it at request time — the identical in-process-seam pattern
// finance.Current() (the money plane) and transport.SetApp (the commerce plane) use for
// a co-resident host handing a narrow capability to a sibling subsystem.
@@ -24,11 +24,11 @@ import (
// Fleet is the read-only fleet observation a co-resident consumer folds over. Observe returns
// the whole platform fleet (every scanned namespace: hanzo/-testnet/-devnet) as AppView drift
// rows — declared vs running tag, operator-reconciled health/phase, and the drift verdict.
// Ready reports whether the k8s client resolved (else reason is the init error the /v1/paas/
// health route already surfaces).
// Ready reports whether the k8s client resolved (else reason is the init error /v1/platform/health
// already surfaces).
type Fleet interface {
Observe(ctx context.Context) ([]AppView, error)
Ready() (ready bool, reason string)
Ready() (fleetReady bool, reason string)
}
var (
@@ -52,10 +52,10 @@ func CurrentFleet() Fleet {
return publishedFleet
}
// fleetObserver binds the seam to the mounted paas state (its dynamic k8s client). It is the
// fleetObserver binds the seam to the mounted fleet state (its dynamic k8s client). It is the
// ONLY implementation; it reuses observeFleet verbatim so the admin board and the
// /v1/paas/apps board can never disagree about what the fleet is.
type fleetObserver struct{ s *cloud.Service[state] }
// /v1/platform/fleet board can never disagree about what the fleet is.
type fleetObserver struct{ s *cloud.Service[fleetState] }
// Observe reads the whole platform fleet across scanOrder() (prod-first). A nil client yields
// an empty fleet (Ready carries the reason); a per-namespace absence is non-fatal inside
+29 -6
View File
@@ -6,7 +6,7 @@
//
// Relationship to the sibling subsystems:
//
// - clients/paas (/v1/paas) — the ADMIN fleet drift board: observes +
// - fleet.go (/v1/platform/fleet) — the ADMIN fleet drift board: observes +
// deploys SYSTEM Service CRs across the platform namespaces, SuperAdmin
// only. It answers "what is the fleet running, and roll a tag."
// - clients/projects (/v1/projects) — per-org STATIC sites (S3 hosting).
@@ -36,6 +36,9 @@ import (
"errors"
"fmt"
"net/http"
"github.com/hanzoai/cloud/clients/k8s"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"os"
"path/filepath"
"regexp"
@@ -138,6 +141,13 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// once the catalog licenses "platform" to a tier. See clients/entitlements.
routes(app, s)
// The fleet board (/v1/platform/fleet) — the platform's view of its OWN service
// tier, folded in from what used to be the separate /v1/paas product. It carries
// its own dynamic client because it observes the whole platform tier, not one
// tenant namespace; the routes are siblings under the one /v1/platform prefix.
fb := cloud.NewBase(deps, "platform")
fleetRoutes(app, &cloud.Service[fleetState]{Base: fb, State: buildFleet(fb)})
// The cloud's own embedded-git apex is a trusted build source (clients/git
// serves repos at this host), so a self-hosted-git app builds with no env.
selfGitHost = strings.ToLower(strings.TrimSpace(deps.Domain))
@@ -293,7 +303,7 @@ func tenant(s *cloud.Service[state], c *zip.Ctx) (string, bool) {
// ── HTTP views (the published contract; mirrors the Goa design result types) ──
type repoView struct {
type gitSource struct {
URL string `json:"url,omitempty"`
Branch string `json:"branch,omitempty"`
Provider string `json:"provider,omitempty"`
@@ -313,7 +323,7 @@ type appView struct {
Description string `json:"description,omitempty"`
Environment string `json:"environment"`
Source string `json:"source"`
Repo repoView `json:"repo"`
Repo gitSource `json:"repo"`
Image imageView `json:"image"`
BuildType string `json:"buildType,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
@@ -351,7 +361,7 @@ func toAppView(a Application) appView {
return appView{
ID: a.ID, Org: a.Org, ProjectID: a.ProjectID, Slug: a.Slug, Name: a.Name,
Description: a.Description, Environment: a.Environment, Source: a.Source,
Repo: repoView{URL: a.RepoURL, Branch: a.RepoBranch, Provider: a.RepoProvider},
Repo: gitSource{URL: a.RepoURL, Branch: a.RepoBranch, Provider: a.RepoProvider},
Image: imageView{Repository: a.ImageRepo, Tag: a.ImageTag},
BuildType: a.BuildType, Dockerfile: a.Dockerfile, Env: env, Port: a.Port,
Replicas: a.Replicas, StorageGB: a.StorageGB, Domains: domains, Status: a.Status, Namespace: a.Namespace,
@@ -569,7 +579,7 @@ func createApp(s *cloud.Service[state], c *zip.Ctx) error {
RepoProvider: providerFromURL(body.Repo.URL), ImageRepo: strings.TrimSpace(body.Image.Repository), ImageTag: strings.TrimSpace(body.Image.Tag),
BuildType: buildType, Dockerfile: strings.TrimSpace(body.Dockerfile), Port: portOr(body.Port), Replicas: s.State.k8s.limits.clampReplicas(body.Replicas),
StorageGB: s.State.k8s.limits.clampStorage(body.StorageGB),
EnvJSON: string(envJSON), DomainsJSON: string(domainsJSON), Status: "draft", Namespace: tenantNamespace(org),
EnvJSON: string(envJSON), DomainsJSON: string(domainsJSON), Status: "draft", Namespace: tenantNamespace(org),
CreatedAt: now, UpdatedAt: now,
}
if err := s.State.store.CreateApplication(c.Context(), a); err != nil {
@@ -725,6 +735,13 @@ func setEnv(s *cloud.Service[state], c *zip.Ctx) error {
// health is a REAL probe: 200 when the metadata store is open AND the cluster is
// reachable; 503 + the real reason otherwise (never status-theater). Not
// admin-gated — liveness must be probe-able without a JWT.
// health probes the cluster for real. A constructed client proves nothing — it is
// built from a kubeconfig, not from a reachable apiserver — so this asks the one
// question the deploy path depends on: can we LIST the operator App CRD? That
// answers reachability AND CRD presence in a single bounded call (Limit 1). The
// probe came from the folded /v1/paas/health, which is why it survived the fold and
// the nil-check it replaced did not: two health routes cannot both be the truth,
// and a nil-check reports "ok" while every deploy 502s.
func health(s *cloud.Service[state], c *zip.Ctx) error {
res := map[string]any{"service": "platform", "status": "ok", "k8s": s.State.k8s.dyn != nil}
if s.State.k8s.dyn == nil {
@@ -732,6 +749,12 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
res["error"] = s.State.k8s.initErr
return c.JSON(http.StatusServiceUnavailable, res)
}
if _, err := s.State.k8s.dyn.Resource(k8s.Apps).Namespace(scanOrder()[0]).
List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
res["status"], res["crd"], res["error"] = "degraded", false, err.Error()
return c.JSON(http.StatusServiceUnavailable, res)
}
res["crd"] = true
return c.JSON(http.StatusOK, res)
}
@@ -855,7 +878,7 @@ func getenv(key, dflt string) string {
return dflt
}
// Shutdown closes the platform store. Idempotent. Mirrors the projects/paas
// Shutdown closes the platform store. Idempotent. Mirrors the projects
// Shutdown contract so the serve layer releases subsystem resources uniformly.
func Shutdown() error {
if mounted == nil || mounted.State.store == nil {
+2 -2
View File
@@ -289,7 +289,7 @@ func releaseFor(s *cloud.Service[state], repoURL, sha, image, tag, dockerfile, b
// step, reached only AFTER the tag receipt is minted (build + smoke passed).
//
// ONE WRITER: patch the operator hanzo.ai/v1 Service CR's spec.image
// (cloud.OnServiceRelease → clients/paas releaseService) and let the operator
// (cloud.OnServiceRelease → rollout.go releaseService) and let the operator
// reconcile the Deployment. No ArgoCD, no repository_dispatch, no git round-trip.
//
// It used to write TWICE — the CR patch plus a repository_dispatch mirror at
@@ -307,7 +307,7 @@ func releaseFor(s *cloud.Service[state], repoURL, sha, image, tag, dockerfile, b
// live, and a release that says otherwise is worse than one that fails.
func rolloutRelease(s *cloud.Service[state], ctx context.Context, image, sha string) error {
if !cloud.ServiceReleaserRegistered() {
return fmt.Errorf("paas control plane not co-resident: no CR releaser registered, image %s is tagged but NOT live", image)
return fmt.Errorf("fleet board not co-resident: no CR releaser registered, image %s is tagged but NOT live", image)
}
if err := cloud.OnServiceRelease(ctx, cloud.ServiceReleaseEvent{Service: releaseServiceName, Image: image, SHA: sha}); err != nil {
return fmt.Errorf("roll out %s via operator CR patch: %w", releaseServiceName, err)
@@ -1,6 +1,6 @@
// release.go — the first-party release seam. build.go's RegisterServiceReleaser is
// the inversion that lets a build-completion path (clients/platform/release.go, or
// any package-cloud caller) request a rollout with no cloud⇄paas import cycle.
// any package-cloud caller) request a rollout with no cloud⇄platform import cycle.
//
// The App CRs in the platform namespaces are declared in universe git
// (infra/k8s/operator/crs/) and reconciled by Hanzo CD with selfHeal, so a direct
@@ -8,7 +8,7 @@
// and names the one way to roll a tag: commit it to the manifest. The clean-semver
// gate (splitReleaseImage) still validates the request so a caller gets an honest,
// specific error.
package paas
package platform
import (
"context"
@@ -52,8 +52,8 @@ func splitReleaseImage(image string) (repository, tag string, err error) {
// not), and returns the refusal naming the one way to release it: commit the tag
// to the manifest. Returns the resolved namespace and semver tag for the caller's
// log; changed is always false.
func releaseService(s *cloud.Service[state], ctx context.Context, service, image string) (ns, tag string, changed bool, err error) {
if e := ready(s); e != nil {
func releaseService(s *cloud.Service[fleetState], ctx context.Context, service, image string) (ns, tag string, changed bool, err error) {
if e := fleetReady(s); e != nil {
return "", "", false, e
}
service = strings.ToLower(strings.TrimSpace(service))
@@ -72,11 +72,11 @@ func releaseService(s *cloud.Service[state], ctx context.Context, service, image
}
// registerReleaser wires the first-party release seam (build.go's
// RegisterServiceReleaser inversion) to this mounted paas service. Called once
// RegisterServiceReleaser inversion) to this mounted fleet board. Called once
// from routes, so a release requested anywhere in the binary (cloud's own
// self-release, or a future in-process first-party builder) reaches the SAME
// refusal with no cloud⇄paas import cycle.
func registerReleaser(s *cloud.Service[state]) {
// refusal with no cloud⇄platform import cycle.
func registerReleaser(s *cloud.Service[fleetState]) {
cloud.RegisterServiceReleaser(func(ctx context.Context, ev cloud.ServiceReleaseEvent) error {
_, _, _, err := releaseService(s, ctx, ev.Service, ev.Image)
return err
@@ -1,4 +1,4 @@
package paas
package platform
import (
"context"
@@ -29,7 +29,7 @@ func appCRObj(name, ns, repo, tag string) *unstructured.Unstructured {
// fakeService builds a hermetic paas Service backed by an in-memory fake dynamic
// client seeded with objs — the release path is exercised without a real cluster.
func fakeService(objs ...runtime.Object) *cloud.Service[state] {
func fakeService(objs ...runtime.Object) *cloud.Service[fleetState] {
scheme := runtime.NewScheme()
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
k8s.Apps: "AppList",
@@ -40,14 +40,14 @@ func fakeService(objs ...runtime.Object) *cloud.Service[state] {
// set falls back to the first-party set, which is what these tests assert on.
k8s.Namespaces: "NamespaceList",
}, objs...)
return &cloud.Service[state]{
return &cloud.Service[fleetState]{
Base: cloud.Base{Log: luxlog.New("test")},
State: state{dyn: dyn, scan: &nsCache{}},
State: fleetState{dyn: dyn, scan: &nsCache{}},
}
}
// declaredImage reads spec.image.{repository,tag} off the live App CR in the fake.
func declaredImage(t *testing.T, s *cloud.Service[state], ns, name string) (repo, tag, pull string) {
func declaredImage(t *testing.T, s *cloud.Service[fleetState], ns, name string) (repo, tag, pull string) {
t.Helper()
obj, err := s.State.dyn.Resource(k8s.Apps).Namespace(ns).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
@@ -159,7 +159,7 @@ func TestReleaseServiceMainFirst(t *testing.T) {
// TestReleaseServiceFailClosed proves that with no cluster client the release
// fails closed (never a fabricated success).
func TestReleaseServiceFailClosed(t *testing.T) {
s := &cloud.Service[state]{Base: cloud.Base{Log: luxlog.New("test")}, State: state{initErr: "no cluster (test)"}}
s := &cloud.Service[fleetState]{Base: cloud.Base{Log: luxlog.New("test")}, State: fleetState{initErr: "no cluster (test)"}}
if _, _, changed, err := releaseService(s, context.Background(), "cloud", "ghcr.io/hanzoai/cloud:v1.0.0"); err == nil || changed {
t.Fatalf("no cluster: got (changed=%v,err=%v), want (false, error)", changed, err)
}
+1 -1
View File
@@ -125,7 +125,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -41,7 +41,7 @@ type Prefs struct {
var errNotFound = errors.New("prefs: not found")
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -383,7 +383,7 @@ type catalog struct {
// fork (mattn+SQLCipher on cgo, pure-Go on !cgo). MaxOpenConns(1) serializes
// writes against the file lock without retry.
func openCatalog(path string) (*catalog, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+14 -8
View File
@@ -35,11 +35,12 @@ type LiveSite struct {
Org, Slug, Name, URL string
Repo, ForkedFrom string
UpdatedAt int64
// Official is the platform-gated first-party marker; Upstream/License credit
// the third-party work a demo was published from. Reported exactly as stored —
// this function never infers provenance, because a guessed badge is worse than
// no badge at all.
Official bool
// Upstream/License credit the third-party work a demo was published from.
// Reported exactly as stored — this function never infers provenance, because
// a guessed credit is worse than no credit at all.
//
// There is no authorship field: who published a site is Org, the account that
// paid for it, which the tenancy boundary enforces and no request can forge.
Upstream, License string
}
@@ -50,9 +51,14 @@ func LiveSites(ctx context.Context) ([]LiveSite, error) {
if s == nil || s.State.store == nil {
return nil, nil
}
// The visibility rule is applied HERE, in the query, not by the caller: this
// is the only cross-org read in the package, so a private or moderated
// project that never leaves it cannot be leaked by a consumer that forgot to
// filter. `status='live'` says it is serving; visibility says who may know.
rows, err := s.State.store.db.QueryContext(ctx,
`SELECT org, slug, name, live_url, repo_url, forked_from, updated_at, official, upstream, license
FROM projects WHERE status='live' ORDER BY updated_at DESC, id ASC`)
`SELECT org, slug, name, live_url, repo_url, forked_from, updated_at, upstream, license
FROM projects WHERE status='live' AND visibility='public' AND hidden=0
ORDER BY updated_at DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("catalog: list live sites: %w", err)
}
@@ -61,7 +67,7 @@ func LiveSites(ctx context.Context) ([]LiveSite, error) {
for rows.Next() {
var v LiveSite
if err := rows.Scan(&v.Org, &v.Slug, &v.Name, &v.URL, &v.Repo, &v.ForkedFrom, &v.UpdatedAt,
&v.Official, &v.Upstream, &v.License); err != nil {
&v.Upstream, &v.License); err != nil {
return nil, err
}
if v.URL == "" {
+185
View File
@@ -0,0 +1,185 @@
package projects
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// adminPatchProject PATCHes a project as a SuperAdmin. Moderation is the only
// admin-gated field on the project body, and asserting it needs a caller the
// ordinary `do` helper cannot make — so this is the ONE place that builds one.
func adminPatchProject(t *testing.T, app *zip.App, org, slug string, in map[string]any) projectView {
t.Helper()
b, _ := json.Marshal(in)
req := httptest.NewRequest(http.MethodPatch, "/v1/projects/"+slug, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u_admin")
req.Header.Set("X-User-IsAdmin", "true")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("admin patch: %v", err)
}
rb, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin patch want 200, got %d (%s)", resp.StatusCode, rb)
}
var out projectView
_ = json.Unmarshal(rb, &out)
return out
}
// community_test.go proves the visibility seam carries the RESOLVED answer, and
// that every path which can change it fires.
//
// The failure this guards against is asymmetric: a publish that fails to reach
// git leaves a project un-browsable, which is annoying. A RETRACTION that fails
// to reach git leaves a private or moderated project's source world-readable,
// which cannot be taken back. So the retraction cases are the ones with teeth.
// recorder captures what crossed the seam. Registration is process-global, so it
// restores the previous publisher on cleanup and guards the slice — subtests and
// any detached caller share it.
type recorder struct {
mu sync.Mutex
events []cloud.CommunityEvent
}
func record(t *testing.T) *recorder {
t.Helper()
r := &recorder{}
cloud.RegisterCommunityPublisher(func(_ context.Context, ev cloud.CommunityEvent) error {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, ev)
return nil
})
t.Cleanup(func() { cloud.RegisterCommunityPublisher(nil) })
return r
}
// last returns the most recent event for a slug, and whether there was one.
func (r *recorder) last(slug string) (cloud.CommunityEvent, bool) {
r.mu.Lock()
defer r.mu.Unlock()
for i := len(r.events) - 1; i >= 0; i-- {
if r.events[i].Slug == slug {
return r.events[i], true
}
}
return cloud.CommunityEvent{}, false
}
// TestPublishingReachesTheCanonicalRepo: creating a project emits its visibility,
// so a public project has a world-readable repo at git.hanzo.ai from the moment
// it exists — no second "share" step that can be forgotten.
func TestPublishingReachesTheCanonicalRepo(t *testing.T) {
app := mountApp(t)
rec := record(t)
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Board", "slug": "board", "description": "a board"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
ev, ok := rec.last("board")
if !ok {
t.Fatal("creating a project emitted no visibility event")
}
if ev.Org != "acme" || !ev.Listed {
t.Fatalf("event = org %q listed %v, want acme/true", ev.Org, ev.Listed)
}
if ev.Name != "Board" || ev.Description != "a board" {
t.Fatalf("the repo seed must carry the project's own name and description: %+v", ev)
}
}
// TestRetractionReachesTheCanonicalRepo is the one with teeth: both ways a
// project can stop being visible — the publisher going private, and the platform
// moderating it — must reach the source, or the code stays readable after the
// listing is gone.
func TestRetractionReachesTheCanonicalRepo(t *testing.T) {
t.Run("moderation", func(t *testing.T) {
app := mountApp(t)
rec := record(t)
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Spam", "slug": "spam"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
if ev, _ := rec.last("spam"); !ev.Listed {
t.Fatal("a new public project must be listed")
}
if p := adminPatchProject(t, app, "acme", "spam",
map[string]any{"hidden": true, "hiddenReason": "spam"}); !p.Hidden {
t.Fatal("admin hide did not take")
}
ev, ok := rec.last("spam")
if !ok {
t.Fatal("moderation emitted no event")
}
if ev.Listed {
t.Fatal("a moderated project's source stayed world-readable")
}
// And lifting it restores the publisher's own choice, in one write.
if p := adminPatchProject(t, app, "acme", "spam", map[string]any{"hidden": false}); p.Hidden {
t.Fatal("admin lift did not take")
}
if ev, _ := rec.last("spam"); !ev.Listed {
t.Fatal("lifting a moderation must restore the listing")
}
})
t.Run("publisher goes private", func(t *testing.T) {
app := mountApp(t)
rec := record(t)
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Secret", "slug": "secret"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
// Private is metered like every other paid surface. With no fee configured
// the gate is open, which is the free-tier operator default — so this
// asserts the SEAM, not the price.
code, body := do(t, app, http.MethodPatch, "/v1/projects/secret", "acme",
map[string]any{"visibility": "private"})
if code != http.StatusOK {
t.Fatalf("go private want 200, got %d (%s)", code, body)
}
var p projectView
_ = json.Unmarshal(body, &p)
if p.Visibility != VisibilityPrivate {
t.Fatalf("visibility = %q, want private", p.Visibility)
}
ev, ok := rec.last("secret")
if !ok {
t.Fatal("going private emitted no event")
}
if ev.Listed {
t.Fatal("a private project's source stayed world-readable")
}
})
}
// TestPublishSurvivesAnUnmountedGitPlane: the project row is the source of truth
// and a binary that does not host git must still be able to publish. The seam is
// best-effort by design, so an absent (or failing) subscriber cannot fail a
// create — the next update reconciles.
func TestPublishSurvivesAnUnmountedGitPlane(t *testing.T) {
app := mountApp(t)
cloud.RegisterCommunityPublisher(nil)
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Alone", "slug": "alone"}); code != http.StatusCreated {
t.Fatalf("create with no git plane want 201, got %d (%s)", code, body)
}
}
-108
View File
@@ -1,108 +0,0 @@
package projects
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"fmt"
"strings"
)
// firstparty.go — the platform's OWN published example apps, declared by name.
//
// WHY THIS EXISTS. Project.Official is the badge that says "Hanzo published
// this", and createProject raises it only for a SuperAdmin (`body.Official &&
// c.IsAdmin()`). That gate is correct and stays: a tenant asking for
// official:true must always get false, or the badge means nothing.
//
// But the platform's own example catalogue was published by a SCRIPT holding an
// ordinary org-admin token, not a SuperAdmin one — so Official was never raised
// on a single one of them. The consequence was not cosmetic: the cross-org
// catalog partitions rows by authorship, so 74 apps Hanzo wrote and hosts were
// filed as somebody else's work, and the gallery rail labelled them
// "third-party". The directory was making a claim about authorship, and the
// claim was wrong in our own disfavour.
//
// The fix is NOT to weaken the gate — an easier gate would let any tenant do
// what the script did. It is to notice that "which apps are ours" is not a
// request parameter at all. It is a FACT the platform knows about itself, so it
// is declared here, in the platform's own source, and applied to the store as a
// projection of that declaration. A tenant cannot edit this file; a request
// cannot reach it; there is no principal that can make the claim by asking.
//
// ONE DECLARATION. firstparty.json is the only place the set is written down.
// The apps repo (hanzoai/examples, hanzo-app/products.json) keys its product
// identities — name, mark, byline, the named agent that built each one — off the
// SAME slugs, and its publish step asserts against the live catalog that each
// one came back official. So the two halves cannot drift silently: the content
// side fails loudly if this side has not been deployed.
//
// FORWARD-ONLY, RAISE-ONLY. backfillOfficial runs on every migrate, so the
// declaration is the source of truth and the column is derived from it — a
// restored backup or a fresh region converges without a manual step. It only
// ever RAISES: a project outside the manifest is left exactly as it is, so a
// badge a real SuperAdmin set by hand is never revoked by a deploy.
//go:embed firstparty.json
var firstPartyJSON []byte
// firstParty is the declaration in firstparty.json: the org the platform
// publishes its own examples from, and the slugs of those examples.
type firstParty struct {
Org string `json:"org"`
Slugs []string `json:"slugs"`
}
// loadFirstParty decodes and validates the embedded declaration. It is strict —
// a malformed or empty manifest is a build-time mistake, and failing the mount
// is better than silently badging nothing (or, worse, badging an empty org,
// which would match every row with an empty org key).
func loadFirstParty() (firstParty, error) {
var f firstParty
if err := json.Unmarshal(firstPartyJSON, &f); err != nil {
return f, fmt.Errorf("firstparty: decode: %w", err)
}
if strings.TrimSpace(f.Org) == "" {
return f, fmt.Errorf("firstparty: org is required")
}
if len(f.Slugs) == 0 {
return f, fmt.Errorf("firstparty: no slugs declared")
}
for _, s := range f.Slugs {
if !slugRE.MatchString(s) {
return f, fmt.Errorf("firstparty: %q is not a valid project slug", s)
}
}
return f, nil
}
// backfillOfficial projects the embedded declaration onto the store: every
// declared slug in the platform org is marked official. Returns the number of
// rows it actually changed (0 once converged, which is the steady state).
//
// The predicate carries `official=0` so a converged run is a no-op UPDATE rather
// than a rewrite of 74 rows on every boot, and so the count it reports is the
// number of badges genuinely raised.
func backfillOfficial(ctx context.Context, db *sql.DB) (int64, error) {
f, err := loadFirstParty()
if err != nil {
return 0, err
}
args := make([]any, 0, len(f.Slugs)+1)
args = append(args, f.Org)
for _, s := range f.Slugs {
args = append(args, s)
}
q := `UPDATE projects SET official=1 WHERE official=0 AND org=? AND slug IN (?` +
strings.Repeat(",?", len(f.Slugs)-1) + `)`
res, err := db.ExecContext(ctx, q, args...)
if err != nil {
return 0, fmt.Errorf("firstparty: backfill: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, nil // driver without RowsAffected: the UPDATE still applied
}
return n, nil
}
-80
View File
@@ -1,80 +0,0 @@
{
"org": "hanzo",
"slugs": [
"almanac",
"antipode",
"apogee",
"armada",
"atelier",
"ballot",
"bazaar",
"bellwether",
"bistro",
"cadence",
"cartograph",
"chorus",
"cog",
"commons",
"compass",
"conduit",
"convene",
"courier",
"cubit",
"current",
"cutlass",
"darkroom",
"daydream",
"deskline",
"fieldwork",
"footnote",
"gridline",
"grimoire",
"halcyon",
"helm",
"highland",
"kindling",
"kith",
"lanes",
"liftoff",
"lodestone",
"longform",
"marginalia",
"meridian",
"milestone",
"needle",
"northgate",
"outlay",
"peek",
"pennyworth",
"podium",
"polyglot",
"quill",
"quorum",
"redline",
"reel",
"relay",
"remit",
"reverb",
"roundtable",
"scribe",
"shortlist",
"sift",
"skirmish",
"slate",
"sluice",
"spool",
"stockroom",
"streaks",
"swarm",
"syllabus",
"tender",
"tessera",
"threshold",
"timeslot",
"tincture",
"trellis",
"vellum",
"watchtower",
"examples"
]
}
-84
View File
@@ -1,84 +0,0 @@
package projects
import (
"context"
"testing"
)
// The declaration itself must be well-formed, or the badge is applied to nothing
// (or, with an empty org, to everything).
func TestFirstPartyManifestIsValid(t *testing.T) {
f, err := loadFirstParty()
if err != nil {
t.Fatalf("load: %v", err)
}
if f.Org != "hanzo" {
t.Fatalf("platform org want hanzo, got %q", f.Org)
}
seen := map[string]bool{}
for _, s := range f.Slugs {
if seen[s] {
t.Fatalf("duplicate slug %q — a set, not a list", s)
}
seen[s] = true
}
if len(f.Slugs) < 74 {
t.Fatalf("want the whole example catalogue (>=74), got %d", len(f.Slugs))
}
}
// The point of the whole file: a project the platform declares as its own comes
// back official WITHOUT any caller asking for it — and one it does not declare
// stays exactly as it was, in BOTH directions (an unofficial row is untouched,
// and a badge a real SuperAdmin set by hand is never revoked).
func TestBackfillOfficialRaisesOnlyDeclaredRows(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
f, err := loadFirstParty()
if err != nil {
t.Fatalf("load: %v", err)
}
declared := f.Slugs[0]
must := func(p Project) {
t.Helper()
if err := st.CreateProject(ctx, p); err != nil {
t.Fatalf("create %s/%s: %v", p.Org, p.Slug, err)
}
}
must(Project{ID: "p1", Org: f.Org, Slug: declared, Name: "declared"})
must(Project{ID: "p2", Org: f.Org, Slug: "not-in-the-manifest", Name: "undeclared"})
must(Project{ID: "p3", Org: "acme", Slug: declared, Name: "same slug, other org"})
must(Project{ID: "p4", Org: "acme", Slug: "hand-badged", Name: "superadmin set this", Official: true})
n, err := backfillOfficial(ctx, st.db)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if n != 1 {
t.Fatalf("want exactly 1 badge raised, got %d", n)
}
for _, tc := range []struct {
org, slug string
want bool
}{
{f.Org, declared, true}, // declared, in the platform org
{f.Org, "not-in-the-manifest", false}, // platform org is NOT enough on its own
{"acme", declared, false}, // a tenant cannot inherit the badge by slug
{"acme", "hand-badged", true}, // raise-only: never revokes
} {
p, err := st.GetProject(ctx, tc.org, tc.slug)
if err != nil {
t.Fatalf("get %s/%s: %v", tc.org, tc.slug, err)
}
if p.Official != tc.want {
t.Fatalf("%s/%s official = %v, want %v", tc.org, tc.slug, p.Official, tc.want)
}
}
// Converged: a second run changes nothing, so this is a projection of the
// declaration and not a rewrite on every boot.
if n, err := backfillOfficial(ctx, st.db); err != nil || n != 0 {
t.Fatalf("second run: n=%d err=%v — want 0, nil", n, err)
}
}
+121 -56
View File
@@ -272,13 +272,14 @@ func TestForkOrgScopingAndErrors(t *testing.T) {
// first-party example published as a LIVE project is forkable BY SLUG (the same
// name it serves under at <slug>.hanzo.app), the fork lands in the forker's own
// org under their own slug carrying the parent's source, and the parent is
// recorded on the child as forkedFrom so the attribution survives the rename. The
// badge is NOT inherited — a fork of a Hanzo example is the forker's app.
// recorded on the child as forkedFrom so the attribution survives the rename.
// Authorship moves with the org: a fork of a Hanzo example IS the forker's app,
// because the forker's org is the one paying for it.
func TestForkPublishedProjectRecordsLineage(t *testing.T) {
app := mountApp(t)
ex := mkProject("hanzo", "example-kanban", "Example Kanban")
ex.Status, ex.Framework, ex.Official = "live", "vite", true
ex.Status, ex.Framework = "live", "vite"
ex.RepoURL, ex.RepoBranch = "https://github.com/hanzo-templates/kanban-board", "main"
if err := mounted.State.store.CreateProject(context.Background(), ex); err != nil {
t.Fatalf("seed example: %v", err)
@@ -302,8 +303,10 @@ func TestForkPublishedProjectRecordsLineage(t *testing.T) {
if p.Repo.URL != ex.RepoURL || p.Framework != "vite" {
t.Fatalf("fork did not inherit buildable source: repo=%q framework=%q", p.Repo.URL, p.Framework)
}
if p.Official {
t.Fatalf("a fork of a first-party example must not inherit the official badge")
// Whose app this is now is its Org, and the fork moved it: acme's. There is no
// authorship field to inherit or fail to clear.
if p.Visibility != VisibilityPublic {
t.Fatalf("a fork must land public by default, got %q", p.Visibility)
}
// A DRAFT example is not published, so it is not forkable — you can only fork
@@ -370,68 +373,77 @@ func TestForkPrivateTemplateIsOwnerOnly(t *testing.T) {
}
}
// TestOfficialBadgeIsPlatformOnly proves the first-party marker cannot be
// self-asserted: an ordinary tenant asking for official:true gets false, and only
// a SuperAdmin caller (the seeding path) can raise it.
func TestOfficialBadgeIsPlatformOnly(t *testing.T) {
// TestPublishingIsUngated is the rule the community runs on: an ordinary tenant,
// with no admin and no funding, publishes PUBLIC — at create and after the fact.
// Nothing has to be granted to it, because a community you must be admitted to
// does not grow. This is the exact inverse of the admin-gated `official` badge it
// replaced, which gated the way IN and so filed the platform's own script-published
// apps as somebody else's work.
func TestPublishingIsUngated(t *testing.T) {
app := mountApp(t)
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Impostor", "slug": "impostor", "official": true})
map[string]any{"name": "Newcomer", "slug": "newcomer"})
if code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
var p projectView
_ = json.Unmarshal(body, &p)
if p.Official {
t.Fatalf("a tenant must not be able to badge its own app as a Hanzo example")
if p.Visibility != VisibilityPublic {
t.Fatalf("a project must default to public, got %q (%s)", p.Visibility, body)
}
if p.Hidden {
t.Fatal("a new project must not be born moderated")
}
req := httptest.NewRequest(http.MethodPost, "/v1/projects",
bytes.NewReader([]byte(`{"name":"Example","slug":"example-app","official":true}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Id", "u_hanzo")
req.Header.Set("X-User-IsAdmin", "true")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("admin create: %v", err)
// Asking for it explicitly is the same answer, not a different code path.
code, body = do(t, app, http.MethodPatch, "/v1/projects/newcomer", "acme",
map[string]any{"visibility": "public"})
if code != http.StatusOK {
t.Fatalf("patch public want 200, got %d (%s)", code, body)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("admin create want 201, got %d (%s)", resp.StatusCode, b)
_ = json.Unmarshal(body, &p)
if p.Visibility != VisibilityPublic {
t.Fatalf("visibility = %q, want public", p.Visibility)
}
_ = json.Unmarshal(b, &p)
if !p.Official {
t.Fatalf("the platform must be able to badge its own examples: %s", b)
// Anything that is neither is refused rather than quietly coerced: a caller
// that misspells "private" must not be handed a public project.
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Typo", "slug": "typo", "visibility": "secret"}); code != http.StatusBadRequest {
t.Fatalf("unknown visibility want 400, got %d (%s)", code, body)
}
}
// TestOfficialBadgeOnUpdate: the badge must also reach the examples published
// BEFORE it existed, under the same one rule — a tenant PATCHing official:true
// on its own app is ignored; a SuperAdmin can badge, and un-badge.
func TestOfficialBadgeOnUpdate(t *testing.T) {
// TestModerationIsAdminOnlyAndSubtractive pins the one admin-gated field. A
// tenant sending hidden:true is ignored; an admin can hide with a reason and lift
// it again. Hiding must NOT rewrite the publisher's own visibility, so lifting
// restores exactly what they asked for with no second write to get wrong.
func TestModerationIsAdminOnlyAndSubtractive(t *testing.T) {
app := mountApp(t)
if code, body := do(t, app, http.MethodPost, "/v1/projects", "hanzo",
map[string]any{"name": "Legacy Example", "slug": "legacy-example"}); code != http.StatusCreated {
if code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
map[string]any{"name": "Spam", "slug": "spam"}); code != http.StatusCreated {
t.Fatalf("create want 201, got %d (%s)", code, body)
}
code, body := do(t, app, http.MethodPatch, "/v1/projects/legacy-example", "hanzo", map[string]any{"official": true})
code, body := do(t, app, http.MethodPatch, "/v1/projects/spam", "acme",
map[string]any{"hidden": true, "hiddenReason": "self-moderated"})
if code != http.StatusOK {
t.Fatalf("tenant patch want 200, got %d (%s)", code, body)
}
var p projectView
_ = json.Unmarshal(body, &p)
if p.Official {
t.Fatalf("a tenant self-badged via update")
if p.Hidden {
t.Fatalf("a tenant must not be able to set moderation state: %s", body)
}
for _, want := range []bool{true, false} {
b, _ := json.Marshal(map[string]any{"official": want})
req := httptest.NewRequest(http.MethodPatch, "/v1/projects/legacy-example", bytes.NewReader(b))
adminPatch := func(t *testing.T, in map[string]any) projectView {
t.Helper()
b, _ := json.Marshal(in)
req := httptest.NewRequest(http.MethodPatch, "/v1/projects/spam", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Id", "u_hanzo")
req.Header.Set("X-Org-Id", "acme")
req.Header.Set("X-User-Id", "u_admin")
req.Header.Set("X-User-IsAdmin", "true")
resp, err := app.Fiber().Test(req)
if err != nil {
@@ -439,23 +451,79 @@ func TestOfficialBadgeOnUpdate(t *testing.T) {
}
rb, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
_ = json.Unmarshal(rb, &p)
if p.Official != want {
t.Fatalf("admin patch official=%v, want %v (%s)", p.Official, want, rb)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin patch want 200, got %d (%s)", resp.StatusCode, rb)
}
var out projectView
_ = json.Unmarshal(rb, &out)
return out
}
hid := adminPatch(t, map[string]any{"hidden": true, "hiddenReason": "spam"})
if !hid.Hidden || hid.HiddenReason != "spam" {
t.Fatalf("admin hide = %v/%q, want true/spam", hid.Hidden, hid.HiddenReason)
}
if hid.Visibility != VisibilityPublic {
t.Fatalf("moderation must not rewrite the publisher's choice, got %q", hid.Visibility)
}
lifted := adminPatch(t, map[string]any{"hidden": false})
if lifted.Hidden {
t.Fatal("admin must be able to lift a moderation")
}
if lifted.HiddenReason != "" {
t.Fatalf("a lifted moderation must leave no stale reason, got %q", lifted.HiddenReason)
}
if lifted.Visibility != VisibilityPublic {
t.Fatalf("lifting must restore exactly what the publisher asked for, got %q", lifted.Visibility)
}
}
// TestPrivateAndModeratedProjectsLeaveNoCatalogRow is the whole point of the two
// fields: LiveSites is the ONE cross-org read, so the rule is enforced in its
// query and a consumer that forgets to filter cannot leak anything. A live site
// that is private, or public-but-moderated, must not appear.
func TestPrivateAndModeratedProjectsLeaveNoCatalogRow(t *testing.T) {
mountApp(t)
ctx := context.Background()
seed := func(slug, vis string, hidden bool) {
p := mkProject("acme", slug, slug)
p.Status, p.Visibility, p.Hidden = "live", vis, hidden
if err := mounted.State.store.CreateProject(ctx, p); err != nil {
t.Fatalf("seed %s: %v", slug, err)
}
}
seed("shown", VisibilityPublic, false)
seed("privately", VisibilityPrivate, false)
seed("moderated", VisibilityPublic, true)
sites, err := LiveSites(ctx)
if err != nil {
t.Fatalf("LiveSites: %v", err)
}
got := map[string]bool{}
for _, s := range sites {
got[s.Slug] = true
}
if !got["shown"] {
t.Fatal("a live public project must appear in the catalogue")
}
for _, slug := range []string{"privately", "moderated"} {
if got[slug] {
t.Fatalf("%q reached the cross-org catalogue", slug)
}
}
}
// TestCreditIsUngatedWhileTheBadgeIsNot pins the asymmetry the two halves of
// provenance deliberately have. Official says "Hanzo made this", so only Hanzo
// may say it. Upstream/License say "somebody ELSE made this", which can only cost
// the publisher credit — so anyone may say it, about their own project, without
// an admin. A platform where claiming authorship is easier than disclaiming it is
// a platform that launders provenance.
func TestCreditIsUngatedWhileTheBadgeIsNot(t *testing.T) {
// TestCreditIsUngated pins the remaining half of provenance. Upstream/License say
// "somebody ELSE made this", which can only cost the publisher credit — so anyone
// may say it about their own project, with no admin. Authorship needs no
// counterpart field: it is the org that pays, which no request can forge.
func TestCreditIsUngated(t *testing.T) {
app := mountApp(t)
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme", map[string]any{
"name": "Fitness Pro", "slug": "kinetic", "official": true,
"name": "Fitness Pro", "slug": "kinetic",
"upstream": "UI8 — Fitness Pro: Website UI Kit", "license": "UI8 commercial licence",
})
if code != http.StatusCreated {
@@ -463,9 +531,6 @@ func TestCreditIsUngatedWhileTheBadgeIsNot(t *testing.T) {
}
var p projectView
_ = json.Unmarshal(body, &p)
if p.Official {
t.Fatal("official is still admin-only")
}
if p.Upstream != "UI8 — Fitness Pro: Website UI Kit" || p.License != "UI8 commercial licence" {
t.Fatalf("a publisher must be able to credit its upstream: %s", body)
}
+57 -20
View File
@@ -155,12 +155,17 @@ type projectView struct {
Space string `json:"space,omitempty"`
// ForkedFrom is the parent this project was forked from ("<org>/<slug>" of a
// published project, or a catalog template slug) — the attribution edge a
// gallery credits. Official marks a FIRST-PARTY Hanzo example rather than an
// independent community submission; it is the machine-readable half of the
// badge, and always present (never omitempty) so a consumer can tell "false"
// from "this API is too old to say".
// gallery credits.
ForkedFrom string `json:"forkedFrom,omitempty"`
Official bool `json:"official"`
// Visibility is "public" or "private", and Hidden reports platform
// moderation. Both are always present (never omitempty) so a consumer can
// tell a real answer from "this API is too old to say" — and so a console
// never renders a project as public because a field was missing.
//
// Authorship is deliberately absent: it is Org, above.
Visibility string `json:"visibility"`
Hidden bool `json:"hidden"`
HiddenReason string `json:"hiddenReason,omitempty"`
// Upstream/License credit the third-party work this project was published
// from, and the terms it carries. Omitted when nothing is declared: an absent
// credit means "nobody has said", not "there is nothing to say".
@@ -177,7 +182,8 @@ func toProjectView(p Project) projectView {
Framework: p.Framework, Status: p.Status, LiveURL: p.LiveURL, Bucket: p.Bucket,
CurrentDeploymentID: p.CurrentDeploy, CacheControl: p.CacheControl, LastPurgeAt: p.LastPurgeAt,
Analytics: p.Analytics, Space: p.SpaceId,
ForkedFrom: p.ForkedFrom, Official: p.Official,
ForkedFrom: p.ForkedFrom,
Visibility: p.Visibility, Hidden: p.Hidden, HiddenReason: p.HiddenReason,
Upstream: p.Upstream, License: p.License,
CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
}
@@ -361,10 +367,11 @@ type createReq struct {
// (nil) ⇒ ON (the default); explicit false ⇒ off. A pointer so "unset" is
// distinguishable from "false" — the only way to turn the default off.
Analytics *bool `json:"analytics"`
// Official requests the first-party-example badge. Honored ONLY for a
// SuperAdmin caller (createProject drops it otherwise), so a tenant can never
// pass its own app off as a Hanzo example.
Official bool `json:"official"`
// Visibility is "public" (the default when absent) or "private". Publishing
// publicly is ungated — that is the point of a community. Going PRIVATE is
// the paid feature, so an unfunded org asking for it is refused rather than
// silently downgraded (see visibilityFor).
Visibility string `json:"visibility"`
// Upstream/License credit the third-party work this project was published
// from. Taken from any caller: disclaiming authorship can only cost the
// publisher credit, so it needs no gate (see Project.Upstream).
@@ -419,6 +426,13 @@ func createProject(s *cloud.Service[state], c *zip.Ctx, org string, body createR
return zip.ErrBadRequest("unsupported framework")
}
// Resolved BEFORE the row is built, so an unfunded org asking for private is
// refused without a half-created project left behind.
vis, err := visibilityFor(s, c, body.Visibility)
if err != nil {
return err
}
now := time.Now().Unix()
id, err := genID("proj")
if err != nil {
@@ -430,10 +444,8 @@ func createProject(s *cloud.Service[state], c *zip.Ctx, org string, body createR
RepoProvider: providerFromURL(body.Repo.URL), Framework: framework,
Status: "draft", Bucket: s.State.blob.bucket, CreatedAt: now, UpdatedAt: now,
ForkedFrom: body.ForkedFrom,
// The badge is an assertion about WHO published, so only the platform may
// make it: a tenant asking for official:true simply gets false.
Official: body.Official && c.IsAdmin(),
Upstream: credit(body.Upstream), License: credit(body.License),
Visibility: vis,
Upstream: credit(body.Upstream), License: credit(body.License),
}
if p.RepoBranch == "" && p.RepoURL != "" {
p.RepoBranch = "main"
@@ -453,6 +465,9 @@ func createProject(s *cloud.Service[state], c *zip.Ctx, org string, body createR
// only after a successful persist so a conflicting create provisions nothing;
// a Base hiccup is logged and swallowed — it never fails the create.
provisionSpace(s, c.Context(), &p)
// Give it a canonical repo at git.hanzo.ai, world-readable exactly when the
// project is.
publishCommunity(s, c.Context(), p)
return c.JSON(http.StatusCreated, toProjectView(p))
}
@@ -527,10 +542,15 @@ type updateReq struct {
URL string `json:"url"`
Branch string `json:"branch"`
} `json:"repo"`
// Official raises or clears the first-party-example badge on an app that
// already exists — the examples published before the badge did. Same ONE rule
// as at create: honored only for a SuperAdmin caller.
Official *bool `json:"official"`
// Visibility flips an existing project between "public" and "private". Same
// ONE rule as at create: public is free, private needs a paid plan.
Visibility *string `json:"visibility"`
// Hidden is MODERATION, and the only admin-gated field on this body: it pulls
// a public project out of the catalogue from admin.hanzo.ai without editing
// the publisher's own visibility choice, so un-hiding restores exactly what
// they asked for. A tenant sending it is ignored.
Hidden *bool `json:"hidden"`
HiddenReason *string `json:"hiddenReason"`
// Upstream/License credit the third-party work this app was published from —
// settable after the fact, because the demos that need crediting most are the
// ones already live. Pointers so "" clears a credit and absent leaves it.
@@ -601,8 +621,22 @@ func update(s *cloud.Service[state], c *zip.Ctx) error {
p.RepoBranch = "main"
}
}
if body.Official != nil && c.IsAdmin() {
p.Official = *body.Official
if body.Visibility != nil {
vis, err := visibilityFor(s, c, *body.Visibility)
if err != nil {
return err
}
p.Visibility = vis
}
// Moderation is admin-only and subtractive; a tenant sending it is ignored.
// Clearing Hidden clears the reason with it, so a lifted moderation leaves no
// stale explanation behind for the console to render.
if body.Hidden != nil && c.IsAdmin() {
p.Hidden = *body.Hidden
p.HiddenReason = ""
if p.Hidden && body.HiddenReason != nil {
p.HiddenReason = credit(*body.HiddenReason)
}
}
if body.Upstream != nil {
p.Upstream = credit(*body.Upstream)
@@ -617,6 +651,9 @@ func update(s *cloud.Service[state], c *zip.Ctx) error {
}
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
}
// Reconcile the repo to whatever this update settled on — including a
// moderation, which must reach the source and not just the listing.
publishCommunity(s, c.Context(), p)
return c.JSON(http.StatusOK, toProjectView(p))
}
+5 -1
View File
@@ -26,7 +26,11 @@ func mkProject(org, slug, name string) Project {
return Project{
ID: "proj_" + org + "_" + slug, Org: org, Slug: slug, Name: name,
Framework: "static", Status: "draft", Bucket: "hanzo-sites",
CreatedAt: 100, UpdatedAt: 100,
// Public is what the API's own default resolves to, so a seeded row that
// skips the handler must carry it too — otherwise a fixture would be
// invisible to the catalogue for a reason no production row can have.
Visibility: VisibilityPublic,
CreatedAt: 100, UpdatedAt: 100,
}
}
+62 -32
View File
@@ -115,22 +115,40 @@ type Project struct {
// lineage cannot be forged) and immutable after create; it is the attribution
// edge the gallery credits back to the author.
ForkedFrom string
// Official marks a FIRST-PARTY example app published by Hanzo itself, not an
// independent community submission. It is the machine-readable half of the
// badge the gallery renders; the human-visible half reads this field. Raised
// only for a SuperAdmin caller, so a tenant can never self-badge.
Official bool
// Upstream and License are the OTHER half of provenance: the third-party work
// this project was published FROM, and the terms it carries. Official answers
// "did we make it"; these answer "then who did, and under what licence".
// Visibility is the PUBLISHER's choice, and the only thing that decides
// whether a project appears in the community catalogue: "public" (the
// default) or "private". Public means the world can see it, fork it, and
// find its source mirrored into hanzo-community. There is no application to
// approve and no badge to be granted — a community that makes you ask
// permission to appear in it does not grow.
//
// They are deliberately NOT admin-gated the way Official is, because the two
// claims point in opposite directions. Official is a claim about US — that
// Hanzo vouches for this app — so only we may make it. Upstream/License is a
// claim that the work is SOMEONE ELSE'S, which can only ever subtract credit
// from the publisher, so the publisher must always be free to make it. A
// platform that lets you claim authorship more easily than you can disclaim it
// is a platform that launders provenance.
// Authorship is NOT stored here. Who published a project is its Org, which
// the tenancy boundary already enforces and which no request can forge; a
// second field restating it could only ever disagree with it. (It did: an
// admin-gated `official` flag meant the platform's own apps, published by a
// script holding an ordinary org token, were filed as somebody else's work.)
Visibility string
// Hidden is the platform's MODERATION action, taken from admin.hanzo.ai: it
// removes a public project from the catalogue without touching the
// publisher's own visibility choice, so lifting the moderation restores
// exactly what they asked for.
//
// This is the one admin-only field in this struct, and it is safe to be one
// precisely because it only ever SUBTRACTS. An allowlist an admin must add
// you to gates growth and rots the moment nobody tends it; a denylist costs
// nothing until it is used. It is the same shape as Apex's reserved-host
// list: everyone is in, except what we took out.
Hidden bool
// HiddenReason records WHY, so moderation is reviewable rather than a silent
// disappearance. Empty whenever Hidden is false.
HiddenReason string
// Upstream and License are provenance: the third-party work this project was
// published FROM, and the terms it carries.
//
// They are free for the publisher to set, because they can only ever
// SUBTRACT credit from the publisher — a claim that the work is somebody
// else's. A platform that lets you claim authorship more easily than you can
// disclaim it is a platform that launders provenance.
Upstream string
License string
}
@@ -165,7 +183,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
@@ -299,10 +317,17 @@ CREATE INDEX IF NOT EXISTS ix_releases_org_slug_created ON releases(org, slug, c
`ALTER TABLE projects ADD COLUMN analytics INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE projects ADD COLUMN space_id TEXT NOT NULL DEFAULT ''`,
// forked_from is the attribution edge (parent "<org>/<slug>" or template
// slug); official is the first-party-example badge. Both backfill to the
// honest default for every pre-existing row: unknown lineage, not official.
// slug). It backfills to the honest default for every pre-existing row:
// unknown lineage.
`ALTER TABLE projects ADD COLUMN forked_from TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN official INTEGER NOT NULL DEFAULT 0`,
// visibility backfills to 'public' because every row that exists when this
// runs is already serving on the public internet — defaulting to 'private'
// would silently retract a catalogue the world can already reach, which is
// a lie in the other direction. hidden backfills to 0: nothing has been
// moderated yet, and moderation is an act, never an initial condition.
`ALTER TABLE projects ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`,
`ALTER TABLE projects ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE projects ADD COLUMN hidden_reason TEXT NOT NULL DEFAULT ''`,
// upstream/license credit the third-party work a project was published
// from. Empty backfills to "no third party declared", which is the honest
// default: it says nothing, rather than asserting the work is ours.
@@ -320,13 +345,15 @@ CREATE INDEX IF NOT EXISTS ix_releases_org_slug_created ON releases(org, slug, c
return fmt.Errorf("migrate alter: %w", err)
}
}
// The platform's OWN examples carry the first-party badge. It is declared in
// source (firstparty.json), not requested — createProject's SuperAdmin gate on
// Official stays exactly as strict as it was, and stays unreachable by the
// script that publishes the catalogue. See firstparty.go for why this is a
// projection rather than a one-shot data fix.
if _, err := backfillOfficial(context.Background(), s.db); err != nil {
return fmt.Errorf("migrate: %w", err)
// `official` was an admin-gated boolean restating what the org column already
// says, and it disagreed with it: the platform's own apps, published by a
// script holding an ordinary org token, could never raise it. Authorship is
// the org that paid for the project, so the column is dropped rather than
// migrated. Tolerated if already absent (a converged database) or if the
// SQLite build predates DROP COLUMN.
if _, err := s.db.Exec(`ALTER TABLE projects DROP COLUMN official`); err != nil &&
!strings.Contains(err.Error(), "no such column") {
return fmt.Errorf("migrate drop official: %w", err)
}
return nil
}
@@ -334,7 +361,7 @@ CREATE INDEX IF NOT EXISTS ix_releases_org_slug_created ON releases(org, slug, c
// Close closes the underlying database.
func (s *Store) Close() error { return s.db.Close() }
const projectCols = `id,org,slug,name,description,repo_url,repo_branch,repo_provider,framework,status,live_url,bucket,current_deploy,current_release,cache_control,last_purge_at,created_at,updated_at,analytics,space_id,forked_from,official,upstream,license`
const projectCols = `id,org,slug,name,description,repo_url,repo_branch,repo_provider,framework,status,live_url,bucket,current_deploy,current_release,cache_control,last_purge_at,created_at,updated_at,analytics,space_id,forked_from,visibility,hidden,hidden_reason,upstream,license`
func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
var p Project
@@ -342,7 +369,8 @@ func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
&p.RepoURL, &p.RepoBranch, &p.RepoProvider, &p.Framework,
&p.Status, &p.LiveURL, &p.Bucket, &p.CurrentDeploy, &p.CurrentRelease,
&p.CacheControl, &p.LastPurgeAt, &p.CreatedAt, &p.UpdatedAt,
&p.Analytics, &p.SpaceId, &p.ForkedFrom, &p.Official, &p.Upstream, &p.License)
&p.Analytics, &p.SpaceId, &p.ForkedFrom, &p.Visibility, &p.Hidden, &p.HiddenReason,
&p.Upstream, &p.License)
return p, err
}
@@ -350,12 +378,13 @@ func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
// errConflict.
func (s *Store) CreateProject(ctx context.Context, p Project) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO projects (`+projectCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
`INSERT INTO projects (`+projectCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.Org, p.Slug, p.Name, p.Description,
p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CurrentRelease,
p.CacheControl, p.LastPurgeAt, p.CreatedAt, p.UpdatedAt,
p.Analytics, p.SpaceId, p.ForkedFrom, p.Official, p.Upstream, p.License)
p.Analytics, p.SpaceId, p.ForkedFrom, p.Visibility, p.Hidden, p.HiddenReason,
p.Upstream, p.License)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return errConflict
@@ -402,10 +431,11 @@ func (s *Store) ListProjects(ctx context.Context, org string) ([]Project, error)
// reads-modifies-writes the whole Project; org+slug+id+created_at are immutable.
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
res, err := s.db.ExecContext(ctx,
`UPDATE projects SET name=?,description=?,repo_url=?,repo_branch=?,repo_provider=?,framework=?,status=?,live_url=?,bucket=?,current_deploy=?,current_release=?,cache_control=?,last_purge_at=?,analytics=?,official=?,upstream=?,license=?,updated_at=?
`UPDATE projects SET name=?,description=?,repo_url=?,repo_branch=?,repo_provider=?,framework=?,status=?,live_url=?,bucket=?,current_deploy=?,current_release=?,cache_control=?,last_purge_at=?,analytics=?,visibility=?,hidden=?,hidden_reason=?,upstream=?,license=?,updated_at=?
WHERE org=? AND slug=?`,
p.Name, p.Description, p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CurrentRelease, p.CacheControl, p.LastPurgeAt, p.Analytics, p.Official, p.Upstream, p.License, p.UpdatedAt, p.Org, p.Slug)
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CurrentRelease, p.CacheControl, p.LastPurgeAt,
p.Analytics, p.Visibility, p.Hidden, p.HiddenReason, p.Upstream, p.License, p.UpdatedAt, p.Org, p.Slug)
if err != nil {
return fmt.Errorf("update project: %w", err)
}
+107
View File
@@ -0,0 +1,107 @@
package projects
import (
"context"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// visibility.go — who can SEE a project, and who decides.
//
// There is exactly one axis, and it belongs to the publisher: public or
// private. Public is the default and is ungated, because a community nobody can
// enter without approval is a directory, and a directory does not grow. Private
// is the paid feature.
//
// That is the inverse of the arrangement this replaced, which gated the way IN
// (an admin-only `official` badge) and left the way out free. Gating entry
// suppresses exactly the thing the platform wants more of, and it failed on its
// own terms: the badge was unreachable by the script that published the
// platform's own catalogue, so 74 Hanzo apps were filed as somebody else's.
//
// AUTHORSHIP IS NOT STORED. Who made a project is its org — the account that
// pays for it — which the tenancy boundary already enforces and no request can
// forge. A tenant cannot publish into `hanzo` because it cannot fund `hanzo`.
// So there is nothing left for a badge to say that the org column does not
// already say, unforgeably.
//
// MODERATION IS SUBTRACTIVE. Project.Hidden is set from admin.hanzo.ai only and
// only ever removes. It is safe to be the one admin-gated field precisely
// because it cannot be used to promote anything, and because it leaves the
// publisher's own Visibility untouched — lifting a moderation restores exactly
// what they asked for, with no second write to get wrong.
const (
// VisibilityPublic is the default: the project appears in the community
// catalogue and its source is mirrored to hanzo-community on git.hanzo.ai.
VisibilityPublic = "public"
// VisibilityPrivate hides a project from the catalogue at the publisher's own
// request. Paid: see visibilityFor.
VisibilityPrivate = "private"
// privateKind is the metering unit for keeping a project private. It shares
// the ONE cloud.ResourceMeter every other paid surface uses (hosting, agents,
// functions, s3), so "must have a paid account" is the existing funded-org
// gate rather than a second notion of entitlement that could disagree with
// billing. Fee 0 (operator-configured) makes private free and un-gated.
privateKind = "private"
)
// visibilityFor resolves the visibility a create/update request asks for, and
// enforces the ONE rule: public is free, private requires a funded org.
//
// An empty request means public — a caller that says nothing gets the default
// the platform wants, not an error. An unfunded org asking for private is
// REFUSED (402), never silently published as public: quietly making somebody's
// private project public is the one failure mode here that cannot be undone.
func visibilityFor(s *cloud.Service[state], c *zip.Ctx, want string) (string, error) {
switch strings.ToLower(strings.TrimSpace(want)) {
case "", VisibilityPublic:
return VisibilityPublic, nil
case VisibilityPrivate:
fee := cloud.ResourceFeeCents(deployFeeEnvPrefix, privateKind)
project, validated := principal.ValidatedProject(c)
if err := s.State.bill.Gate(c.Context(), principal.Ledger(c), project, validated, privateKind, fee); err != nil {
return "", err
}
return VisibilityPrivate, nil
default:
return "", zip.Errorf(http.StatusBadRequest,
"visibility must be %q or %q", VisibilityPublic, VisibilityPrivate)
}
}
// listed reports whether a project belongs in the public community catalogue:
// the publisher chose public AND moderation has not removed it. Both halves are
// plain values on the row, so this is the whole rule and there is nowhere else
// for a second copy of it to drift.
func (p Project) listed() bool {
return p.Visibility == VisibilityPublic && !p.Hidden
}
// publishCommunity pushes a project's resolved visibility to the canonical git
// plane, which gives it a repo at git.hanzo.ai/<org>/<slug>, world-readable
// exactly when the project is.
//
// Fired on every create and every update rather than only on a transition: the
// subscriber is idempotent, and a transition this side failed to notice would
// leave a private project's source world-readable. It is also BEST-EFFORT —
// logged, never returned — because the project row is the source of truth and a
// git plane that is down (or simply not co-resident in this binary) must not
// fail a publish. The next update reconciles it.
func publishCommunity(s *cloud.Service[state], ctx context.Context, p Project) {
if !cloud.CommunityPublisherRegistered() {
return
}
if err := cloud.OnCommunityPublish(ctx, cloud.CommunityEvent{
Org: p.Org, Slug: p.Slug, Name: p.Name, Description: p.Description,
Listed: p.listed(),
}); err != nil {
s.Log.Warn("community publish", "org", p.Org, "slug", p.Slug,
"listed", p.listed(), "err", err)
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func TestMigrateSelfHealsFromLegacyPromptSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "prompts.db")
legacy, err := cek.Open(path)
legacy, err := cek.Open(cek.Global, path)
if err != nil {
t.Fatalf("open legacy: %v", err)
}
+1 -1
View File
@@ -59,7 +59,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -67,7 +67,7 @@ type Store struct {
// openStore opens (creating if needed) the SQLite metadata DB at path and runs
// the migration. The "sqlite" driver is the hanzoai/sqlite fork.
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -59,7 +59,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+2 -2
View File
@@ -106,7 +106,7 @@ func soleMembership(t *testing.T, id string) *org.Membership {
func cekCanReopen(t *testing.T) bool {
t.Helper()
p := filepath.Join(t.TempDir(), "cek-probe.db")
db, err := cek.Open(p)
db, err := cek.Open(cek.Global, p)
if err != nil {
return false
}
@@ -115,7 +115,7 @@ func cekCanReopen(t *testing.T) bool {
return false
}
_ = db.Close()
db2, err := cek.Open(p) // the reopen a broken-SQLCipher build fails (migrate → sqlcipher_export)
db2, err := cek.Open(cek.Global, p) // the reopen a broken-SQLCipher build fails (migrate → sqlcipher_export)
if err != nil {
return false
}
+1 -1
View File
@@ -62,7 +62,7 @@ type Store struct {
}
func openStore(path string) (*Store, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}
+1 -1
View File
@@ -50,7 +50,7 @@ type SettingsStore struct {
}
func openSettingsStore(path string) (*SettingsStore, error) {
db, err := cek.Open(path)
db, err := cek.Open(cek.Global, path)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
}

Some files were not shown because too many files have changed in this diff Show More