Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ea1a8e0b |
+26
-6
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]++
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,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,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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user