Compare commits

...
10 Commits
Author SHA1 Message Date
antje d19eac9bc9 money: the SELECTED org is the payer of record
The @hanzo/iam switcher moved data and nothing else. A person picks an org,
every surface stamps X-Org-Id from that choice, and the money still came out
of their HOME org: the boundary re-minted X-Org-Id from the `owner` claim
unconditionally, and principal.BillingOrg keyed the debit on home anyway. Both
halves are fixed here, and they are one fix — one org, for data and money alike.

  - SanitizeIdentity honors a selection the token's signed `orgs` claim says the
    caller belongs to (isMember). IAM already mints that set, home first;
    nothing else can widen it. A selection OUTSIDE the set is discarded, not
    refused — a stale localStorage choice after a revoked membership reads and
    bills the caller's OWN org, never someone else's. A legacy token, an opaque
    key and a machine principal carry no set, so they cannot switch at all.
  - principal.BillingOrg returns that effective org. Platform sudo is the one
    exception and is not a selection: a masquerading SuperAdmin still spends
    from the admin ledger, keyed on the header the boundary already mints.
  - HomeOrg -> Ledger. A name that states the wrong fact is how a gate ends up
    keying one wallet while the debit spends another.

And an unresolvable org now REFUSES instead of charging someone else:

  - WalletOf propagates ok=false. It used to discard BillingOrg's ok-bit and
    return an EMPTY ledger, which is not "no ledger" but "whatever the next
    layer substitutes".
  - metering.AuthorizeVerdict and Record refuse an empty org. orgFor fell back
    to the deployment's BRAND org, so an org-less principal was gated against
    Hanzo's balance and its debit posted under Hanzo's header. orgFor stays for
    CONFIG reads (spend rules, caps, plan tier), where the platform's own org IS
    the right default, and is no longer reachable from the money path.

Tests fail without the change: TestSelectedOrgIsThePayer bills "hanzo" instead
of the selected "acme"; TestUnresolvableOrgRefuses resolves a wallet with no
payer; TestUnresolvableOrgRefuses_NoBrandSubstitute posts a real debit to the
brand's ledger.
2026-07-26 17:09:48 -07:00
zeekay a68f1a01b2 deps: repin hanzoai/ai to v1.831.6 — the tag, not a pseudo-version
Was v1.831.5-0.20260726065328-5420f6ba9987 while tag v1.831.6 exists and is
newer. A pseudo-version shadowing a real, later tag is the drift that had this
fleet disagreeing about what a dependency means — the same defect fixed in cloud
and commerce for hanzoai/orm today.

Checked the rest rather than bulk-repinning: cloud sendgrid-go and o11y, and
commerce sendgrid-go, are pseudo-versions AHEAD of their latest tag. Those are
the honest case for an unreleased commit, and "fixing" them would be a
downgrade. Only the two genuinely shadowing pins moved.
2026-07-26 17:00:33 -07:00
antje 218daedb18 fix(auth): a publishable key must not authenticate — then one pk-
pk- is the published key and sk- is the secret one. Cloud did not hold that line:
IdentityFromRequest resolved ANY isAPIKey token into "the same principal a JWT
yields", and pk- is in APIKeyPrefixes. So a key IAM documents as "stored
verbatim, safe to show" — the one you put in a browser bundle — authenticated
like a secret and could READ everything its owning org could.

The codebase had three different answers on this and could not all be right:
auth_identity.go called pk- write-only, build.go called it read-only, and
analytics/publishable.go said the IAM family "mint a FULL principal that can
READ" and therefore built a SECOND publishable-key family to avoid it — pk_
(underscore), HMAC'd under CLOUD_INGEST_KEY_SECRET, with its own mint endpoint.
The underscore was load-bearing: it kept that key out of isAPIKey.

Fixed at the boundary rather than routed around it. IdentityFromRequest refuses a
publishable key outright, so publishable means publishable at every door, not
just the one that remembered to dodge. pk- deliberately STAYS in APIKeyPrefixes:
OrgForKey must still resolve it to learn which tenant a browser beacon belongs
to. Resolvable, not authenticating.

That makes the collapse safe, so it happens in the same commit: the pk_ family,
its HMAC, its secret and POST /v1/ingest/keys are gone, and /v1/event resolves a
pk- through the SAME IAM seam as every other key. One publishable key, issued by
the service that owns identity.

Order matters and is the whole point — collapsing first would have shipped a
public read credential.

Verified: full build; the analytics suite green with the auth tests rewritten
against the IAM seam (a stubbed resolveKeyOrg) so they still assert what they
always did — admitted, key's org beats a forged one, unresolvable fails closed.
TestIAMEmbedBehindMiddlewareChain and TestConfigValidate_ShardSafety fail
identically with this change stashed; they are pre-existing.
2026-07-26 16:58:53 -07:00
zeekayandhanzo-dev 5ed47be60a ci: resolve the reusable from .hanzo/workflows
The forge resolves `uses:` only under WORKFLOW_DIRS (.hanzo/workflows), so
pointing at hanzoai/ci/.github/workflows/build.yml@v1 failed outright:

  path ".github/workflows/build.yml" must be under a configured workflow directory

This repo has built NOTHING since that took effect. hanzoai/ci now publishes the
reusable from .hanzo/workflows/build.yml and tags it v2; a new tag rather than a
force-moved v1, because moving a floating tag is what desynced that repo's two
heads earlier today.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:57:09 -07:00
hanzo-dev 2dcbbd389f git(webhook): read X-Git-* headers, accept the pre-rename pair
Our webhook header says X-Git, not X-Gitea. Cloud is the RECEIVER, so it moves
first: it now prefers X-Git-Event / X-Git-Signature and still reads the
X-Gitea-* pair the git image sends today.

Order is the whole point. The two images roll independently, and if the fork
started sending X-Git-* while cloud only understood X-Gitea-*, every push
webhook would fall through the event check and 204 — no signature error, no log,
deploys just silently stop firing. Receiver-accepts-both makes either order
safe. The fallback is a rename in flight, not a compatibility layer: it comes
out in the same change that makes the fork send the new names.

Also drops the upstream name from this file
type giteaPush -> pushEvent, and the doc comments now describe the ingest
without it. The two remaining mentions are the pre-rename header literals,
which are wire values, not our branding.

TestWebhookAcceptsBothHeaderSpellings covers it with one app and one capture,
asserting the build count climbs 1 then 2 so neither spelling can pass on the
others build. Proven non-vacuous: with the fallback reads removed it fails
X-Gitea-Event/X-Gitea-Signature not honored: builds = 1, want 2. Polls for the
count rather than sleeping, because fireBranchBuild is detached from the request
(context.WithoutCancel) - a fixed sleep is what made the first version flake on
TempDir cleanup.

go test ./clients/git/ -run Webhook passes; go build ./clients/git/ clean;
both files gofmt-clean. (A fresh worktree cannot link ./cmd/account - clients/flags
wants native/flags/target/release/libhanzo_flags.a, a Rust artifact only the main
checkout has built. Pre-existing, unrelated to this change.)
2026-07-26 16:52:32 -07:00
zeekayandClaude Opus 5 130a1e00a8 rip beego out of cloud
The last real use was sessionAccessToken, which mapped a first-party session
cookie to a server-stored JWT by reading web.GlobalSessions — the process-global
the retired Casdoor iam-v1 embed populated.

That embed is gone. IAM v2 (github.com/hanzoai/iam) is zip-native on
hanzoai/orm + hanzoai/sqlite and mounts directly on cloud app, so NOTHING in
this binary ever set that global. The function could only return "". It was dead
code keeping an entire web framework, and its process-global config, in the
dependency graph.

Removed rather than kept as a fallback: a bridge to a session manager that is
never initialised is not a fallback, it is a lie about where sessions come from.
If a first-party session needs to resolve to a token again it resolves through
IAM v2.

The guarantee it existed to provide is unchanged and now stronger. The test no
longer skips when a session manager happens to be present and no longer reaches
into a framework global — a session cookie without a bearer resolves ANONYMOUS,
unconditionally. Green.

beego is out of go.mod. One HTTP framework (zip), one data layer (orm).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:41:23 -07:00
hanzo-dev da2d52c06e fix(kms): serve the operator's list contract so the standalone KMS can retire
The embedded /v1/kms plane and the standalone KMS answered the same request
differently, which is what blocked pointing the KMS operator at cloud:
  operator sends ?environment=&secretPath=   cloud read ?env=&path=
  operator reads  {"names":[...]}            cloud returned {"secrets":[],"total":0}
Repointing KMS_API_BASE would therefore have parsed as 'no secrets' for all 108
KMSSecret syncs — every service silently starved rather than erroring.

