mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Claims read from the identity headers fell back to X-Org-Id for the home org whenever X-User-Owner was absent. The boundary mints X-User-Owner for every principal it validates, so an absent one means no validated home, and X-Org-Id is then only the org the request named. Such claims now carry Attested, and homeOrg reads their home from X-User-Owner alone. The JWT path, where Owner is the owner claim, is unchanged. Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
480 lines
21 KiB
Go
480 lines
21 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
// Package iammiddleware is the gateway-trust shim for legacy call
|
|
// sites. It used to do JWKS fetch + JWT validation in-binary (293
|
|
// LOC). That trust boundary is now hanzoai/gateway: gateway validates
|
|
// the JWT, populates X-Org-Id / X-User-Id / X-User-Email, and only
|
|
// gateway-routed traffic reaches commerced.
|
|
//
|
|
// This file preserves the public API the rest of commerce depends on
|
|
// (Init, Client, IAMTokenRequired, IsIAMAuthenticated,
|
|
// GetIAMClaims, GetIAMTier) so the 13 call sites compile, but every
|
|
// function reads identity from the gateway-supplied headers via
|
|
// pkg/auth.
|
|
//
|
|
// Deletion target: once all call sites migrate to pkg/auth + pkg/org,
|
|
// this file can be removed wholesale.
|
|
package iammiddleware
|
|
|
|
import (
|
|
"context"
|
|
nethttp "net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
pkgAuth "github.com/hanzoai/commerce/pkg/auth"
|
|
"github.com/hanzoai/commerce/pkg/org"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
jsonhttp "github.com/hanzoai/commerce/util/json/http"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
)
|
|
|
|
// orgAdminGrant is the org-SCOPED merchant-management authority a gateway-verified
|
|
// ORG admin (X-User-IsAdmin=="true") carries for its OWN org — the catalog
|
|
// primitives self-serve onboarding needs (create a store, then its products,
|
|
// collections and variants). IAMTokenRequired ORs it into the request permissions
|
|
// ONLY for the caller's resolved namespace (X-Org-Id), so it never crosses tenants.
|
|
//
|
|
// It EXCLUDES permission.Admin by design: Admin is the money/platform authority
|
|
// (credit-mint, card-charge and cross-org billing gates key on it — see
|
|
// edgeauth.permsHeader), reserved for the service token and platform super-admins.
|
|
// An org owner managing its own store must never gain it.
|
|
var orgAdminGrant = bit.Field(
|
|
permission.Store | permission.ReadStore | permission.WriteStore |
|
|
permission.Product | permission.ReadProduct | permission.WriteProduct |
|
|
permission.Collection | permission.ReadCollection | permission.WriteCollection |
|
|
permission.Variant | permission.ReadVariant | permission.WriteVariant,
|
|
)
|
|
|
|
// isOrgAdmin reports whether the identity edge marked this request's caller an
|
|
// ORG-level admin via a trusted (stripped-then-reminted on ingress) header. The
|
|
// in-process host (cloud SanitizeIdentity) mints X-User-IsOrgAdmin for an org admin
|
|
// and X-User-IsAdmin only for a SUPERADMIN — so a real merchant admin carries the
|
|
// FORMER, not the latter; reading only X-User-IsAdmin here (the gateway-era name)
|
|
// left every non-superadmin org admin without WriteStore → self-serve create-store
|
|
// 403'd. Accept EITHER: X-User-IsOrgAdmin (the org admin) OR X-User-IsAdmin (a
|
|
// superadmin, who administers their own org too). Both are authority headers,
|
|
// stripped+re-minted on ingress, so neither is client-forgeable. Only "true"
|
|
// (case-insensitive) counts; absent/any other value fails closed. The caller's
|
|
// call site additionally binds the grant to home==effective (orgAdminHomeMatches).
|
|
func isOrgAdmin(c *zip.Ctx) bool {
|
|
return strings.EqualFold(strings.TrimSpace(c.Header(HeaderUserIsOrgAdmin)), "true") ||
|
|
strings.EqualFold(strings.TrimSpace(c.Header(HeaderUserIsAdmin)), "true")
|
|
}
|
|
|
|
// ActsAsOrgAdmin reports whether the caller administers the org this request acts
|
|
// in: the identity edge mints X-User-IsOrgAdmin from the caller's role in the
|
|
// EFFECTIVE org of the signed membership set, so switching into an org one merely
|
|
// belongs to never carries it. It is an authority header, stripped and re-minted
|
|
// on ingress.
|
|
func ActsAsOrgAdmin(c *zip.Ctx) bool {
|
|
return strings.EqualFold(strings.TrimSpace(c.Header(HeaderUserIsOrgAdmin)), "true")
|
|
}
|
|
|
|
// orgAdminHomeMatches reports whether the caller's HOME org (X-User-Owner) equals
|
|
// the EFFECTIVE org this request acts in (effectiveOrg == the resolved X-Org-Id).
|
|
// The org-admin grant is authority over one's OWN org; this is the tenant check that
|
|
// keeps an org-switched principal (home != effective) from inheriting merchant
|
|
// authority over a foreign org. Fail closed: a missing/blank home org never matches
|
|
// a non-empty effective org. Both are gateway-minted + stripped-on-ingress, so
|
|
// neither is client-forgeable; this enforces their coupling rather than assuming it.
|
|
func orgAdminHomeMatches(c *zip.Ctx, effectiveOrg string) bool {
|
|
home := strings.TrimSpace(c.Header(HeaderUserOwner))
|
|
eff := strings.TrimSpace(effectiveOrg)
|
|
if home == "" || eff == "" {
|
|
return false
|
|
}
|
|
return strings.EqualFold(home, eff)
|
|
}
|
|
|
|
var (
|
|
mu sync.RWMutex
|
|
initialized bool
|
|
iamClient *auth.IAMClient
|
|
)
|
|
|
|
// Init builds the IAM client used by the directly-exposed commerce-api
|
|
// edge — the surfaces that face a raw user Bearer JWT instead of
|
|
// gateway-minted headers: EdgeAuth (middleware/edgeauth.go) and the
|
|
// /admin/billing UI gate (billing/handler.go). It verifies tokens
|
|
// against the IAM JWKS. The gateway-trust middleware chain
|
|
// (IAMTokenRequired) is unchanged — it still reads validated headers.
|
|
//
|
|
// A nil config or a build error leaves the client nil, so those edge
|
|
// surfaces fail closed (every request treated as unauthorized).
|
|
func Init(cfg *auth.IAMConfig) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
initialized = true
|
|
if cfg == nil {
|
|
iamClient = nil
|
|
return nil
|
|
}
|
|
cl, err := auth.NewIAMClient(cfg)
|
|
if err != nil {
|
|
iamClient = nil
|
|
return err
|
|
}
|
|
iamClient = cl
|
|
return nil
|
|
}
|
|
|
|
// Client returns the initialized IAM client, or nil if IAM is disabled or
|
|
// Init() has not been called. Consumers outside the middleware chain (e.g.
|
|
// SPA handlers with their own auth gate) use this to validate bearer tokens
|
|
// against the same JWKS the /v1 middleware uses. Fail-closed: a nil return
|
|
// means "treat every request as unauthenticated".
|
|
//
|
|
// Returns the client built by Init from the IAM config, or nil when IAM
|
|
// is disabled / misconfigured (fail-closed at the call site). This is the
|
|
// non-gateway entry point hook: commerce is exposed directly, not behind
|
|
// hanzoai/gateway, so its edge surfaces verify the JWT here.
|
|
func Client() *auth.IAMClient {
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
return iamClient
|
|
}
|
|
|
|
// IAMTokenRequired returns a zip middleware that:
|
|
// 1. Reads the gateway-supplied X-Org-Id / X-User-Id / X-User-Email
|
|
// headers (already JWT-validated upstream).
|
|
// 2. Resolves the Organization via pkg/org.Resolve (cached).
|
|
// 3. Sets the legacy context keys downstream handlers expect:
|
|
// iam_authenticated, iam_org, iam_user_id, iam_email,
|
|
// organization, active-organization, permissions.
|
|
//
|
|
// Missing headers: falls through (handler chain may use a legacy
|
|
// org-token instead). The gateway is the trust boundary; commerced is
|
|
// only reachable via the gateway in production, where COMMERCED_REQUIRE_IDENTITY
|
|
// rejects header-less requests at the edge of the binary.
|
|
func IAMTokenRequired() zip.Handler {
|
|
return func(c *zip.Ctx) error {
|
|
// pkg/auth has already attached headers to ctx; read from there.
|
|
// If pkg/auth wasn't installed, fall back to direct header reads
|
|
// so this middleware still works in legacy mounts.
|
|
ctx := c.Context()
|
|
ownerID := pkgAuth.OrgID(ctx)
|
|
userID := pkgAuth.UserID(ctx)
|
|
email := pkgAuth.UserEmail(ctx)
|
|
if ownerID == "" {
|
|
ownerID = c.Header(pkgAuth.HeaderOrgID)
|
|
}
|
|
if userID == "" {
|
|
userID = c.Header(pkgAuth.HeaderUserID)
|
|
}
|
|
if email == "" {
|
|
email = c.Header(pkgAuth.HeaderUserEmail)
|
|
}
|
|
|
|
// A VALIDATED PRINCIPAL, not merely an org selector.
|
|
//
|
|
// This used to admit on ownerID alone, so the mere PRESENCE of X-Org-Id
|
|
// authenticated the request. That is the forge: cloud's identity boundary
|
|
// deliberately RESTORES a client's raw X-Org-Id on the bearer-less Phase-1
|
|
// data path (middleware_identity.go, "PHASE-1 RESIDUAL") while leaving
|
|
// X-User-Id empty, so an off-gateway caller could send `X-Org-Id: victim`
|
|
// with no credential at all and be treated as authenticated. Proven live
|
|
// against api.hanzo.ai: GET /v1/store/current and /v1/store/access answered
|
|
// 401 with no headers and 200 with `X-Org-Id: hanzo` and no token — another
|
|
// org's store record and entitlement status, read anonymously.
|
|
//
|
|
// The permission bits were already safe (they come only from
|
|
// X-User-Permissions and fail closed at zero), which is why masked routes
|
|
// held. The hole was the no-mask reads, which need only iam_authenticated.
|
|
//
|
|
// X-User-Id is the right predicate because it is the ONE thing a client
|
|
// cannot supply: every authority header is stripped on ingress and X-User-Id
|
|
// is re-injected ONLY from a verified credential, never restored from client
|
|
// input. This is exactly cloud's own gate — clients/principal.Validated is
|
|
// `c.User() != ""`, described there as "the ONE predicate that separates a
|
|
// gateway-minted identity from a client-forged X-Org-Id on the bearer-less
|
|
// path" — and cloud's whole data plane already enforces it. Commerce was the
|
|
// outlier, so this removes a divergence rather than adding a rule.
|
|
//
|
|
// Nothing legitimate loses access, because this branch was never how the
|
|
// credentialed callers authenticate:
|
|
// - the internal service token is checked FIRST, ahead of the IAM branch
|
|
// (middleware/accesstoken.go);
|
|
// - a legacy per-org access token is verified in its own later branch;
|
|
// - a real user always carries X-User-Id, minted from their validated JWT.
|
|
// What is refused is precisely the credential-less org selector.
|
|
//
|
|
// Falling through (rather than 401 here) keeps this middleware additive:
|
|
// TokenRequired still gets to authenticate a service or per-org token, and
|
|
// only a caller with no credential at all reaches its 401.
|
|
if ownerID == "" || userID == "" {
|
|
// No validated principal — fall through to legacy auth.
|
|
return c.Next()
|
|
}
|
|
|
|
// Bound DB context — request ctx may be canceled by a navigation.
|
|
dbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
o, err := org.Resolve(dbCtx, ownerID)
|
|
if err != nil {
|
|
log.Warn("iammiddleware: org resolve failed for %q: %v", ownerID, err)
|
|
return jsonhttp.Fail(c, nethttp.StatusServiceUnavailable,
|
|
"Unable to resolve organization: "+err.Error(), err)
|
|
}
|
|
|
|
// Gateway-trusted identity counts as live by default. An explicit
|
|
// gateway-propagated X-Hanzo-Test: true opts the request into TEST
|
|
// mode (org.Live=false) so charges hit sandbox processors and write
|
|
// the test ledger — the same Live=false semantics a test access
|
|
// token carries (see middleware/accesstoken.go). The gateway is the
|
|
// trust boundary: it only forwards this header for test orgs. The
|
|
// flag is additive and never widens scope (test is strictly less
|
|
// privileged than live).
|
|
o.Live = liveFromHeaders(c)
|
|
|
|
// Permissions are derived strictly from gateway-supplied headers,
|
|
// never granted by mere presence of identity. The gateway MUST
|
|
// mint X-User-Permissions from the validated JWT (see
|
|
// hanzoai/gateway/auth_middleware.go and HEADERS.md). If the
|
|
// header is absent we fail closed: zero permissions, no Admin,
|
|
// no Live. The gateway is the trust boundary; this binary
|
|
// trusts the bits it provides and nothing else.
|
|
perms := parsePermissionsHeader(c.Header(HeaderUserPermissions))
|
|
// The platform principal — home owner "admin", a SuperAdmin or the
|
|
// platform's own application — holds the Admin bit by what it IS, not by
|
|
// a permissions header. The gateway mints no X-User-Permissions for it,
|
|
// and a shared service token used to supply these bits in a branch of
|
|
// its own; that token is gone, so the claim supplies them.
|
|
if strings.EqualFold(strings.TrimSpace(c.Header(HeaderUserOwner)), "admin") {
|
|
if liveFromHeaders(c) {
|
|
perms |= bit.Field(permission.Admin | permission.Live)
|
|
} else {
|
|
perms |= bit.Field(permission.Admin | permission.Test)
|
|
}
|
|
}
|
|
|
|
// An ORG-level admin (gateway/EdgeAuth-minted X-User-IsAdmin=="true", e.g.
|
|
// an org owner like maxpower) additionally carries org-SCOPED merchant
|
|
// authority for its OWN org, so self-serve onboarding works: create a store,
|
|
// then its catalog (products/collections/variants). It deliberately EXCLUDES
|
|
// permission.Admin — the money/platform authority (credit-mint, card-charge,
|
|
// cross-org billing gates key on it; see edgeauth.permsHeader) reserved for
|
|
// the service token and platform super-admins.
|
|
//
|
|
// Bind the grant to HOME==EFFECTIVE: the org-admin flag authorizes the caller's
|
|
// HOME org (X-User-Owner) only. Applying it when the effective namespace
|
|
// (X-Org-Id, resolved into ownerID) differs would let an org-switched principal
|
|
// gain merchant authority over a FOREIGN org. Enforced HERE (defense in depth),
|
|
// not merely trusted from the gateway by comment — a money/multi-tenant boundary
|
|
// must not delegate its own tenant check.
|
|
if isOrgAdmin(c) && orgAdminHomeMatches(c, ownerID) {
|
|
perms |= orgAdminGrant
|
|
}
|
|
|
|
// Mirror onto request locals for legacy handlers.
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("iam_user_id", userID)
|
|
c.Locals("iam_email", email)
|
|
c.Locals("iam_org", ownerID)
|
|
c.Locals("organization", o)
|
|
c.Locals("active-organization", o.Id())
|
|
c.Locals("permissions", perms)
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// HeaderTest is the gateway-propagated test-mode signal. When the
|
|
// gateway forwards "true" (only for test orgs), the request runs in
|
|
// TEST mode: org.Live=false, charges hit sandbox processors, ledger
|
|
// rows are flagged Test. Absent/any-other value ⇒ live. Mirrors the
|
|
// X-Hanzo-Test semantics in middleware/accesstoken.go. The gateway is
|
|
// the trust boundary; commerced trusts the bit it forwards.
|
|
const HeaderTest = "X-Hanzo-Test"
|
|
|
|
// liveFromHeaders reports whether a gateway-trusted request is live.
|
|
// It is live unless the gateway forwarded HeaderTest == "true". This
|
|
// is the single place that turns the gateway test signal into the
|
|
// org.Live authority that payment.ProcessorsForOrg keys sandbox-vs-
|
|
// production off of (see payment/orgsetup.go SquareConfig(!org.Live)).
|
|
// Forwards-only: never widen test → live based on identity presence.
|
|
func liveFromHeaders(c *zip.Ctx) bool {
|
|
return !strings.EqualFold(strings.TrimSpace(c.Header(HeaderTest)), "true")
|
|
}
|
|
|
|
// HeaderUserPermissions is the canonical gateway-minted permission
|
|
// header. It carries the bit.Field value as a base-10 int64 string
|
|
// (e.g. "3" for Live|Test). The gateway MUST set it from the
|
|
// validated JWT roles/claims; commerced reads it as-is. Missing or
|
|
// malformed values fail closed (zero permissions). Documented in
|
|
// HEADERS.md.
|
|
const HeaderUserPermissions = "X-User-Permissions"
|
|
|
|
// parsePermissionsHeader converts the gateway-minted X-User-Permissions
|
|
// value into a bit.Field. Empty or invalid input fails closed (zero
|
|
// permissions). This is the only path that turns gateway intent into
|
|
// commerced permissions; do not introduce defaults that grant rights
|
|
// based on identity presence alone.
|
|
func parsePermissionsHeader(v string) bit.Field {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return bit.Field(0)
|
|
}
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || n < 0 {
|
|
return bit.Field(0)
|
|
}
|
|
return bit.Field(n)
|
|
}
|
|
|
|
// IsIAMAuthenticated reports whether a VALIDATED IAM identity is attached to the
|
|
// request — i.e. IAMTokenRequired resolved the org from the trusted (gateway- or
|
|
// EdgeAuth-minted, post-strip) X-Org-Id and set iam_authenticated. It does NOT
|
|
// fall back to the raw X-Org-Id header: trusting mere header presence let an
|
|
// unvalidated opaque bearer + a client X-Org-Id impersonate any org (the checkout
|
|
// money-surface bypass). IAMTokenRequired runs on every /v1 and /v1/commerce
|
|
// group, so the local-key check covers every legitimate IAM caller; an
|
|
// opaque service token authorizes through TokenRequired's service-token branch
|
|
// instead, never here.
|
|
func IsIAMAuthenticated(c *zip.Ctx) bool {
|
|
if v := c.Locals("iam_authenticated"); v != nil {
|
|
if b, ok := v.(bool); ok && b {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetIAMClaims returns a non-nil *auth.IAMClaims populated from the
|
|
// gateway-minted identity headers. The gateway validated the JWT and
|
|
// stamped X-Org-Id, X-User-Id, X-User-Email, X-User-IsAdmin, X-Roles
|
|
// (see hanzoai/gateway/auth_middleware.go). commerced trusts those
|
|
// bits and reflects them into a claims struct so call sites can read
|
|
// IsAdmin / Owner / Subject / Roles uniformly.
|
|
//
|
|
// Fail-closed contract: missing headers map to zero-valued fields. In
|
|
// particular, missing X-User-IsAdmin yields IsAdmin=false (not "unknown").
|
|
// Call sites MUST NOT nil-guard the return — it is always non-nil.
|
|
//
|
|
// The legacy in-test path stores a *auth.IAMClaims under the
|
|
// "iam_claims" local; that wins when present so tests can inject
|
|
// arbitrary claim shapes without going through HTTP.
|
|
func GetIAMClaims(c *zip.Ctx) *auth.IAMClaims {
|
|
if c == nil {
|
|
return &auth.IAMClaims{}
|
|
}
|
|
if v := c.Locals("iam_claims"); v != nil {
|
|
if claims, ok := v.(*auth.IAMClaims); ok && claims != nil {
|
|
return claims
|
|
}
|
|
}
|
|
owner := strings.TrimSpace(c.Header(pkgAuth.HeaderOrgID))
|
|
user := strings.TrimSpace(c.Header(pkgAuth.HeaderUserID))
|
|
email := strings.TrimSpace(c.Header(pkgAuth.HeaderUserEmail))
|
|
isAdmin := strings.EqualFold(strings.TrimSpace(c.Header(HeaderUserIsAdmin)), "true")
|
|
// HOME org — the identity + platform-sudo anchor (the gateway/EdgeAuth
|
|
// X-User-Owner header, the validated JWT `owner`). DISTINCT from Owner (==
|
|
// X-Org-Id, the EFFECTIVE org a SuperAdmin may switch to another tenant):
|
|
// IsSuperAdmin gates on this, so owner=="admin" survives an org-switch. The
|
|
// boundary mints it for every validated principal, so its absence means no
|
|
// validated home, and Attested keeps homeOrg() from reading the caller's own
|
|
// X-Org-Id in its place. No spoofable isGlobalAdmin boolean is read — the org
|
|
// IS the signal.
|
|
homeOrg := strings.TrimSpace(c.Header(HeaderUserOwner))
|
|
roles := parseRolesHeader(c.Header(HeaderRoles))
|
|
|
|
claims := &auth.IAMClaims{
|
|
Owner: owner,
|
|
HomeOrg: homeOrg,
|
|
Attested: true,
|
|
Name: user,
|
|
Email: email,
|
|
IsAdmin: isAdmin,
|
|
Roles: roles,
|
|
}
|
|
// Subject is the canonical user id field IAMClaims callers read;
|
|
// the gateway puts the JWT sub into X-User-Id.
|
|
claims.Subject = user
|
|
return claims
|
|
}
|
|
|
|
// HeaderUserIsAdmin is the gateway-minted "true"/"" ORG-level admin flag
|
|
// (set for an org owner). Only "true" (case-insensitive) is treated as admin;
|
|
// any other value (including absent) fails closed to false. It is org-scoped
|
|
// only — NEVER gate cross-org/superadmin actions on it; use IsSuperAdmin().
|
|
const HeaderUserIsAdmin = "X-User-IsAdmin"
|
|
|
|
// HeaderUserIsOrgAdmin is the identity edge's ORG-level admin flag. cloud's
|
|
// SanitizeIdentity (commerce's in-process host) mints THIS for an org admin
|
|
// (claims.IsAdmin) and reserves X-User-IsAdmin for a SUPERADMIN — so a real merchant
|
|
// admin carries X-User-IsOrgAdmin, not X-User-IsAdmin. It is an authority header,
|
|
// stripped-then-reminted on ingress (unforgeable). isOrgAdmin reads it (OR
|
|
// X-User-IsAdmin) so a non-superadmin org admin gets the self-serve store grant.
|
|
const HeaderUserIsOrgAdmin = "X-User-IsOrgAdmin"
|
|
|
|
// HeaderUserOwner is the gateway/EdgeAuth-minted HOME-org header — the validated
|
|
// JWT `owner`, the identity + platform-sudo anchor. DISTINCT from X-Org-Id (the
|
|
// EFFECTIVE org a SuperAdmin can switch to another tenant): IsSuperAdmin gates on
|
|
// this so owner=="admin" survives an org-switch. Replaces the removed
|
|
// X-User-IsGlobalAdmin boolean — the org IS the signal, not a spoofable flag.
|
|
const HeaderUserOwner = "X-User-Owner"
|
|
|
|
// HeaderRoles is the canonical comma-joined role-name header set by
|
|
// the gateway from the JWT roles claim. Empty value -> no roles.
|
|
const HeaderRoles = "X-Roles"
|
|
|
|
// parseRolesHeader splits the X-Roles comma list into a FlexRoles
|
|
// slice, trimming whitespace and dropping empties.
|
|
func parseRolesHeader(v string) auth.FlexRoles {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(v, ",")
|
|
out := make(auth.FlexRoles, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HeaderTier is the optional gateway-minted tier override. When the gateway
|
|
// resolves a caller's tier from the validated JWT it MAY stamp X-Tier; commerced
|
|
// honors it as authoritative. Absent (the norm today) → "" so the tier is derived
|
|
// server-side from the caller's subscription (api/billing.deriveTier).
|
|
const HeaderTier = "X-Tier"
|
|
|
|
// GetIAMTier returns the gateway-minted X-Tier override, or "" when absent. Empty
|
|
// is the normal case: the tier is then derived from the subscription store, so a
|
|
// direct GET /v1/billing/tier?user=<subject> resolves the caller's REAL tier with
|
|
// no upstream header. Fail-open to "" on a nil ctx.
|
|
func GetIAMTier(c *zip.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(c.Header(HeaderTier))
|
|
}
|
|
|
|
// orgFromContext is exported for tests that want to assert the legacy
|
|
// "organization" local was populated correctly.
|
|
func orgFromContext(c *zip.Ctx) *organization.Organization {
|
|
if v := c.Locals("organization"); v != nil {
|
|
if o, ok := v.(*organization.Organization); ok {
|
|
return o
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var _ = orgFromContext // referenced in tests
|