Files
zeekay f7aabf72f7 SuperAdmin is the identity edge's verdict, and commerce compares no org
IsSuperAdmin folded the case of the home org and fell back to the owner
claim, the org of the application that minted the token, and the money
bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for
an admin-org application and an admin-org person's API key as well as for
a person, so either could mint money and edit the catalog here while
being nobody in the rest of the same process. Every gate now reads
X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo
alone; the standalone EdgeAuth decides it with that predicate over the
verified token's signed membership set. X-User-IsAdmin no longer doubles
as the org-admin bit, which is X-User-IsOrgAdmin. No machine is
SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
2026-10-18 22:08:47 -07:00

136 lines
5.6 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
// Package mintauth is the ONE structural money-mint invariant for the commerce
// ledger: a write that MINTS spendable balance (credits the gateway-honored
// IAM-user wallet without a funded source) is refused unless its persistence
// context is authorized to mint.
//
// The invariant lives at the datastore write sink (datastore.Datastore.Put calls
// Enforce), NOT sprinkled across HTTP route gates. That is deliberate — every
// prior round of the money audit found ANOTHER route that reached a mint sink
// (billing/deposit, /zap billing.deposit, /allotment/grant, and then
// /v1/transaction). Gating routes is whack-a-mole; gating the SINK is closed
// form. No handler — present or future, on any route, in any package — can mint
// spendable balance without passing through an authorization, because the ledger
// layer itself refuses the write.
//
// Two orthogonal context markers compose the invariant:
//
// - Gate (WithGate/Gated): set ONCE at the HTTP→datastore boundary
// (Organization.Namespaced, for every inbound *gin.Context), because ANY
// inbound principal is potentially an untrusted org-level admin. A context
// that never crosses that boundary — a background cron, a migration, a unit
// test building a plain context — is UNGATED and mints freely, so the fix is
// backward-compatible with every internal and test flow.
//
// - Grant (WithAuthorized/Authorized): set ONLY by a proven mint authority —
// MayMintMoney success (the internal service token or a platform global
// admin, via PlatformOnly / the ZAP mint gate / the transaction mint gate),
// a settled external payment (top-up charge, provider webhook settlement),
// or a server-fixed / subscription-clamped grant (welcome credit, monthly
// allotment). This is the capability that satisfies the Gate.
//
// Enforce refuses iff the write mints (Guarded.MintRequiresAuthorization) AND the
// context is Gated AND NOT Authorized. Fail-closed.
package mintauth
import (
"context"
"errors"
)
// ErrNotAuthorized is returned by Enforce when a spendable-balance mint is
// attempted through a mint-gated context that carries no mint authorization.
// It is a fail-closed refusal at the ledger layer, independent of any route gate.
var ErrNotAuthorized = errors.New(
"commerce: minting spendable balance requires mint authorization " +
"(internal service token, SuperAdmin, settled payment, or server-fixed grant)")
type gateKey struct{}
type grantKey struct{}
// WithGate marks ctx as mint-gated: a spendable-balance mint flowing through it
// MUST also be authorized (WithAuthorized) or the ledger sink refuses the write.
// Idempotent. Set at the HTTP→datastore boundary for every inbound request.
func WithGate(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if Gated(ctx) {
return ctx
}
return context.WithValue(ctx, gateKey{}, true)
}
// Gated reports whether ctx crossed the HTTP boundary and is therefore subject
// to mint enforcement. An ungated context (background job, migration, test) is
// not enforced.
func Gated(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(gateKey{}).(bool)
return v
}
// WithAuthorized marks ctx as authorized to mint spendable balance. Idempotent.
// Set ONLY after establishing a proven mint authority (see package doc). This is
// the ONE way an HTTP request grants itself the capability the Gate demands.
func WithAuthorized(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if Authorized(ctx) {
return ctx
}
return context.WithValue(ctx, grantKey{}, true)
}
// Authorized reports whether ctx carries mint authorization.
func Authorized(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(grantKey{}).(bool)
return v
}
// Guarded is implemented by ledger entities that can MINT spendable balance. The
// datastore write sink asks the entity whether THIS instance is a spendable mint
// (its money-semantics live with the entity, e.g. models/transaction), and only
// then requires authorization. Non-ledger entities do not implement it and are
// never enforced.
type Guarded interface {
// MintRequiresAuthorization reports whether persisting this entity mints
// spendable balance and therefore demands an authorized (or ungated) context.
MintRequiresAuthorization() bool
}
// Require is THE mint-authority policy in one place: it refuses (ErrNotAuthorized)
// exactly when the context is mint-gated (an inbound HTTP principal) and NOT
// authorized. An ungated context (background cron, migration, unit test) or an
// authorized one passes. Both the ledger write sink (Enforce, below) and the
// chain-backed mint service (treasury.Mint) call it, so "who may mint" is
// defined once and cannot drift between the two enforcement points.
func Require(ctx context.Context) error {
if !Gated(ctx) || Authorized(ctx) {
return nil
}
return ErrNotAuthorized
}
// Enforce is THE money-mint invariant, called at the datastore write sink for
// every entity Put. It refuses a spendable-balance mint when the context is
// mint-gated (an inbound HTTP principal) and not authorized. It is a no-op for
// non-ledger entities, for non-mint writes (withdraws, holds, reads), and for
// ungated contexts (crons, migrations, tests) — so it closes the mint hole
// without touching any legitimate internal flow.
func Enforce(ctx context.Context, val interface{}) error {
g, ok := val.(Guarded)
if !ok || !g.MintRequiresAuthorization() {
return nil
}
return Require(ctx)
}