listSecrets now accepts either spelling and returns a superset carrying BOTH
`secrets`/`total` (this plane's clients) and `names` (the operator). One endpoint
serves both, so the standalone can be retired without a flag day or a lockstep
operator release.

firstNonEmpty is split out as a plain function so the precedence rule (primary
before alias, empty treated as absent) is tested without a request context: 5/5.
The remaining clients/kms failures are pre-existing and environmental — the suite
wants CLOUD_KMS_MASTER_KEY_REF (105 failures on untouched main); my test passes
standalone and go vet is clean.

Parity must still be proven against a POPULATED path before the operator is
repointed and the standalone retired.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 16:29:36 -07:00
0c365399ca feat(agents): seed the built-in crew @dev @des @vi on org first-touch (re-land) (#342)
Re-land of the crew seed (a parallel force-push to cloud main dropped it).
personalities.go: dev/des/vi personas + idempotent SeedPersonalities(ctx,org)
(one registry, UNIQUE(org,name); no-op without a model). account.go OAuth
callback seeds per-org after EnsureWorkspace (best-effort, never blocks login).
Native TestSeedPersonalities green. TEAM_AGENTS_ENABLED=1 already live +
AIDefaultModel=deepseek-v4-flash → the crew materializes and answers @-mentions.

Co-authored-by: zeekay <ai@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:28:59 -07:00
2d53f93acf rebuild(cloud): embed console-embed@sha-bd8a816 (casibase auth /v1/* fix + console per-project resources) + activate MCP builtin tool-plane (#292)
No Go change — this rebuild re-resolves the freshly-republished console-embed:latest
(console main bd8a81651) into the go:embed console served at console.hanzo.ai, and
ships builtin.go's auto '/v1 route → MCP tool' plane at /v1/tools/mcp (already in main,
newer than the deployed v1.801.69).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:28:59 -07:00
zeekayandhanzo-dev 96a5495261 chore: trigger release build for iam2 v0.15.4 (federation fix)
d411200 (iam2 v0.15.1→v0.15.4 bump) did not trigger a release run; nudge
the push-triggered release so the federation-security-fixed image ships.
2026-07-26 16:28:59 -07:00
44 changed files with 756 additions and 442 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ concurrency:
jobs:
# Test gate + the decoupled native flags staticlib image, driven by hanzo.yml.
gate:
uses: hanzoai/ci/.github/workflows/build.yml@v1
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
secrets: inherit
# Guards the Stage-1 byzantine ceremony containment (clients/controlplane,
+52 -9
View File
@@ -41,21 +41,21 @@ import (
type idClaims struct {
jwt.Claims
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
Owner string `json:"owner"` // org slug (the org)
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
Name string `json:"name"` // display name (id fallback)
PreferredUsername string `json:"preferred_username"` // id fallback
Email string `json:"email"`
IsAdmin bool `json:"isAdmin"`
// Type is IAM's account kind: "application" for a client_credentials MACHINE
// identity (object/token_oauth.go stamps Type:"application"), else a human kind
// ("normal-user", …). It is the discriminator that keeps a machine token — of ANY
// app, not only the KMS-sync one — from ever being granted SuperAdmin. Empty on a
// token that predates the claim ⟹ treated as non-machine (fail toward the KMS-aud
// check below, never toward granting admin).
Type string `json:"type"`
Orgs []model.OrgRef `json:"orgs"` // membership SET (home first); empty on legacy tokens
Type string `json:"type"`
Orgs []model.OrgRef `json:"orgs"` // membership SET (home first); empty on legacy tokens
}
// mintedProject returns the project id to stamp into X-Project-Id, or "" when the
@@ -217,6 +217,29 @@ func isMachinePrincipal(claims *idClaims) bool {
return claims.Type == "application" || isKMSMachinePrincipal(claims)
}
// isMember reports whether org is in the token's signed membership set — the
// `orgs` claim IAM mints for a USER token, home org first. It is the ONE test that
// turns a client's org SELECTION into an effective org (SanitizeIdentity), and
// therefore into the ledger that pays (principal.BillingOrg).
//
// The comparison is VERBATIM, no folding, for the same reason the owner claim is
// taken verbatim: "acme" and "ACME" are DISTINCT orgs in IAM, and a fold would let
// a member of one select the other. An empty org is never a member, so an absent
// selection leaves the caller in their home org. An empty set (a legacy token, an
// opaque key, a machine principal — IAM never mints `orgs` for a client_credentials
// token) admits nothing, which is exactly the pre-claim behavior.
func isMember(orgs []model.OrgRef, org string) bool {
if org == "" {
return false
}
for _, o := range orgs {
if o.Org == org {
return true
}
}
return false
}
// validate parses raw, verifies its signature against the JWKS, and enforces
// issuer/audience/expiry. Returns the claims on success, an error otherwise.
func (v *identityValidator) validate(raw string) (*idClaims, error) {
@@ -405,6 +428,26 @@ func (c *jwksCache) fetch() (*gojose.JSONWebKeySet, error) {
// stays free of cloud-internal imports); if this list changes, that copy must too.
var APIKeyPrefixes = []string{"pk-", "sk-", "hk-"}
// PublishablePrefix is the ONE publishable spelling: pk- is the key you may ship
// in a browser bundle, sk- is the one you may not. Stripe's split, same reason.
const PublishablePrefix = "pk-"
// IsPublishableKey reports whether tok is a publishable key.
//
// A publishable key is NOT a credential: it identifies a tenant so a public
// surface can WRITE (ingest events), and it must never mint a principal that can
// READ. Cloud resolved any isAPIKey token — pk- included — into "the same
// principal a JWT yields", which made a key documented as "safe to show" into a
// full bearer for the org that owns it. IdentityFromRequest now refuses it, so
// publishable means publishable.
//
// It stays in APIKeyPrefixes on purpose: OrgForKey must still resolve a pk- to
// its owning org, because that is exactly how the ingest door learns which tenant
// a browser beacon belongs to. Resolvable, not authenticating.
func IsPublishableKey(tok string) bool {
return strings.HasPrefix(strings.TrimSpace(tok), PublishablePrefix)
}
// isAPIKey reports whether tok is an opaque, backend-validated key rather than a
// JWT, so the sanitizer skips JWT parsing for it.
func isAPIKey(tok string) bool {
-1
View File
@@ -150,7 +150,6 @@ func routes(app *zip.App, s *cloud.Service[state]) {
// /v1/errors is the type:'error' read lens (validated principal — reads never
// accept the write-only key).
app.Post("/v1/ingest", cloud.Handle(s, ingest))
app.Post("/v1/ingest/keys", cloud.Handle(s, mintKey))
app.Get("/v1/errors", cloud.Handle(s, errorsLens))
// DEPRECATED foreign-protocol ingest shims — external-SDK compat ONLY; no Hanzo
+11 -2
View File
@@ -27,7 +27,7 @@
// SERVER-SIDE and FAIL-CLOSED, in strict trust order:
//
// 1. a validated IAM bearer principal — its owner org;
// 2. a write-only publishable key (pk_…) — HMAC-verified org, no IAM/DB hop (the
// 2. a publishable key (pk-…) — IAM resolves it to its org; it can write but
// SAME key publishable.go mints; folded in here so a pk_ caller uses /v1/event
// directly);
// 3. an out-of-band IAM access key (hk-/sk-…) — resolved through the ONE key seam
@@ -96,8 +96,17 @@ func eventTenant(c *zip.Ctx) (string, bool) {
if org, ok := tenant(c); ok {
return org, true
}
// ONE publishable key, and IAM issues it. A pk- on any ingest-shaped carrier
// (Bearer, x-hanzo-ingest-key, ?ingest_key= for sendBeacon, which cannot set
// headers) resolves through the SAME IAM seam as every other key. Cloud used
// to mint and verify its own pk_ under an HMAC of CLOUD_INGEST_KEY_SECRET —
// a second publishable-key family with its own prefix, secret and mint
// endpoint, beside the one IAM already owned.
//
// Safe only because a pk- no longer authenticates: IdentityFromRequest
// refuses it, so it attributes a write and never mints a reading principal.
if key := ingestKey(c); key != "" {
if org, ok := verifyPublishableKey(ingestSecret(), key); ok {
if org, ok := resolveKeyOrg(c.Context(), key); ok {
return org, true
}
}
+28 -120
View File
@@ -12,46 +12,37 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// publishable.go — the FASTEST capture path: a write-only PUBLISHABLE KEY (pk_…)
// that authenticates a direct-to-datastore ingest with ZERO network hop.
// publishable.go — the public capture path: a PUBLISHABLE KEY (pk-…) attributes
// a browser beacon to its tenant and writes straight to the datastore.
//
// POST /v1/ingest body: {batch:[WireEvent]} auth: pk_… -> {accepted,dropped}
// POST /v1/ingest/keys mint a pk_ for the caller's org (validated principal)
// GET /v1/errors recent type:'error' events for the org (read lens)
// POST /v1/ingest body: {batch:[WireEvent]} auth: pk-… -> {accepted,dropped}
// GET /v1/errors recent type:'error' events for the org (read lens)
//
// WHY a distinct key from the IAM hk-/sk-/pk- family: those resolve through IAM
// (get-user?accessKey — a network round-trip) and mint a FULL principal that can
// READ. A publishable key is meant to ship in a browser bundle, so it must be
// write-only and cheap to verify. This key is:
// ONE publishable key, and IAM issues it. pk- is publishable, sk- is secret, and
// there is no third thing.
//
// - INGEST-ONLY BY CONSTRUCTION. The `pk_` (underscore) prefix is deliberately
// NOT in isAPIKey's set (hk-/sk-/pk-/fw_/hz_, all dash/`fw_`/`hz_`), so the
// identity boundary (SanitizeIdentity) and OrgForKey both REFUSE it — it can
// never become a bearer principal, so it can never read. Its only door is the
// ingest verifier below. Write-only is a property of WHICH resolver accepts
// the value, not a flag on a row.
// - ORG-SCOPED, SIGNED, NON-FORGEABLE. The org is carried in the key but sealed
// under HMAC-SHA256(secret, org): a client cannot flip the org without the
// secret. The server stamps tenant_id from the VERIFIED org, never from the
// request body — the same tenant invariant the rest of the plane enforces.
// - LOWEST LATENCY. Verification is one HMAC compute — no IAM call, no keys
// table, no DB read. This is the no-Kafka, no-bridge, direct-to-Datastore
// path; it funnels through the SAME write core (ingestEvents) into the SAME
// hanzo.events table as every other adapter. One write path, many front doors.
// This file used to mint and verify its OWN pk_ (underscore) under an
// HMAC of CLOUD_INGEST_KEY_SECRET, with its own mint endpoint at
// /v1/ingest/keys — a second publishable-key family sitting beside the one IAM
// already owned. The underscore was load-bearing back then: pk_ was deliberately
// kept OUT of isAPIKey's set, because anything isAPIKey resolved into "the same
// principal a JWT yields", and a key meant for a browser bundle must not read.
//
// SECRET: the HMAC secret is CLOUD_INGEST_KEY_SECRET (KMS-injected by the
// operator). Absent ⇒ mint and verify BOTH fail closed (503 / 403) — a deployment
// without the secret never mints a forgeable key nor admits an unverifiable one.
// That is fixed at the boundary instead of routed around: IdentityFromRequest now
// refuses a pk- outright (cloud.IsPublishableKey), so publishable means
// publishable no matter which door it arrives at. A pk- stays inside
// APIKeyPrefixes on purpose — OrgForKey must resolve it to learn which tenant a
// beacon belongs to. Resolvable, not authenticating.
//
// The tenant is whatever IAM resolves the key to, never a body or header claim,
// so the tenant invariant the rest of the plane enforces holds here too. Every
// door funnels through the SAME write core (ingestEvents) into the SAME
// hanzo.events table: one write path, many front doors.
package analytics
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
@@ -61,13 +52,11 @@ import (
)
// ingestKeySecretEnv names the KMS-injected HMAC secret that seals a publishable
// key's org. Absent ⇒ the publishable-key path is disabled (fails closed).
const ingestKeySecretEnv = "CLOUD_INGEST_KEY_SECRET"
// publishablePrefix marks a write-only ingest key. Underscore (not the dash of
// the isAPIKey family) is load-bearing: it keeps pk_ OUT of the bearer/principal
// the isAPIKey family) is load-bearing: it keeps pk- OUT of the bearer/principal
// path, so a publishable key is structurally read-incapable.
const publishablePrefix = "pk_"
const publishablePrefix = cloud.PublishablePrefix
// sourceIngest tags rows that arrived via the publishable-key direct ingest, so
// the ONE hanzo.events table stays honest about origin (queryable as
@@ -76,70 +65,9 @@ const publishablePrefix = "pk_"
const sourceIngest = "ingest"
// sigBytes is the HMAC truncation length (128 bits) — ample against forgery while
// keeping the key short enough to embed in a bundle.
const sigBytes = 16
// ── key codec (pure) ─────────────────────────────────────────────────────────
// ingestSecret returns the configured HMAC secret, or "" when unset (path off).
func ingestSecret() string { return strings.TrimSpace(os.Getenv(ingestKeySecretEnv)) }
// keySig computes the org signature under the secret: HMAC-SHA256(secret, org),
// truncated to sigBytes. The org is the only signed input — the tenant a key can
// ever write into is fixed at mint time and cannot be shifted without the secret.
func keySig(secret, org string) []byte {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(org))
return m.Sum(nil)[:sigBytes]
}
// mintPublishableKey mints "pk_<b64url(org)>.<b64url(sig)>" for org under secret.
// '.' is the delimiter because it is OUTSIDE the base64url alphabet (which uses
// '-' and '_'), so the two segments split unambiguously. Returns ("",false) when
// the secret is unconfigured (fail closed) or org is empty.
func mintPublishableKey(secret, org string) (string, bool) {
org = strings.TrimSpace(org)
if secret == "" || org == "" {
return "", false
}
b64 := base64.RawURLEncoding
return publishablePrefix + b64.EncodeToString([]byte(org)) + "." + b64.EncodeToString(keySig(secret, org)), true
}
// verifyPublishableKey resolves a presented key to its org, or ("",false) if the
// key is not a well-formed, correctly-signed publishable key under the configured
// secret. FAILS CLOSED: unconfigured secret, wrong prefix, malformed segments, or
// a signature mismatch all return not-ok. Constant-time signature compare. Pure:
// no I/O, so tests drive it directly.
func verifyPublishableKey(secret, key string) (string, bool) {
key = strings.TrimSpace(key)
if secret == "" || !strings.HasPrefix(key, publishablePrefix) {
return "", false
}
body := key[len(publishablePrefix):]
dot := strings.IndexByte(body, '.')
if dot <= 0 || dot == len(body)-1 {
return "", false
}
b64 := base64.RawURLEncoding
orgBytes, err := b64.DecodeString(body[:dot])
if err != nil {
return "", false
}
sig, err := b64.DecodeString(body[dot+1:])
if err != nil {
return "", false
}
org := string(orgBytes)
if org == "" || len(org) > maxIngestOrgLen {
return "", false
}
if subtle.ConstantTimeCompare(sig, keySig(secret, org)) != 1 {
return "", false
}
return org, true
}
// maxIngestOrgLen bounds a decoded org (it becomes a warehouse partition key),
// mirroring the cap OrgForKey applies to an IAM-resolved owner.
const maxIngestOrgLen = 128
@@ -149,7 +77,7 @@ const maxIngestOrgLen = 128
// ingestKey pulls the presented publishable key, in priority order: the
// Authorization: Bearer header (the common browser-fetch shape), the
// x-hanzo-ingest-key header, then the ?ingest_key= query (navigator.sendBeacon
// cannot set headers). Only a pk_-prefixed value is returned — an unrelated
// cannot set headers). Only a pk--prefixed value is returned — an unrelated
// bearer (a real JWT/IAM key) is ignored here so this door never shadows the
// identity path. "" when none is present.
func ingestKey(c *zip.Ctx) string {
@@ -211,41 +139,21 @@ func foldException(e CaptureEvent) CaptureEvent {
// ── handlers ─────────────────────────────────────────────────────────────────
// ingest answers POST /v1/ingest — a THIN DEPRECATED ALIAS of the canonical door.
// Since /v1/event now natively accepts the publishable key (pk_…, via eventTenant)
// Since /v1/event now natively accepts the publishable key (pk-…, via eventTenant)
// AND the {batch:[…]} wire (via decodeIngest), /v1/ingest is redundant: it delegates
// to the EXACT canonical handler logic (eventHandle) — the SAME pluggable auth,
// tolerant decode, error-fold, and ONE write core — differing only in a one-shot
// deprecation log and the $source=ingest origin tag for the migration signal.
// Existing pk_ callers keep working unchanged; there is ONE implementation.
// Existing pk- callers keep working unchanged; there is ONE implementation.
func ingest(s *cloud.Service[state], c *zip.Ctx) error {
deprecated(s, c, "/v1/event")
return eventHandle(c, sourceIngest)
}
// mintKey answers POST /v1/ingest/keys — an org owner (VALIDATED principal) mints
// a publishable key for its OWN org. The key is org-scoped to the caller's tenant
// (never a body-supplied org), so a caller can only ever mint a key that writes
// into its own partition. Fails closed (503) when the secret is unconfigured.
func mintKey(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("valid bearer required")
}
secret := ingestSecret()
if secret == "" {
return zip.Errorf(http.StatusServiceUnavailable, "publishable keys unavailable: ingest key secret not configured")
}
key, ok := mintPublishableKey(secret, org)
if !ok {
return zip.Errorf(http.StatusServiceUnavailable, "could not mint publishable key")
}
return c.JSON(http.StatusOK, map[string]any{"key": key, "org": org, "scope": "ingest"})
}
// errorsLens answers GET /v1/errors — the error-tracking read view: recent
// type:'error' events for the org, newest first. Tenant-scoped server-side and
// gated on a VALIDATED principal (tenant()), NOT the publishable key — reads
// require real auth, reinforcing that pk_ is write-only. The captured exception
// require real auth, reinforcing that pk- is write-only. The captured exception
// is surfaced straight from properties.$exception. limit defaults 50, caps 200.
func errorsLens(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
+15 -88
View File
@@ -7,102 +7,29 @@ package analytics
import (
"encoding/json"
"github.com/hanzoai/cloud"
"testing"
"time"
)
const testSecret = "test-ingest-secret-0123456789"
// mint→verify is a round trip: a key minted for an org verifies back to exactly
// that org under the same secret.
func TestPublishableKeyRoundTrip(t *testing.T) {
for _, org := range []string{"acme", "hanzo", "maxpower", "org-with-dashes", "MixedCase"} {
key, ok := mintPublishableKey(testSecret, org)
if !ok {
t.Fatalf("mint failed for %q", org)
}
got, ok := verifyPublishableKey(testSecret, key)
if !ok {
t.Fatalf("verify failed for freshly minted key %q", key)
}
if got != org {
t.Fatalf("round trip org = %q, want %q", got, org)
}
// The ONE publishable spelling is IAM's pk-, and cloud only validates it. Cloud
// used to mint and verify its OWN pk_ under an HMAC of CLOUD_INGEST_KEY_SECRET —
// a second publishable-key family with its own prefix, secret and mint endpoint,
// beside the one IAM already owned.
//
// The prefix is asserted against the ONE authority rather than a literal, and the
// safety property it depends on is asserted with it: a pk- must resolve (so the
// ingest door can attribute a beacon) and must NOT authenticate (so a key shipped
// in a browser bundle is not a reading credential).
func TestPublishablePrefixIsTheIAMFamily(t *testing.T) {
if publishablePrefix != cloud.PublishablePrefix {
t.Fatalf("publishable prefix = %q, want %q", publishablePrefix, cloud.PublishablePrefix)
}
}
// The key is write-only by construction: pk_ is NOT accepted by the isAPIKey
// family, so it can never be minted into a bearer principal. (Guards the prefix
// choice — an accidental switch to a dash prefix would silently make the key
// readable.) We assert the prefix here; isAPIKey lives in the parent package.
func TestPublishablePrefixIsUnderscore(t *testing.T) {
key, _ := mintPublishableKey(testSecret, "acme")
if key[:3] != "pk_" {
t.Fatalf("publishable key must start with pk_ (write-only lane), got %q", key[:3])
if !cloud.IsPublishableKey(publishablePrefix + "abc") {
t.Fatal("a pk- must be recognised as publishable")
}
}
// verify FAILS CLOSED on every malformed / forged / unconfigured case.
func TestVerifyFailsClosed(t *testing.T) {
good, _ := mintPublishableKey(testSecret, "acme")
cases := []struct {
name, secret, key string
}{
{"no secret", "", good},
{"wrong prefix", testSecret, "sk-abcdef"},
{"empty", testSecret, ""},
{"no delimiter", testSecret, "pk_YWNtZQ"},
{"trailing delimiter", testSecret, "pk_YWNtZQ."},
{"leading delimiter", testSecret, "pk_.YWNtZQ"},
{"bad base64 org", testSecret, "pk_!!!.YWNtZQ"},
{"bad base64 sig", testSecret, "pk_YWNtZQ.!!!"},
{"wrong secret", "other-secret", good},
{"tampered org keeps old sig", testSecret, forgeOrg(good, "evil")},
}
for _, tc := range cases {
if org, ok := verifyPublishableKey(tc.secret, tc.key); ok {
t.Errorf("%s: verify admitted a bad key → org %q (want fail closed)", tc.name, org)
}
}
}
// forgeOrg swaps the org segment of a real key while keeping its signature — the
// canonical forgery attempt the HMAC must reject.
func forgeOrg(key, newOrg string) string {
// pk_<b64org>.<b64sig> — replace the b64org segment.
dot := -1
for i := 3; i < len(key); i++ {
if key[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return key
}
enc := base64Raw(newOrg)
return "pk_" + enc + key[dot:]
}
func base64Raw(s string) string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
src := []byte(s)
var out []byte
for i := 0; i < len(src); i += 3 {
var b [3]byte
n := copy(b[:], src[i:])
out = append(out, alphabet[b[0]>>2])
out = append(out, alphabet[(b[0]&0x03)<<4|b[1]>>4])
if n > 1 {
out = append(out, alphabet[(b[1]&0x0f)<<2|b[2]>>6])
}
if n > 2 {
out = append(out, alphabet[b[2]&0x3f])
}
}
return string(out)
}
// foldException lifts a type:'error' event's exception into properties.$exception
// and defaults the type, so the write core stores it as event_type='error'.
func TestFoldException(t *testing.T) {
+36 -32
View File
@@ -8,6 +8,7 @@
package analytics
import (
"context"
"net/http"
"reflect"
"testing"
@@ -27,7 +28,7 @@ import (
// server-resolved org. This is exactly the row buildEventsInsert binds.
// - AUTH dimension (HTTP layer): each auth context is ADMITTED (503, never 403),
// proving the canonical door resolved a tenant for it. Each pure resolver
// (verifyPublishableKey→org, resolveKeyOrg→org, the host-forced Site.Org) is
// (resolveKeyOrg→org, the host-forced Site.Org) is
// unit-proven elsewhere (publishable_test, capture_keyorg_test, hostcarve_test),
// so admission + those proofs compose into "lands in the SAME tenant".
@@ -160,68 +161,71 @@ func assertRowArgsEqualExceptID(t *testing.T, name string, want, got []any) {
}
}
// ── pluggable auth on the ONE door: pk_ folded into /v1/event ─────────────────
// ── pluggable auth on the ONE door: IAM's pk- folded into /v1/event ──────────
// TestEvent_PkKeyAdmitted proves the write-only publishable key (pk_…) is a
// first-class auth mode ON the canonical door: a pk_ bearer for org acme is ADMITTED
// (503, datastore down), so a pk_ caller uses /v1/event directly — no separate
// /v1/ingest door required.
func TestEvent_PkKeyAdmitted(t *testing.T) {
t.Setenv(ingestKeySecretEnv, testSecret)
app := mountApp(t)
key, ok := mintPublishableKey(testSecret, "acme")
if !ok {
t.Fatal("mint pk_ failed")
// stubKeyOrg points the ONE IAM key seam at a table for the test's duration.
// resolveKeyOrg is a var precisely so the seam can be swapped without a network;
// these exercise the DOOR, not IAM's resolution.
func stubKeyOrg(t *testing.T, table map[string]string) {
t.Helper()
prev := resolveKeyOrg
resolveKeyOrg = func(_ context.Context, key string) (string, bool) {
org, ok := table[key]
return org, ok
}
t.Cleanup(func() { resolveKeyOrg = prev })
}
// A pk- is a first-class auth mode ON the canonical door: admitted (503,
// datastore down), so a pk- caller uses /v1/event directly.
func TestEvent_PkKeyAdmitted(t *testing.T) {
stubKeyOrg(t, map[string]string{"pk-acme": "acme"})
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "", `{"batch":[{"type":"event","event":"signup_completed"}]}`,
map[string]string{"Authorization": "Bearer " + key})
map[string]string{"Authorization": "Bearer pk-acme"})
if code != http.StatusServiceUnavailable {
t.Fatalf("pk_ on /v1/event want 503 (admitted, datastore down), got %d", code)
t.Fatalf("pk- on /v1/event want 503 (admitted, datastore down), got %d", code)
}
}
// TestEvent_PkKeyForgedOrgIgnored: the tenant a pk_ writes into is the SIGNED org,
// never the body/header claim — a pk_ for acme with a forged X-Org-Id + body org is
// still admitted (as acme), proving the key's org wins.
// The tenant a pk- writes into is the org IAM resolves it to, never a body or
// header claim — the key's org wins over a forged one.
func TestEvent_PkKeyForgedOrgIgnored(t *testing.T) {
t.Setenv(ingestKeySecretEnv, testSecret)
stubKeyOrg(t, map[string]string{"pk-acme": "acme"})
app := mountApp(t)
key, _ := mintPublishableKey(testSecret, "acme")
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
`{"batch":[{"type":"pageview"}],"org":"attacker"}`,
map[string]string{"Authorization": "Bearer " + key, "X-Org-Id": "attacker"})
map[string]string{"Authorization": "Bearer pk-acme", "X-Org-Id": "attacker"})
if code != http.StatusServiceUnavailable {
t.Fatalf("pk_ door with forged org want 503 (ingested as key org), got %d", code)
t.Fatalf("pk- door with forged org want 503 (ingested as key org), got %d", code)
}
}
// TestEvent_BadPkKeyFailsClosed: a malformed/forged pk_ that does not verify, with no
// other auth, is refused 403 — the canonical door fails closed (no brand-host escape).
// A pk- IAM does not resolve, with no other auth, is refused 403 — fail closed.
func TestEvent_BadPkKeyFailsClosed(t *testing.T) {
t.Setenv(ingestKeySecretEnv, testSecret)
stubKeyOrg(t, map[string]string{})
app := mountApp(t)
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`,
map[string]string{"Authorization": "Bearer pk_deadbeef.deadbeef"})
map[string]string{"Authorization": "Bearer pk-deadbeef"})
if code != http.StatusForbidden {
t.Fatalf("unverifiable pk_ on /v1/event want 403 (fail closed), got %d", code)
t.Fatalf("unresolvable pk- on /v1/event want 403 (fail closed), got %d", code)
}
}
// ── /v1/ingest is now a THIN ALIAS of the ONE handler ─────────────────────────
// TestIngestAlias_DelegatesToEventHandler proves /v1/ingest is the SAME
// implementation as /v1/event: a pk_ caller is admitted (unchanged), AND — because it
// implementation as /v1/event: a pk- caller is admitted (unchanged), AND — because it
// delegates to eventHandle — it now ALSO admits an IAM bearer, while no-auth still
// fails closed. One implementation, reached through two routes.
func TestIngestAlias_DelegatesToEventHandler(t *testing.T) {
t.Setenv(ingestKeySecretEnv, testSecret)
stubKeyOrg(t, map[string]string{"pk-acme": "acme"})
app := mountApp(t)
key, _ := mintPublishableKey(testSecret, "acme")
// pk_ — the historical /v1/ingest auth — still works.
// pk- — the historical /v1/ingest auth — still works.
if code := postKeyed(t, app, "/v1/ingest", "", `{"batch":[{"type":"pageview"}]}`,
map[string]string{"Authorization": "Bearer " + key}); code != http.StatusServiceUnavailable {
t.Fatalf("pk_ on /v1/ingest want 503 (admitted), got %d", code)
map[string]string{"Authorization": "Bearer pk-acme"}); code != http.StatusServiceUnavailable {
t.Fatalf("pk- on /v1/ingest want 503 (admitted), got %d", code)
}
// IAM bearer — admitted too, because the alias IS eventHandle now.
if code, _ := doBody(t, app, http.MethodPost, "/v1/ingest", "user-dave", "acme",
+1 -1
View File
@@ -176,7 +176,7 @@ func narrate(s *cloud.Service[*state], c *zip.Ctx, question string, facts []Fact
Model: s.State.model,
Prompt: narratePrompt(question, facts, tmpl),
Org: org,
BillingOrg: principal.HomeOrg(c),
BillingOrg: principal.Ledger(c),
})
if err != nil || res == nil || strings.TrimSpace(res.Content) == "" {
return tmpl
+1 -1
View File
@@ -43,7 +43,7 @@ const (
// BEFORE any work, so an out-of-funds caller gets a clean 402, never a half stream.
func serveWeb(s *cloud.Service[*state], c *zip.Ctx, in AskRequest, q string) error {
dataOrg, _ := principal.Org(c) // gated non-empty at askHandler entry
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
if payer == "" {
payer = dataOrg
}
+1 -1
View File
@@ -873,7 +873,7 @@ func recordRunEnd(s *cloud.Service[state], ctx context.Context, in RunEndInput)
// meterUnit records one metered unit for an HTTP caller's org. Nil/disabled meter → no-op.
func meterUnit(s *cloud.Service[state], org string, c *zip.Ctx) {
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
}
// meterRun records one metered unit for a flow run from the durable path (no HTTP
+1 -1
View File
@@ -316,7 +316,7 @@ func narrateAsk(s *cloud.Service[*state], c *zip.Ctx, org, question string, resp
Model: s.State.model,
Prompt: prompt,
Org: org,
BillingOrg: principal.HomeOrg(c),
BillingOrg: principal.Ledger(c),
})
if err != nil || res == nil {
return ""
+1 -1
View File
@@ -120,7 +120,7 @@ func scanHandler(s *cloud.Service[*state], c *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "books open failed")
}
ex, err := scanExtract(c.Context(), s.State.ai, s.State.model, org, principal.HomeOrg(c), text)
ex, err := scanExtract(c.Context(), s.State.ai, s.State.model, org, principal.Ledger(c), text)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "scan extraction failed: %s", err.Error())
}
+5 -4
View File
@@ -11,9 +11,10 @@ package cloudflare
// (cloud.BYOInferenceFeeMicros), never the full inference cost (that double-bills).
// - O11Y (one, shared): it emits ONE gen_ai client span through clients.StartGenAISpan
// (gen_ai.system = "cloudflare"), the same span plane every LLM/embedding call uses.
// - PAYER: the HOME org (principal.HomeOrg) is billed, so a SuperAdmin acting in
// another org spends from the admin ledger — the token, though, is the EFFECTIVE
// org's. Same split the LLM meter enforces.
// - PAYER: the SELECTED org (principal.Ledger) is billed — the org the caller
// switched into, which is also the org whose token is used. A SuperAdmin
// masquerading is the one exception and spends from the admin ledger. Same rule
// the LLM meter enforces.
//
// A run needs only a validated org (authClient), not org admin: it is gated by BALANCE
// like every model call, not by the admin bit that guards destructive verbs.
@@ -99,7 +100,7 @@ func aiRun(s *cloud.Service[state], c *zip.Ctx) error {
// Billing PAYER = the HOME org (X-User-Owner; falls back to the effective org for a
// normal caller). Project narrows the scope + its validated cap, exactly as the
// edge/LLM meters thread it.
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
project, projectValidated := principal.ValidatedProject(c)
// BALANCE/FREEZE gate BEFORE any Cloudflare contact. The BYO fee is FLOORED
+4 -4
View File
@@ -199,7 +199,7 @@ func (s *service) handleSearch(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -239,7 +239,7 @@ func (s *service) handleContext(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -344,7 +344,7 @@ func (s *service) handleAsk(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
eng, err := s.engineFor(org, principal.Ledger(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -394,7 +394,7 @@ func (s *service) handleIndex(c *zip.Ctx) error {
if err != nil {
return zip.ErrInternal("open index")
}
res, err := s.indexRepo(c.Context(), org, principal.HomeOrg(c), principal.Project(c), store, repo, body.Files, body.Prune)
res, err := s.indexRepo(c.Context(), org, principal.Ledger(c), principal.Project(c), store, repo, body.Files, body.Prune)
if err != nil {
s.log.Warn("code index failed", "org", org, "repo", repo, "err", err)
return zip.ErrInternal("index failed")
+3 -3
View File
@@ -181,7 +181,7 @@ func invoke(s *cloud.Service[state], c *zip.Ctx) error {
// the post-success debit; fee==0 or unconfigured billing makes this a no-op.
fee := cloud.ResourceFeeCents(invokeFeeEnvPrefix, "invoke")
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, "invoke", fee); err != nil {
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, "invoke", fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -232,9 +232,9 @@ func invoke(s *cloud.Service[state], c *zip.Ctx) error {
// Either is independently free (fee 0 → no-op), so an operator can bill by
// request alone, compute alone, or both.
if runErr == nil {
s.Bill.Meter(principal.HomeOrg(c), project, "invoke", fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), project, "invoke", fee, c.RequestID(), cloud.ClientIP(c))
gbSecCents := gbSecondsCents(dur, memLimitMB(f.MemoryLimit), cloud.ResourceFeeCents(gbSecFeeEnvPrefix, "gbsec"))
s.Bill.MeterUsage(principal.HomeOrg(c), "gbsec", metering.Usage{
s.Bill.MeterUsage(principal.Ledger(c), "gbsec", metering.Usage{
Model: "gbsec", // the billed unit: GB-seconds of compute.
AmountCents: gbSecCents,
Project: project,
+28 -19
View File
@@ -14,16 +14,16 @@ import (
"github.com/zap-proto/zip"
)
// Gitea push-webhook ingest. The external Hanzo Git server (a Gitea fork,
// service hanzo-git.hanzo.svc, host git.hanzo.ai) POSTs here on every push so a
// Push-webhook ingest. The external Hanzo Git server (service
// hanzo-git.hanzo.svc, host git.hanzo.ai) POSTs here on every push so a
// push that lands on it drives the SAME push-to-deploy core the embedded
// smart-HTTP receive-pack path drives: fireBranchBuild → cloud.OnGitPush (deploy
// trigger) + EmitLifecycle (mirror-out / Slack). One deploy trigger, one
// lifecycle stream, regardless of which git server the push landed on — no
// second code path.
//
// Auth is Gitea's HMAC: X-Gitea-Signature is the hex HMAC-SHA256 of the raw
// request body under a shared secret. The secret is KMS-synced
// Auth is an HMAC: X-Git-Signature is the hex HMAC-SHA256 of the raw request
// body under a shared secret. The secret is KMS-synced
// (hanzo/prod:/git/webhook-secret) into the cloud CR as env GIT_WEBHOOK_SECRET;
// it is NEVER hardcoded. Fail-closed: an unset secret or a mismatched signature
// is 401, so a misconfigured deployment refuses webhooks rather than trusting
@@ -31,14 +31,22 @@ import (
const (
webhookSecretEnv = "GIT_WEBHOOK_SECRET"
giteaEventHeader = "X-Gitea-Event"
giteaSigHeader = "X-Gitea-Signature"
eventHeader = "X-Git-Event"
sigHeader = "X-Git-Signature"
// Pre-rename spellings, still what the git image sends today. Read as a
// fallback purely so cloud and the git image can roll in EITHER order: cloud
// must already accept X-Git-* before the fork starts sending it, or the first
// push after a fork roll silently stops triggering deploys. Delete both the
// moment the fork ships the new names — this is a rename in flight, not a
// compatibility layer to keep.
eventHeaderPre = "X-Gitea-Event"
sigHeaderPre = "X-Gitea-Signature"
// syncActorEnv names the login the universal sync engine's inbound relay pushes
// AS when it lands an upstream push into native git. A native push webhook whose
// pusher equals it is the ECHO of our own relay — re-driving the build/mirror
// would ping-pong straight back to the upstream it came from. Unset ⇒ no login is
// treated as the sync bot (no push is suppressed), so a deployment without the
// relay keeps today's behavior; set it to the relay's Gitea login to arm the
// relay keeps today's behavior; set it to the relay's git login to arm the
// guard. Idempotent SHAs already make the echo a no-op downstream; this skips it
// early and explicitly (loop guard, engine-level twin in sync).
syncActorEnv = "GIT_SYNC_ACTOR"
@@ -47,10 +55,10 @@ const (
zeroSHA = "0000000000000000000000000000000000000000"
)
// giteaPush is the subset of Gitea's push payload the deploy core needs. Gitea
// varies the actor field name across versions (login vs username) for both the
// repo owner and the pusher, so each accepts both; the first non-empty wins.
type giteaPush struct {
// pushEvent is the subset of the push payload the deploy core needs. The field
// name for an actor varies across git-server versions (login vs username) for
// both the repo owner and the pusher, so each accepts both; first non-empty wins.
type pushEvent struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
@@ -67,23 +75,24 @@ type giteaPush struct {
} `json:"pusher"`
}
// webhook ingests a Gitea push webhook and funnels it through the shared
// push-to-deploy core (fireBranchBuild). Non-push events and no-op pushes
// (branch delete, non-branch ref) are acknowledged 204 so Gitea does not retry.
// webhook ingests a push webhook and funnels it through the shared push-to-deploy
// core (fireBranchBuild). Non-push events and no-op pushes (branch delete,
// non-branch ref) are acknowledged 204 so the sender does not retry.
func webhook(s *cloud.Service[state], c *zip.Ctx) error {
// Only push drives a build; every other Gitea event is an acknowledged no-op.
if c.Header(giteaEventHeader) != "push" {
// Only push drives a build; every other event is an acknowledged no-op.
if firstNonEmptyStr(c.Header(eventHeader), c.Header(eventHeaderPre)) != "push" {
return c.NoContent(http.StatusNoContent)
}
// Verify BEFORE parse so an unauthenticated body is never decoded. An unset
// secret is a 401 (fail-closed), not an open door.
body := c.Body()
if !validSignature(os.Getenv(webhookSecretEnv), c.Header(giteaSigHeader), body) {
sig := firstNonEmptyStr(c.Header(sigHeader), c.Header(sigHeaderPre))
if !validSignature(os.Getenv(webhookSecretEnv), sig, body) {
return zip.ErrUnauthorized("invalid webhook signature")
}
var ev giteaPush
var ev pushEvent
if err := json.Unmarshal(body, &ev); err != nil {
return zip.ErrBadRequest("invalid push payload")
}
@@ -116,7 +125,7 @@ func webhook(s *cloud.Service[state], c *zip.Ctx) error {
}
// The SAME funnel receive-pack drives (fireBranchBuilds → fireBranchBuild):
// cloud.OnGitPush deploy trigger + EmitLifecycle. project is "" — Gitea repos
// cloud.OnGitPush deploy trigger + EmitLifecycle. project is "" — native repos
// are org-level, the scope the smart-HTTP pack handlers resolve for a native
// push. Detached from the request (WithoutCancel) so the build outlives the
// 204, matching receivePack.
+60 -5
View File
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
@@ -24,7 +25,7 @@ type pushCapture struct {
events []cloud.GitPushEvent
}
// signHook returns Gitea's hex HMAC-SHA256 of body under secret.
// signHook returns the hex HMAC-SHA256 of body under secret.
func signHook(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
@@ -39,10 +40,10 @@ func postHook(t *testing.T, app *zip.App, event, sig string, body []byte) int {
req := httptest.NewRequest(http.MethodPost, "/v1/git/webhook", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if event != "" {
req.Header.Set(giteaEventHeader, event)
req.Header.Set(eventHeader, event)
}
if sig != "" {
req.Header.Set(giteaSigHeader, sig)
req.Header.Set(sigHeader, sig)
}
resp, err := app.Fiber().Test(req, testCfg)
if err != nil {
@@ -67,7 +68,7 @@ func captureBuilder(t *testing.T) *pushCapture {
}
func pushPayload(owner, name, ref, before, after, pusher string) []byte {
var p giteaPush
var p pushEvent
p.Ref = ref
p.Before = before
p.After = after
@@ -184,7 +185,7 @@ func TestWebhookLoopGuardSkipsSyncActor(t *testing.T) {
}
}
// TestWebhookEventFilter proves a non-push Gitea event is an acknowledged 204
// TestWebhookEventFilter proves a non-push event is an acknowledged 204
// no-op that fires no build (even with a valid signature).
func TestWebhookEventFilter(t *testing.T) {
t.Setenv(webhookSecretEnv, testWebhookSecret)
@@ -232,3 +233,57 @@ func TestWebhookZeroShaNoOp(t *testing.T) {
t.Fatalf("no-op pushes must fire no build, got %+v", got.events)
}
}
// TestWebhookAcceptsBothHeaderSpellings pins the rename-in-flight contract: the
// same signed push fires a build whether it arrives with the X-Git-* names cloud
// now prefers or the X-Gitea-* names the git image still sends. That is what lets
// the two images roll in EITHER order — without it, a fork roll landing before a
// cloud roll would silently stop triggering every deploy.
//
// One app and one capture for both cases, asserting the count CLIMBS 1 then 2, so
// neither spelling can pass on the other's build.
func TestWebhookAcceptsBothHeaderSpellings(t *testing.T) {
t.Setenv(webhookSecretEnv, testWebhookSecret)
got := captureBuilder(t)
app := mountApp(t)
for i, pair := range []struct{ ev, sig string }{
{eventHeader, sigHeader},
{eventHeaderPre, sigHeaderPre},
} {
body := pushPayload("acme", "code", "refs/heads/main", "a1",
"1111111111111111111111111111111111111111", "hanzo-dev")
req := httptest.NewRequest(http.MethodPost, "/v1/git/webhook", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(pair.ev, "push")
req.Header.Set(pair.sig, signHook(testWebhookSecret, body))
resp, err := app.Fiber().Test(req, testCfg)
if err != nil {
t.Fatalf("%s: Test POST: %v", pair.ev, err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("%s: status = %d, want 204", pair.ev, resp.StatusCode)
}
if n := waitForBuilds(t, got, i+1, 3*time.Second); n != i+1 {
t.Fatalf("%s/%s not honored: builds = %d, want %d", pair.ev, pair.sig, n, i+1)
}
}
}
// waitForBuilds blocks until want builds have been captured (or d elapses),
// returning the final count. Polls rather than sleeps a fixed span because
// fireBranchBuild is detached from the request (context.WithoutCancel).
func waitForBuilds(t *testing.T, got *pushCapture, want int, d time.Duration) int {
t.Helper()
deadline := time.Now().Add(d)
for {
got.Lock()
n := len(got.events)
got.Unlock()
if n >= want || time.Now().After(deadline) {
return n
}
time.Sleep(10 * time.Millisecond)
}
}
+1 -1
View File
@@ -544,7 +544,7 @@ func doStep(s *cloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
id := idParam(c)
store, cur, _, rows, err := snapshotFor(s, c.Context(), org)
if err != nil {
+2 -2
View File
@@ -210,7 +210,7 @@ func chat(s *cloud.Service[state], c *zip.Ctx) error {
}
// narrate runs ONE grounded AI completion for the caller, billed to the caller's own
// payer (principal.HomeOrg) and scoped to the caller's own org — so a suggestion/chat
// payer (principal.Ledger) and scoped to the caller's own org — so a suggestion/chat
// can never spend another tenant's budget. Returns "" when no AI plane is wired or
// the call errors (the caller falls back to the deterministic output). A free
// function (Go forbids methods on the external cloud.Service) — the ONE
@@ -219,7 +219,7 @@ func narrate(s *cloud.Service[state], c *zip.Ctx, org, prompt string) string {
if s.State.ai == nil || strings.TrimSpace(prompt) == "" {
return ""
}
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
res, err := s.State.ai.ChatCompletion(c.Context(), &cloud.ChatRequest{
Model: s.State.model,
Prompt: prompt,
+32 -3
View File
@@ -272,11 +272,15 @@ type secretPutRequest struct {
// path/env. ?path= narrows to a subpath; ?env= selects the environment.
func listSecrets(s *cloud.Service[state], ctx *zip.Ctx) error {
org := reqOrg(ctx)
env := envOr(ctx.Query("env"))
// The KMS operator (the fleet's only machine consumer) spells these
// `environment` and `secretPath`; this plane's own clients use `env` and
// `path`. Accept both so one endpoint serves both callers — the operator can
// be repointed here without a lockstep operator release.
env := envOr(firstQuery(ctx, "env", "environment"))
if !validEnv(env) {
return zip.ErrBadRequest("'env' must not contain '/', control characters, or exceed 63 bytes")
}
sub := ctx.Query("path")
sub := firstQuery(ctx, "path", "secretPath")
if !ValidSubpath(sub) {
return zip.ErrBadRequest("'path' must be '/'-separated non-empty segments without '.', '..', or control characters")
}
@@ -284,7 +288,32 @@ func listSecrets(s *cloud.Service[state], ctx *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusBadGateway, "%v", err)
}
return ctx.JSON(http.StatusOK, map[string]any{"secrets": metas, "total": len(metas)})
// Superset response: `secrets`/`total` for this plane's clients, `names` for
// the operator, which reads that key. Emitting both means the standalone KMS
// can be retired without a flag day — a consumer of either shape keeps working.
names := make([]string, 0, len(metas))
for _, m := range metas {
names = append(names, m.Name)
}
return ctx.JSON(http.StatusOK, map[string]any{"secrets": metas, "total": len(metas), "names": names})
}
// firstQuery returns the first non-empty value among alias query keys, so one
// handler serves callers that spell the same parameter differently.
func firstQuery(ctx *zip.Ctx, keys ...string) string {
return firstNonEmpty(ctx.Query, keys...)
}
// firstNonEmpty is firstQuery's lookup rule, taken as a plain function so the
// precedence (primary before alias, empty treated as absent) is testable without
// standing up a request context.
func firstNonEmpty(get func(string) string, keys ...string) string {
for _, k := range keys {
if v := get(k); v != "" {
return v
}
}
return ""
}
// getSecret reads one secret value. The trailing wildcard is the sub-path + name
@@ -0,0 +1,29 @@
package kms
import "testing"
// The KMS operator is the fleet's only machine consumer of the secrets list, and
// it spells the parameters `environment`/`secretPath` and reads the `names` key.
// This plane's own clients use `env`/`path` and read `secrets`. These lock in the
// superset that lets ONE endpoint serve both, so the standalone KMS can be retired
// without a lockstep operator release.
func TestFirstQueryPrefersPrimaryThenAlias(t *testing.T) {
for _, tc := range []struct {
name string
q map[string]string
keys []string
expect string
}{
{"primary wins", map[string]string{"env": "prod", "environment": "dev"}, []string{"env", "environment"}, "prod"},
{"alias used when primary absent", map[string]string{"environment": "prod"}, []string{"env", "environment"}, "prod"},
{"alias path", map[string]string{"secretPath": "/ci"}, []string{"path", "secretPath"}, "/ci"},
{"neither set", map[string]string{}, []string{"env", "environment"}, ""},
{"empty primary falls through", map[string]string{"env": "", "environment": "prod"}, []string{"env", "environment"}, "prod"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := firstNonEmpty(func(k string) string { return tc.q[k] }, tc.keys...); got != tc.expect {
t.Fatalf("firstQuery(%v, %v) = %q, want %q", tc.q, tc.keys, got, tc.expect)
}
})
}
}
+30 -2
View File
@@ -320,8 +320,23 @@ func (c *Client) AuthorizeVerdict(ctx context.Context, in AuthInput) (Verdict, e
}
return Verdict{}, fmt.Errorf("metering: empty user")
}
// No LEDGER -> cannot bill either, and the client default is NOT a substitute.
// orgFor falls back to c.org (the deployment's BRAND org, "hanzo"), which is the
// right default for a CONFIG read — spend-alert rules, plan tier, cap rows are
// scoped by the X-Org-Id header and the brand owns the platform's own. It is the
// WRONG default for money: an org-less principal gated against the brand's balance
// reads a wallet it has no claim on, and every unattributable request in the fleet
// would be authorized by whatever Hanzo happens to be holding. An unresolvable org
// refuses; it never charges — or clears — someone else.
org := strings.TrimSpace(in.Org)
if org == "" {
if c.failOpen {
return Verdict{Allow: true}, nil
}
return Verdict{}, fmt.Errorf("metering: empty org")
}
available, err := c.fetchAvailable(ctx, user, c.orgFor(in.Org), currencyOr(in.Currency))
available, err := c.fetchAvailable(ctx, user, org, currencyOr(in.Currency))
if err != nil {
if c.failOpen {
return Verdict{Allow: true}, nil
@@ -611,6 +626,14 @@ func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error) {
if strings.TrimSpace(u.User) == "" {
return nil, fmt.Errorf("metering: Record requires a user")
}
// The DEBIT names its ledger or it does not happen. Same rule as the gate above,
// and the same reason: c.org would silently make the brand org pay for work it
// never asked for. The native path already refuses an empty org inside finance,
// but the HTTP path would post it to commerce under the brand header — so state it
// once, here, where both paths pass.
if strings.TrimSpace(u.Org) == "" {
return nil, fmt.Errorf("metering: Record requires an org")
}
if u.Currency == "" {
u.Currency = "usd"
}
@@ -644,7 +667,7 @@ func (c *Client) Record(ctx context.Context, u Usage) (*RecordResult, error) {
return nil, fmt.Errorf("metering: encode usage: %w", err)
}
body, err := c.post(ctx, pathUsage, payload, c.orgFor(u.Org))
body, err := c.post(ctx, pathUsage, payload, u.Org)
if err != nil {
return nil, err
}
@@ -710,6 +733,11 @@ func (c *Client) do(req *http.Request, org string) ([]byte, error) {
return body, nil
}
// orgFor picks the X-Org-Id a CONFIG read is scoped by: the per-call org, else the
// deployment's own (brand) org. It is for reads whose absence of an org means "the
// platform's own settings" — spend-alert rules, cap rows, plan tier — and it is
// deliberately NOT reachable from the gate or the debit. Money has no default payer:
// AuthorizeVerdict and Record refuse an empty org before they ever get here.
func (c *Client) orgFor(perCall string) string {
if perCall = strings.TrimSpace(perCall); perCall != "" {
return perCall
+13 -12
View File
@@ -82,7 +82,7 @@ func TestAuthorize_Allows_WhenAvailablePositive(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{})
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
t.Fatalf("Authorize allowed should be nil, got %v", err)
}
@@ -113,7 +113,7 @@ func TestAuthorize_Denies_WhenAvailableZero(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{})
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
if err != metering.ErrInsufficientBalance {
t.Fatalf("want ErrInsufficientBalance, got %v", err)
}
@@ -125,7 +125,7 @@ func TestAuthorize_FailClosed_OnCommerceError(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{})
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
if err == nil {
t.Fatal("fail-closed: commerce 500 must deny, got nil")
}
@@ -140,7 +140,7 @@ func TestAuthorize_FailOpen_OnCommerceError(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{FailOpen: true})
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
t.Fatalf("fail-open: commerce down must allow, got %v", err)
}
}
@@ -153,7 +153,7 @@ func TestAuthorize_NotConfigured_Allows(t *testing.T) {
if c.Enabled() {
t.Fatal("client with no BaseURL should report Enabled()=false")
}
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
t.Fatalf("not-configured Authorize must allow, got %v", err)
}
}
@@ -165,7 +165,7 @@ func TestAuthorize_TierAware_UsesEffectiveAvailable(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{TierAware: true})
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
t.Fatalf("tier-aware allow (included allotment) should be nil, got %v", err)
}
if fc.path != "/v1/billing/tier" {
@@ -182,7 +182,7 @@ func TestAuthorize_TierAware_DeniesWhenEffectiveZero(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{TierAware: true})
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != metering.ErrInsufficientBalance {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != metering.ErrInsufficientBalance {
t.Fatalf("tier-aware exhausted must deny with ErrInsufficientBalance, got %v", err)
}
}
@@ -193,7 +193,7 @@ func TestTestMode_SendsTestHeader(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{Test: true})
if err := c.Authorize(context.Background(), metering.AuthInput{User: "meter-sandbox"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "meter-sandbox", Org: "hanzo"}); err != nil {
t.Fatalf("Authorize: %v", err)
}
if fc.testHdr != "true" {
@@ -207,7 +207,7 @@ func TestLiveMode_OmitsTestHeader(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{}) // Test=false (production default)
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"}); err != nil {
if err := c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"}); err != nil {
t.Fatalf("Authorize: %v", err)
}
if fc.testHdr != "" {
@@ -235,6 +235,7 @@ func TestRecord_PostsCanonicalPayload(t *testing.T) {
c := newClient(t, srv, metering.Config{})
res, err := c.Record(context.Background(), metering.Usage{
User: "hanzo/alice",
Org: "hanzo",
AmountCents: 250,
Provider: "search",
RequestID: "req-9",
@@ -297,7 +298,7 @@ func TestRecord_ZeroAmount_IsNoOp(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{})
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", AmountCents: 0})
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", Org: "hanzo", AmountCents: 0})
if err != nil || res != nil {
t.Fatalf("zero-amount Record should be (nil,nil), got (%v,%v)", res, err)
}
@@ -308,7 +309,7 @@ func TestRecord_ZeroAmount_IsNoOp(t *testing.T) {
func TestRecord_NotConfigured_IsNoOp(t *testing.T) {
c, _ := metering.New(metering.Config{})
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", AmountCents: 100})
res, err := c.Record(context.Background(), metering.Usage{User: "hanzo/alice", Org: "hanzo", AmountCents: 100})
if err != nil || res != nil {
t.Fatalf("not-configured Record should be (nil,nil), got (%v,%v)", res, err)
}
@@ -373,7 +374,7 @@ func TestContractMatchesGateway(t *testing.T) {
defer srv.Close()
c := newClient(t, srv, metering.Config{})
_ = c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice"})
_ = c.Authorize(context.Background(), metering.AuthInput{User: "hanzo/alice", Org: "hanzo"})
// Gateway: GET {base}/v1/billing/balance?user=hanzo%2Falice&currency=usd
if !strings.HasPrefix(gotURL, "/v1/billing/balance?") {
+2 -2
View File
@@ -268,7 +268,7 @@ func create(s *cloud.Service[state], k resourceKind) zip.Handler {
// post-success debit; fee==0 or unconfigured billing makes this a no-op.
fee := cloud.ResourceFeeCents(computeFeeEnvPrefix, k.kind)
_, projectValidated := principal.ValidatedProject(c)
if err := s.State.bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, k.kind, fee); err != nil {
if err := s.State.bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, k.kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -299,7 +299,7 @@ func create(s *cloud.Service[state], k resourceKind) zip.Handler {
// Resource created — debit the caller's org ledger for the compute
// submission (per-org, env-attributed, async best-effort). Ongoing
// GPU-hour cost reuses s.State.bill.Meter from a future runtime usage watcher.
s.State.bill.Meter(principal.HomeOrg(c), project, k.kind, fee, c.RequestID(), cloud.ClientIP(c))
s.State.bill.Meter(principal.Ledger(c), project, k.kind, fee, c.RequestID(), cloud.ClientIP(c))
return c.JSON(http.StatusCreated, view(out, true))
}
}
+2 -2
View File
@@ -106,7 +106,7 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
// default — the anti-cross-tenant billing property (resource_billing.go).
fee := cloud.ResourceFeeCents(runFeeEnvPrefix, runKind)
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, runKind, fee); err != nil {
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, runKind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -177,7 +177,7 @@ func run(s *cloud.Service[state], c *zip.Ctx) error {
ensureSecretSync(s, c.Context(), org, a)
// Record the paid unit on the run's OWN org ledger (fire-and-forget).
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), runKind, fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), runKind, fee, c.RequestID(), cloud.ClientIP(c))
s.Log.Info("run (container-serverless)", "org", org, "app", slug, "ns", tenantNamespace(org),
"image", image, "min", minScale, "max", maxScale, "actor", c.User(), "requestID", c.RequestID())
+46 -31
View File
@@ -144,46 +144,61 @@ func Owner(c *zip.Ctx) string {
return strings.Clone(owner)
}
// BillingOrg resolves the org whose ledger PAYS for this request — the HOME org
// (Owner). It is the ONE "who pays" resolver: the edge gate, the AI meter, and the
// resource meter all key their balance CHECK and their DEBIT on it, so a platform
// SuperAdmin masquerading into another org spends from the admin org's balance and
// the debit lands on the admin ledger — never the org being acted on. DATA scope
// keeps using Org (the effective org); this splits "who pays" (home) from "whose
// data" (effective), which the old code conflated onto one org value.
// BillingOrg resolves the org whose ledger PAYS for this request — the org the
// caller SELECTED, i.e. the effective org (Org). It is the ONE "who pays" resolver:
// the edge gate, the AI meter, and the resource meter all key their balance CHECK
// and their DEBIT on it.
//
// Gated on Validated like Org: an unvalidated request bills nothing (("", false)),
// so an off-gateway forge can neither probe nor drain a ledger. Falls back to the
// effective Org when the home header is absent — a normal caller has home==effective
// so the fallback is EXACT for them, and it preserves today's behavior on a gateway
// that has not yet minted X-User-Owner; only an admin org-switch differs, and that
// path always carries X-User-Owner once minted. Returns the resolved payer + true,
// or ("", false) when the request may not be billed.
// THE ORG IS THE PAYER OF RECORD. A person belongs to several orgs, picks one in the
// switcher, and that org's wallet funds the work — the same org whose data they are
// looking at, and the same org their top-up credited. Splitting "who pays" (home)
// from "whose data" (effective) is what made the switcher a lie: a member of `acme`
// could act in `acme` all day while every cent came out of their home org's books.
// The two are ONE value again, and the trust boundary is what makes that safe:
// SanitizeIdentity only ever sets X-Org-Id to an org the validated `orgs` claim says
// the caller belongs to (isMember), so an unselected, stale, or forged org can never
// become the effective org — and therefore can never become the payer.
//
// THE ONE EXCEPTION IS MASQUERADE, and it is not a selection. A platform SuperAdmin
// may act in ANY org, membership or not (that is what platform sudo means), so its
// effective org is not a statement about who should pay. It spends from its OWN
// books: the debit lands on the admin ledger, never on the org being inspected. The
// predicate needs no new header — the boundary already mints X-User-IsAdmin for
// exactly that identity, and a SuperAdmin acting at home has Owner == Org anyway.
//
// FAIL CLOSED. An unvalidated request bills nothing, so an off-gateway forge can
// neither probe nor drain a ledger; and an org that does not RESOLVE (absent, over
// MaxOrgLen) bills nothing either, rather than falling through to a default. There
// is no substitute payer: an unresolvable org must refuse, never charge someone else.
func BillingOrg(c *zip.Ctx) (string, bool) {
if !Validated(c) {
org, ok := Org(c) // composes Validated; empty/oversized org ⟹ (", false) ⟹ refuse
if !ok {
return "", false
}
if owner := Owner(c); owner != "" {
return owner, true
if IsSuperAdmin(c) {
// Masquerade (or a SuperAdmin at home, where owner == org): spend own books.
if owner := Owner(c); owner != "" {
return owner, true
}
}
return Org(c) // rollout fallback: no home header yet ⟹ today's effective-org billing.
return org, true
}
// HomeOrg is the bare-string form of BillingOrg for the in-handler resource meters
// Ledger is the bare-string form of BillingOrg for the in-handler resource meters
// (ResourceMeter.Gate/Meter/MeterUsage), which take an org string rather than the
// ctx. It returns the HOME org that PAYS (X-User-Owner, effective-org fallback), or
// "" when unvalidated. Call it ONLY after the caller has already resolved AND gated
// the effective org via Org (every resource handler does), so "" cannot occur on a
// live path; the meter also no-ops on an empty org, so an unexpected "" bills nothing
// rather than mis-billing. Use it for the billing key; keep Org for the data namespace.
// ctx. It returns the SELECTED org that PAYS, or "" when the request may not be
// billed. Call it ONLY after the caller has already resolved AND gated the effective
// org via Org (every resource handler does), so "" cannot occur on a live path; the
// meter also no-ops on an empty org, so an unexpected "" bills nothing rather than
// mis-billing. Use it for the billing key; keep Org for the data namespace.
//
// It was called Payer, which now means something else: hanzoai/account.Payer returns
// the ACCOUNT that pays, and this returns the ORG whose ledger holds it. Those are
// different values on the same request — a person in the shared signup org pays from
// account "hanzo/alice" held in ledger "hanzo" — so one name for both invited exactly
// the confusion that let the gate key the pool while the debit spent the person.
// An org names a ledger; an account names a wallet within it.
func HomeOrg(c *zip.Ctx) string {
// It is called Ledger, not Payer and no longer HomeOrg. Payer means something else
// (hanzoai/account.Payer returns the ACCOUNT that pays, and this returns the ORG
// whose ledger holds it), and HomeOrg became a lie the moment the SELECTED org
// started paying — a name that states the wrong fact is how a gate ends up keying
// one wallet while the debit spends another. An org names a ledger; an account names
// a wallet within it; this is the ledger.
func Ledger(c *zip.Ctx) string {
if org, ok := BillingOrg(c); ok {
return org
}
+17 -12
View File
@@ -42,22 +42,27 @@ type Wallet struct {
}
// WalletOf resolves the wallet this request spends from, or ok=false when the
// request may not touch money at all (no validated principal never key a ledger
// on a restored, client-forged X-Org-Id, or an anonymous caller could probe and
// drain a victim org's balance).
// request may not touch money at all: no validated principal (never key a ledger on
// a restored, client-forged X-Org-Id, or an anonymous caller could probe and drain a
// victim org's balance), or no resolvable org.
//
// Ledger is the HOME org (BillingOrg: the validated `owner` claim, effective-org
// fallback), NOT the effective X-Org-Id — so a platform SuperAdmin masquerading
// into another org spends from the admin org's books, never the org being acted on.
// Account is resolved by the ONE rule (account.Payer) from the signed
// `billing_account` claim, falling back to Payer's legacy rule for a pre-claim
// token. An org-less validated principal keeps the bare subject: no org names no
// account, but a subject can still gate.
// Ledger is the SELECTED org (BillingOrg) — the org the caller switched into, which
// the trust boundary already proved they belong to; a masquerading SuperAdmin is the
// one exception and spends from its own books. Account is resolved by the ONE rule
// (account.Payer) from the signed `billing_account` claim, falling back to Payer's
// legacy rule for a pre-claim token.
//
// AN UNRESOLVABLE ORG REFUSES. It used to discard BillingOrg's ok-bit and return a
// wallet with an EMPTY ledger, ok=true — and an empty ledger is not "no ledger", it
// is "whatever the next layer substitutes". The metering client substituted the
// BRAND org, so a principal whose owner claim carried a zero-width rune was gated
// against Hanzo's balance and, had the debit not errored on the empty org, would have
// spent it. There is no substitute payer; the ok-bit is the answer and it propagates.
func WalletOf(c *zip.Ctx) (Wallet, bool) {
if !Validated(c) {
ledger, ok := BillingOrg(c) // composes Validated; refuses an unresolvable org
if !ok {
return Wallet{}, false
}
ledger, _ := BillingOrg(c) // "" only for a validated principal with no usable org
sub := strings.TrimSpace(c.User())
acct := account.Payer(account.Credential{
Owner: ledger,
+3 -3
View File
@@ -33,7 +33,7 @@ const (
// A fee of 0 (operator-configured free tier) is un-gated: Gate returns nil. The
// caller renders a non-nil error via cloud.DenyResource.
//
// The gate keys on principal.HomeOrg(c) (the resolved CALLER org, hardened against a
// The gate keys on principal.Ledger(c) (the resolved CALLER org, hardened against a
// masquerading admin) and threads the caller's validated project sub-scope
// (principal.ValidatedProject) so a forged X-Project-Id can neither hard-stop nor
// evade a project-scoped hosting cap — the SAME anti-spoof gate the functions/s3
@@ -41,7 +41,7 @@ const (
func gateHosting(s *cloud.Service[state], c *zip.Ctx) (fee int64, err error) {
fee = cloud.ResourceFeeCents(deployFeeEnvPrefix, deployKind)
project, projectValidated := principal.ValidatedProject(c)
return fee, s.State.bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, deployKind, fee)
return fee, s.State.bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, deployKind, fee)
}
// meterDeploy debits the caller's org ledger ONCE for a successful deploy. It is
@@ -50,5 +50,5 @@ func gateHosting(s *cloud.Service[state], c *zip.Ctx) (fee int64, err error) {
// flipped the site live — never on a failed deploy. It attributes spend to the
// caller's validated project sub-scope so a per-project cap sums correctly.
func meterDeploy(s *cloud.Service[state], c *zip.Ctx, fee int64) {
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), deployKind, fee, c.RequestID(), cloud.ClientIP(c))
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), deployKind, fee, c.RequestID(), cloud.ClientIP(c))
}
+2 -2
View File
@@ -306,7 +306,7 @@ func create(s *cloud.Service[state], kind string) zip.Handler {
// this a no-op. Applies to BOTH strategies.
fee := cloud.ResourceFeeCents(provisionFeeEnvPrefix, kind)
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(ctx, principal.HomeOrg(c), project, projectValidated, kind, fee); err != nil {
if err := s.Bill.Gate(ctx, principal.Ledger(c), project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -409,7 +409,7 @@ func create(s *cloud.Service[state], kind string) zip.Handler {
// blocks or corrupts this 201; a debit failure is logged for
// reconciliation). Recurring storage footprint reuses s.Bill.Meter with a
// GB-month amount once a live-size source exists.
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
// Return the PUBLIC endpoint, never the internal admin host. Remap the
// connection string's host:port too so a copy-pasted DSN is routable.
+1 -1
View File
@@ -250,7 +250,7 @@ func submitScan(s *cloud.Service[state], c *zip.Ctx) error {
}
// One metered unit per scan (product=security). Nil/disabled meter → no-op.
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, 0, c.RequestID(), clientIP(c))
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), meterKind, 0, c.RequestID(), clientIP(c))
// Audit: the scan happened, by whom, with what tally. The redacted findings
// (never the secrets) are the evidence; the tally is the AU-3 outcome.
+2 -2
View File
@@ -182,13 +182,13 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
fee := cloud.ResourceFeeCents(opFeeEnvPrefix, "op")
project, projectValidated := principal.ValidatedProject(ctx)
if err := s.State.bill.Gate(ctx.Context(), principal.HomeOrg(ctx), project, projectValidated, "op", fee); err != nil {
if err := s.State.bill.Gate(ctx.Context(), principal.Ledger(ctx), project, projectValidated, "op", fee); err != nil {
return cloud.DenyResource(ctx, err)
}
if err := h(ctx); err != nil {
return err // handler failed — surface it; do not bill failed work.
}
s.State.bill.Meter(principal.HomeOrg(ctx), principal.Project(ctx), "op", fee, ctx.RequestID(), cloud.ClientIP(ctx))
s.State.bill.Meter(principal.Ledger(ctx), principal.Project(ctx), "op", fee, ctx.RequestID(), cloud.ClientIP(ctx))
return nil
}
}
+1 -1
View File
@@ -318,7 +318,7 @@ func validToolName(name string) bool {
}
func meterUnit(s *cloud.Service[state], c *zip.Ctx) {
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind,
s.Bill.Meter(principal.Ledger(c), principal.Project(c), meterKind,
cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
}
+4 -4
View File
@@ -248,7 +248,7 @@ func createProject(s *cloud.Service[state], c *zip.Ctx) error {
kind := "project"
fee := createFeeCents(kind)
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, kind, fee); err != nil {
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, kind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -264,7 +264,7 @@ func createProject(s *cloud.Service[state], c *zip.Ctx) error {
}
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), kind, fee, c.RequestID(), cloud.ClientIP(c))
return c.JSON(http.StatusCreated, toProjectView(p))
}
@@ -467,7 +467,7 @@ func createIssue(s *cloud.Service[state], c *zip.Ctx) error {
const billKind = "issue"
fee := createFeeCents(billKind)
project, projectValidated := principal.ValidatedProject(c)
if err := s.Bill.Gate(c.Context(), principal.HomeOrg(c), project, projectValidated, billKind, fee); err != nil {
if err := s.Bill.Gate(c.Context(), principal.Ledger(c), project, projectValidated, billKind, fee); err != nil {
return cloud.DenyResource(c, err)
}
@@ -486,7 +486,7 @@ func createIssue(s *cloud.Service[state], c *zip.Ctx) error {
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), billKind, fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), billKind, fee, c.RequestID(), cloud.ClientIP(c))
return c.JSON(http.StatusCreated, toIssueView(p.Key, created))
}
+2 -2
View File
@@ -645,7 +645,7 @@ func discoverAndFold(s *cloud.Service[state], c *zip.Ctx, org string, cr cred, d
// Bill NEW folds fail-closed (an existing fold refreshes free); the fee
// keys on the HOME (paying) org, the fold on the operating org.
if !prev[name] {
if berr := s.Bill.Gate(c.Context(), principal.HomeOrg(c), principal.Project(c), projectValidated, foldClusterKind, fee); berr != nil {
if berr := s.Bill.Gate(c.Context(), principal.Ledger(c), principal.Project(c), projectValidated, foldClusterKind, fee); berr != nil {
res.Error = "billing gate denied"
results = append(results, res)
continue
@@ -658,7 +658,7 @@ func discoverAndFold(s *cloud.Service[state], c *zip.Ctx, org string, cr cred, d
continue
}
if !prev[name] {
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), foldClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.Ledger(c), principal.Project(c), foldClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
}
res.Folded = true
res.Nodes = rec.Nodes
+2 -2
View File
@@ -59,14 +59,14 @@ func attachCluster(s *cloud.Service[state], c *zip.Ctx) error {
// org, not the project sub-scope).
fee := cloud.ResourceFeeCents("CLOUD_COMPUTE_FEE_CENTS", byoClusterKind)
_, projectValidated := principal.ValidatedProject(c)
if err := s.State.bill.Gate(c.Context(), principal.HomeOrg(c), principal.Project(c), projectValidated, byoClusterKind, fee); err != nil {
if err := s.State.bill.Gate(c.Context(), principal.Ledger(c), principal.Project(c), projectValidated, byoClusterKind, fee); err != nil {
return cloud.DenyResource(c, err)
}
rec, err := s.State.fleet.Register(c.Context(), org, project(c), name, req.Kubeconfig, req.Provider, req.Default)
if err != nil {
return zip.Errorf(http.StatusUnprocessableEntity, "%v", err)
}
s.State.bill.Meter(principal.HomeOrg(c), principal.Project(c), byoClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
s.State.bill.Meter(principal.Ledger(c), principal.Project(c), byoClusterKind, fee, c.RequestID(), cloud.ClientIP(c))
return c.JSON(http.StatusCreated, byoToClusterView(rec))
}
+2 -2
View File
@@ -198,7 +198,7 @@ func enforce(s *cloud.Service[state], c *zip.Ctx) error {
}
// Ledger settlement debits an ORG ledger, so a validated payer is required.
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
if payer == "" {
return zip.ErrForbidden("sign in")
}
@@ -319,7 +319,7 @@ func settleLedger(s *cloud.Service[state], ctx context.Context, st *Settlement,
// getSettlement is the receipt lookup: GET /v1/x402/settlements/:id, scoped to the
// caller's payer org so one tenant can never read another's settlement.
func getSettlement(s *cloud.Service[state], c *zip.Ctx) error {
payer := principal.HomeOrg(c)
payer := principal.Ledger(c)
if payer == "" {
return zip.ErrForbidden("sign in")
}
+1 -3
View File
@@ -483,7 +483,6 @@ require (
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/gtank/ristretto255 v0.2.0 // indirect
github.com/hanzoai/beego/v2 v2.4.2
github.com/hanzoai/dashscope-go-sdk v0.0.2 // indirect
github.com/hanzoai/dashscopego v0.6.0 // indirect
github.com/hanzoai/dbx v1.17.1 // indirect
@@ -636,7 +635,6 @@ require (
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sethvargo/go-password v0.2.0 // indirect
github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
@@ -755,7 +753,7 @@ require (
github.com/hanzo-ds/go v1.0.1
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/agent v0.1.3
github.com/hanzoai/ai v1.831.5-0.20260726065328-5420f6ba9987
github.com/hanzoai/ai v1.831.6
github.com/hanzoai/authz v1.10.7
github.com/hanzoai/base v1.5.7
github.com/hanzoai/licensing v0.1.5
+2 -6
View File
@@ -607,8 +607,6 @@ github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/elastic/lunes v0.2.0 h1:WI3bsdOTuaYXVe2DS1KbqA7u7FOHN4o8qJw80ZyZoQs=
github.com/elastic/lunes v0.2.0/go.mod h1:u3W/BdONWTrh0JjNZ21C907dDc+cUZttZrGa625nf2k=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
@@ -1064,12 +1062,12 @@ github.com/hanzoai/agent v0.1.3 h1:zzV4t8kN/m/wTLrqzEy0fxxONSZbx3XSVH7TIR9gZNU=
github.com/hanzoai/agent v0.1.3/go.mod h1:Z3hCBdSeN/nGV4o+3F4psQ2bbFk17+tMP5l+G2ssNNA=
github.com/hanzoai/ai v1.831.5-0.20260726065328-5420f6ba9987 h1:XP016M5/7SoeOvSWq0JFUJ3ZnV127vp0B8DiMFJRZko=
github.com/hanzoai/ai v1.831.5-0.20260726065328-5420f6ba9987/go.mod h1:Q3WkxfW6rUwvfXLb8qBBcerJQ60g6nTvRac+XwVqW1s=
github.com/hanzoai/ai v1.831.6 h1:tPdmTyfHy5EGECrziv+lrOGpEzRK13rLCmZRjXzyB2M=
github.com/hanzoai/ai v1.831.6/go.mod h1:6nqnTEZnsJPTg65pAF7aMGi4YOyH1nY7CiXFf2yGCAQ=
github.com/hanzoai/authz v1.10.7 h1:JrHljH29mbmVi8u6/6EVG7R0NiFhIYYm2WUBBuBmFq0=
github.com/hanzoai/authz v1.10.7/go.mod h1:9wf6n6BvrvxRULUtL3yc+vmprwwvAGZLGJoMHhiQSK4=
github.com/hanzoai/base v1.5.7 h1:490temFA2Bz4/QD5lWRKWFWJp+k7FfIJCOi3FXMA80w=
github.com/hanzoai/base v1.5.7/go.mod h1:oiso4FbBNwFXX1ipOtt3i8g7d6AFIVq5SvSyt/QxMy0=
github.com/hanzoai/beego/v2 v2.4.2 h1:Piq3DwRfB6JM0XeF6GdvwAncOiTYSTvRuCQxvj28qiM=
github.com/hanzoai/beego/v2 v2.4.2/go.mod h1:R/5uyJtklFoKrwBMyfcnfiFgHB6K5PceNIonnlp8Mh4=
github.com/hanzoai/builder v0.3.13 h1:tAOJ+0Q0xrrovk7lkvaZxuKZ4lqENIB6tE0Rr9+6Bo8=
github.com/hanzoai/builder v0.3.13/go.mod h1:TWZaiP0Y9tCMwtLH2EvQqBAeT1f3aJI5Y0XPM8S0wcE=
github.com/hanzoai/captable v1.0.0 h1:utXPsOaPL+QV0rJaoNDFHvWFv7etf2kfm1bsFh6JRD0=
@@ -1913,8 +1911,6 @@ github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 h1:v9ezJDHA1XGxViAUSIoO/Id7Fl63u6d0YmsAm+/p2hs=
github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02/go.mod h1:RF16/A3L0xSa0oSERcnhd8Pu3IXSDZSK2gmGIMsttFE=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
+5 -3
View File
@@ -320,9 +320,10 @@ func billingProbe(t *testing.T, headers map[string]string) (billingOrg, billingU
// admin's spend was silently charged to the org being acted on.
func TestIdentityFromCtx_AdminMasqueradeBillsHomeOrg(t *testing.T) {
billOrg, billUser, dataOrg := billingProbe(t, map[string]string{
"X-User-Id": "u_admin", // validated principal
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
"X-User-Owner": "admin", // HOME — the identity + billing anchor
"X-User-Id": "u_admin", // validated principal
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
"X-User-Owner": "admin", // HOME — the identity + billing anchor
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
})
if billOrg != "admin" {
t.Errorf("billing org (debit ledger) = %q, want %q (HOME org pays, not the acted-on org)", billOrg, "admin")
@@ -432,6 +433,7 @@ func TestIdentityFromCtx_MasqueradeKeepsTheAdminsLedger(t *testing.T) {
"X-User-Id": "u_admin",
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
"X-User-Owner": "admin", // HOME — who pays
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
"X-Billing-Account-Id": "org:victim", // a claim naming the VICTIM's ledger
})
if billOrg == "victim" || billUser == "victim" {
+44 -31
View File
@@ -27,12 +27,10 @@ package cloud
// matching the gateway's admin-guard. An org admin gets NO admin authority.
import (
"context"
"net/url"
"strings"
"unicode"
"github.com/hanzoai/beego/v2/server/web"
"github.com/zap-proto/zip"
)
@@ -252,9 +250,27 @@ func SanitizeIdentity(v *identityValidator, adminOrg string) zip.Handler {
}
req.Header.Set("X-Org-Id", effOrg)
case owner != "":
// Any other principal: pinned to their own org, never SuperAdmin.
// Any other principal acts in the org it SELECTED, provided the
// validated token says it is a member of that org — the `orgs` claim
// (IAM's signed membership set, home first). The org switcher is the
// product: a person belongs to several orgs, picks one, and THAT org
// is the payer of record (principal.BillingOrg reads this header). So
// the selection has to survive the trust boundary, and membership is
// the only thing that makes surviving safe.
//
// It is not a widening: the set is signed by IAM, so a caller can only
// ever land on an org it already belongs to, and a claim-less token (a
// legacy JWT, an hk-/sk- key, a client_credentials machine — IAM never
// mints `orgs` for one) has an EMPTY set and stays pinned to home. A
// selection outside the set is DISCARDED, not honored and not refused:
// the request continues in the caller's own org, so a stale localStorage
// selection after a membership is revoked reads the caller's own data
// and bills the caller's own ledger — never someone else's.
effOrg = owner
req.Header.Set("X-Org-Id", owner)
if isMember(claims.Orgs, cliOrg) {
effOrg = cliOrg
}
req.Header.Set("X-Org-Id", effOrg)
}
// X-User-IsOrgAdmin marks a validated principal that is an admin OF ITS OWN
// ORG — the IAM `isAdmin` bit (claims.IsAdmin). It is minted on the SAME
@@ -358,6 +374,15 @@ func validatedPrincipal(c *zip.Ctx, v *identityValidator) *idClaims {
// so key auth and session auth mint one identity. An unresolved key stays
// anonymous (nil) — a bad key never grants trust.
if isAPIKey(tok) {
// A PUBLISHABLE key never becomes a principal. pk- ships in browser
// bundles by design ("stored verbatim, safe to show"), so resolving it
// here would hand every visitor a reading credential for the org that
// owns it. It stays resolvable through OrgForKey — that is how the ingest
// door attributes a beacon to a tenant — but resolvable is not
// authenticated.
if IsPublishableKey(tok) {
return nil
}
if v.keys == nil {
return nil
}
@@ -370,33 +395,21 @@ func validatedPrincipal(c *zip.Ctx, v *identityValidator) *idClaims {
return claims
}
// sessionAccessToken returns the IAM access-token JWT the in-process IAM stored
// server-side for the caller's first-party session, or "" when there is no
// session manager, no session cookie, no session, or no stored token. The token
// is NOT trusted here — validatedPrincipal feeds it back through v.validate — so
// this only maps an opaque, httpOnly session id to the server-minted JWT bound to
// it. The session cookie name and store are the SAME ones clients/iam wired
// into Beego's global session manager (web.BConfig.WebConfig.Session).
func sessionAccessToken(c *zip.Ctx) string {
mgr := web.GlobalSessions
if mgr == nil {
return ""
}
name := web.BConfig.WebConfig.Session.SessionName
if name == "" {
return ""
}
sid := c.Fiber().Cookies(name)
if sid == "" {
return ""
}
store, err := mgr.GetSessionStore(sid)
if err != nil || store == nil {
return ""
}
tok, _ := store.Get(context.Background(), "accessToken").(string)
return tok
}
// sessionAccessToken used to map a first-party session cookie to the JWT the
// in-process IAM had stored server-side, by reading Beego's global session
// manager (web.GlobalSessions) that the Casdoor iam-v1 embed wired up.
//
// That embed is retired. IAM v2 (github.com/hanzoai/iam) is zip-native on
// hanzoai/orm + hanzoai/sqlite and registers its surface directly on cloud's
// app, so nothing in this binary ever populates web.GlobalSessions — the
// function could only ever return "". It was dead code holding a whole beego
// module in the graph, along with the process-global config it drags in.
//
// Removed rather than kept "just in case": a session bridge to a manager that
// is never initialised is not a fallback, it is a lie about where sessions come
// from. If a first-party session ever needs to resolve to a token again, it
// resolves through IAM v2, not through a global in a retired framework.
func sessionAccessToken(*zip.Ctx) string { return "" }
// sessionBridgeSameOrigin reports whether the request may use the ambient-cookie
// session bridge (RED H3). A legitimate embed request is same-origin (the SPA calls
+224
View File
@@ -0,0 +1,224 @@
package cloud
// THE SELECTED ORG PAYS — the whole thread, end to end, in one file.
//
// A person belongs to several orgs, picks one in the @hanzo/iam switcher, and the
// surface sends it as X-Org-Id. Every assertion here drives a REAL RSA-signed IAM
// token (with the `orgs` membership claim IAM actually mints) through the REAL
// trust boundary (SanitizeIdentity) and reads the money address the debit uses
// (identityFromCtx → principal.WalletOf → account.Payer). Nothing is stubbed
// between the token and the ledger key, because the bug this closes lived exactly
// in that gap: the selection was stripped at the boundary, and even if it had
// survived, the payer was re-derived from the home org and ignored it.
//
// Each test states which half it pins:
//
// SelectedOrgIsThePayer — the switcher's org reaches the debit.
// NonMemberSelectionIgnored — a selection outside the signed set never lands.
// MasqueradeSpendsOwnBooks — platform sudo is not a selection.
// UnresolvableOrgRefuses — no payer ⟹ no charge, and no substitute payer.
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/clients/principal"
model "github.com/hanzoai/iam/pkg/model"
"github.com/zap-proto/zip"
)
// walletProbe signs claims into a real token, drives it through SanitizeIdentity
// (adminOrg="admin") with the given client-selected X-Org-Id, and returns the money
// address the request would spend from plus the DATA scope it would read.
//
// ok is principal.WalletOf's refusal bit: false means the request may not touch
// money at all. billOrg/billUser are the ledger + wallet the gate checks and the
// debit drains — read through identityFromCtx, the SAME function BillingGate calls.
func walletProbe(t *testing.T, claims idClaims, selected string) (billOrg, billUser, dataOrg string, ok bool) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
jwks := jwksServer(t, &key.PublicKey)
v := newIdentityValidator(testIssuer, jwks.URL, 0)
tok := signWith(t, key, claims)
done := make(chan struct{})
app := zip.New(zip.Config{})
app.Use(SanitizeIdentity(v, "admin"))
app.Get("/probe", func(c *zip.Ctx) error {
in := identityFromCtx(c)
billOrg, billUser = in.Org, in.User
dataOrg, _ = principal.Org(c)
_, ok = principal.WalletOf(c)
close(done)
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set("Authorization", "Bearer "+tok)
if selected != "" {
req.Header.Set("X-Org-Id", selected)
}
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("probe: %v", err)
}
<-done
return billOrg, billUser, dataOrg, ok
}
// memberClaims is a normal human token: home org `owner`, plus the signed `orgs`
// membership set IAM mints (home first). aud matches the validator's allowlist via
// tokenClaims.
func memberClaims(owner string, orgs ...string) idClaims {
c := tokenClaims("hanzo-cloud", owner, owner+"@example.test", false, time.Now().Add(time.Hour))
c.Name = "alice"
for _, o := range orgs {
c.Orgs = append(c.Orgs, model.OrgRef{Org: o, Role: "member"})
}
return c
}
// TestSelectedOrgIsThePayer is THE proof. alice's home org is `hanzo`; she is also a
// member of `acme` and selects it. Every cent must come out of ACME's books.
//
// Before this change the boundary discarded the selection (X-Org-Id was re-minted
// from `owner` unconditionally) and principal.BillingOrg keyed the debit on the home
// org, so this request billed `hanzo` — the switcher moved the data and nothing else.
func TestSelectedOrgIsThePayer(t *testing.T) {
billOrg, billUser, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), "acme")
if !ok {
t.Fatal("a validated member selecting their own org must resolve a wallet")
}
if billOrg != "acme" {
t.Errorf("ledger charged = %q, want %q — the SELECTED org is the payer of record", billOrg, "acme")
}
if billUser != "acme" {
t.Errorf("wallet drained = %q, want %q — a real org pays from its own pool", billUser, "acme")
}
if dataOrg != "acme" {
t.Errorf("data scope = %q, want %q — one selection, one org, data and money together", dataOrg, "acme")
}
}
// TestSelectedOrgIsThePayer_HomeIsJustAnotherChoice: selecting the home org (or
// selecting nothing) is not a special case — it resolves through the same rule and
// lands on the same ledger. The switcher's default is a selection like any other.
func TestSelectedOrgIsThePayer_HomeIsJustAnotherChoice(t *testing.T) {
for _, selected := range []string{"", "hanzo"} {
billOrg, _, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), selected)
if !ok || billOrg != "hanzo" || dataOrg != "hanzo" {
t.Errorf("selected=%q: bill=%q data=%q ok=%v, want hanzo/hanzo/true", selected, billOrg, dataOrg, ok)
}
}
}
// TestNonMemberSelectionIgnored: the signed set is the whole authorization. A
// selection naming an org the token does not carry is DISCARDED — the caller keeps
// acting, and paying, in their own org. It is not an error, because a stale
// localStorage selection after a membership is revoked is ordinary, and it must
// degrade to "your own org", never to "someone else's ledger".
func TestNonMemberSelectionIgnored(t *testing.T) {
billOrg, billUser, dataOrg, ok := walletProbe(t, memberClaims("hanzo", "hanzo", "acme"), "victim")
if !ok {
t.Fatal("a validated caller with a stale selection must still resolve their own wallet")
}
if billOrg == "victim" || billUser == "victim" || dataOrg == "victim" {
t.Fatalf("a non-member selection reached the request (bill=%q/%q data=%q) — cross-tenant", billOrg, billUser, dataOrg)
}
if billOrg != "hanzo" || dataOrg != "hanzo" {
t.Errorf("bill=%q data=%q, want hanzo/hanzo (the caller's own org)", billOrg, dataOrg)
}
}
// TestLegacyTokenCannotSwitch: a token minted before IAM shipped the `orgs` claim
// carries an EMPTY membership set, so it can select nothing and stays pinned to
// home. Same for an opaque key and a client_credentials machine, for which IAM never
// mints the claim at all. The switch is strictly additive — no token gains reach.
func TestLegacyTokenCannotSwitch(t *testing.T) {
noClaim := memberClaims("hanzo") // no orgs at all
billOrg, _, dataOrg, ok := walletProbe(t, noClaim, "acme")
if !ok || billOrg != "hanzo" || dataOrg != "hanzo" {
t.Fatalf("legacy token switched: bill=%q data=%q ok=%v, want hanzo/hanzo/true", billOrg, dataOrg, ok)
}
}
// TestMasqueradeSpendsOwnBooks: platform sudo is NOT a selection. A SuperAdmin may
// act in any org, membership or not — that is what sudo means — so its effective org
// says nothing about who should pay. It spends from the admin ledger; the org being
// inspected is never charged for being looked at.
func TestMasqueradeSpendsOwnBooks(t *testing.T) {
admin := tokenClaims("hanzo-cloud", "admin", "z@hanzo.ai", false, time.Now().Add(time.Hour))
admin.Name = "z"
billOrg, billUser, dataOrg, ok := walletProbe(t, admin, "victim")
if !ok {
t.Fatal("a SuperAdmin must resolve a wallet")
}
if billOrg == "victim" || billUser == "victim" {
t.Fatalf("masquerade billed the inspected org (bill=%q/%q) — cross-tenant debit", billOrg, billUser)
}
if billOrg != "admin" {
t.Errorf("ledger charged = %q, want admin (sudo spends its own books)", billOrg)
}
if dataOrg != "victim" {
t.Errorf("data scope = %q, want victim (sudo still SEES the org it switched into)", dataOrg)
}
}
// TestUnresolvableOrgRefuses is the fail-closed half. A validated principal whose
// `owner` claim carries a zero-width rune is org-less by design (OrgHasUnsafeRune
// refuses to fold it), so no ledger can be named — and the request must be REFUSED,
// not silently attached to a default.
//
// This is the shape of the F1 fail-open: WalletOf discarded BillingOrg's ok-bit and
// returned ok=true with an empty ledger, and metering.orgFor turned that empty
// ledger into the deployment's BRAND org. An unattributable request was therefore
// gated against Hanzo's balance. Both halves are pinned below.
func TestUnresolvableOrgRefuses(t *testing.T) {
bad := memberClaims("hanzo", "hanzo") // zero-width space inside the owner
billOrg, billUser, dataOrg, ok := walletProbe(t, bad, "")
if ok {
t.Fatalf("an org-less principal resolved a wallet (%q/%q) — money with no payer", billOrg, billUser)
}
if billOrg != "" || billUser != "" {
t.Errorf("refused request still named a payer: org=%q user=%q", billOrg, billUser)
}
if dataOrg != "" {
t.Errorf("org-less principal got data scope %q, want none", dataOrg)
}
}
// TestUnresolvableOrgRefuses_NoBrandSubstitute is the second half of fail-closed,
// at the layer that actually spent: the metering client must REFUSE an empty org
// rather than fall back to its configured (brand) org. Without this, an org-less
// AuthInput reads — and a Record debits — whatever the platform's own wallet holds.
func TestUnresolvableOrgRefuses_NoBrandSubstitute(t *testing.T) {
// A client configured with the brand org, exactly as build.go wires it.
fc := &fakeCommerce{balanceBody: `{"available":100000}`}
c, err := metering.New(metering.Config{BaseURL: fc.server(t).URL, Token: "svc", Org: "hanzo"})
if err != nil {
t.Fatalf("New: %v", err)
}
// The GATE: a funded brand org must not authorize an org-less principal.
if err := c.Authorize(t.Context(), metering.AuthInput{User: "someone", AmountCents: 100}); err == nil {
t.Error("Authorize with no org allowed the request — the brand's balance is not a substitute payer")
}
// The DEBIT: likewise refuses rather than posting under the brand header.
if _, err := c.Record(t.Context(), metering.Usage{User: "someone", AmountCents: 100}); err == nil {
t.Error("Record with no org posted a debit — it would land on the brand's ledger")
}
if n := fc.usages(); n != 0 {
t.Errorf("commerce saw %d usage posts for an org-less debit, want 0", n)
}
}
+11 -14
View File
@@ -5,24 +5,21 @@ import (
"net/http/httptest"
"testing"
"github.com/hanzoai/beego/v2/server/web"
"github.com/zap-proto/zip"
)
// The EMBED session→principal bridge (validatedPrincipal → sessionAccessToken) must
// be a SAFE NO-OP whenever there is no in-process IAM session manager — i.e. on any
// binary that does not mount clients/iam (and in these tests, where GlobalSessions
// is never initialised). A first-party-session cookie present WITHOUT a bearer and
// WITHOUT a session manager must resolve ANONYMOUS (never a principal, never a panic),
// so the bridge can never widen auth on a non-embed deployment.
// A first-party session cookie presented WITHOUT a bearer must resolve ANONYMOUS —
// never a principal, never a panic — so the session bridge can never widen auth.
//
// This used to be conditional on Beego's global session manager being nil, which
// is how the retired Casdoor iam-v1 embed stored sessions. That embed is gone and
// IAM v2 is zip-native, so nothing populates that global and sessionAccessToken is
// now unconditionally "". The property under test is unchanged and is now
// unconditional too: no skip, no framework global, just the guarantee.
func TestSessionBridge_NoSessionManager_IsAnonymous(t *testing.T) {
if web.GlobalSessions != nil {
t.Skip("a session manager is initialised in this process; the no-op path is not exercised")
}
name := web.BConfig.WebConfig.Session.SessionName
if name == "" {
name = "beegosessionID"
}
// The cookie name the retired embed used. Any opaque session id must be
// ignored regardless of what it is called.
const name = "beegosessionID"
app, got := newIdentityApp(t, nil) // nil validator: no JWT path can validate either
probe(t, app, func(r *http.Request) {
+21
View File
@@ -0,0 +1,21 @@
package cloud
import "testing"
// A publishable key must never authenticate. It ships in browser bundles, so if
// it minted a principal every visitor would hold a reading credential for the
// org that owns it.
func TestPublishableKeyIsNotAPrincipal(t *testing.T) {
if !IsPublishableKey("pk-abc123") {
t.Fatal("pk- must be recognised as publishable")
}
for _, tok := range []string{"sk-abc123", "hk-abc123", "eyJhbGciOi.x.y", ""} {
if IsPublishableKey(tok) {
t.Fatalf("%q must NOT be publishable", tok)
}
}
// It stays an API key so OrgForKey can still attribute an ingest beacon.
if !isAPIKey("pk-abc123") {
t.Fatal("pk- must remain in APIKeyPrefixes so OrgForKey resolves it")
}
}
+7 -6
View File
@@ -109,7 +109,7 @@ func TestResourceMeter_GateAllowsFundedCallerOrg(t *testing.T) {
}
}
// payerFor resolves principal.HomeOrg(c) — the HOME org that PAYS — from a request's
// payerFor resolves principal.Ledger(c) — the HOME org that PAYS — from a request's
// identity headers, exactly as a create-handler does before passing it to Gate.
func payerFor(t *testing.T, headers map[string]string) string {
t.Helper()
@@ -117,7 +117,7 @@ func payerFor(t *testing.T, headers map[string]string) string {
done := make(chan struct{})
app := zip.New(zip.Config{})
app.Use(func(c *zip.Ctx) error {
payer = principal.HomeOrg(c)
payer = principal.Ledger(c)
close(done)
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
})
@@ -133,7 +133,7 @@ func payerFor(t *testing.T, headers map[string]string) string {
}
// TestResourceMeter_GateKeysOnPayerForMasqueradingAdmin (LOW-1 fast-follow): the ml
// + provisioning create-handlers pass principal.HomeOrg(c) (the HOME org) to the
// + provisioning create-handlers pass principal.Ledger(c) (the HOME org) to the
// pre-create balance Gate, matching the paired debit. So a masquerading SuperAdmin
// (home=admin via X-User-Owner, acting in a victim org via X-Org-Id) is balance-gated
// on the ADMIN's funds — never the victim's. Before the fix these two Gates keyed on
@@ -143,9 +143,10 @@ func payerFor(t *testing.T, headers map[string]string) string {
func TestResourceMeter_GateKeysOnPayerForMasqueradingAdmin(t *testing.T) {
// A create-handler resolves Payer(c) from the request; for a masquerade it is home.
payer := payerFor(t, map[string]string{
"X-User-Id": "u_admin", // validated principal
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
"X-User-Owner": "admin", // HOME — the identity + billing anchor
"X-User-Id": "u_admin", // validated principal
"X-Org-Id": "victim", // EFFECTIVE — the org being acted on
"X-User-Owner": "admin", // HOME — the identity + billing anchor
"X-User-IsAdmin": "true", // platform sudo — what makes this a MASQUERADE
})
if payer != "admin" {
t.Fatalf("principal.Payer for a masquerade = %q, want admin (HOME org)", payer)