Compare commits
10
Commits
lsp
...
v1.801.219
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d19eac9bc9 | ||
|
|
a68f1a01b2 | ||
|
|
218daedb18 | ||
|
|
5ed47be60a | ||
|
|
2dcbbd389f | ||
|
|
130a1e00a8 | ||
|
|
da2d52c06e | ||
|
|
0c365399ca | ||
|
|
2d53f93acf | ||
|
|
96a5495261 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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¤cy=usd
|
||||
if !strings.HasPrefix(gotURL, "/v1/billing/balance?") {
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user