Hanzo CI/CD / cicd (push) Successful in 21s
CI/CD / gate (push) Successful in 22s
CI/CD / containment (push) Successful in 1m46s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
iamurl.go opens by saying the IAM-address policy "existed as three inlined copies ... so there is exactly one now". There were four, and they had grown three different fallbacks: iamurl.go IAM_URL, else the public issuer (the policy) auth_apikey.go IAM_URL, else IAM_INTERNAL_URL, else NOTHING apps/account/iam.go IAM_URL, else a hardcoded cluster address apps/platform re-read IAM_URL to answer a different question A copy of a policy does not disagree until one is edited. These had already diverged: auth_apikey read a fifth env name no cloud deployment sets (IAM_INTERNAL_URL is on admin-guard and chat only), so a single-process deploy that knew only its issuer resolved "" and API-key auth stayed silently unconfigured; apps/account reached a cluster address that a non-cluster deploy does not have. iamurl.go now answers both questions the estate actually asks, over ONE env read: IAMBase() for the address, IAMExternal() for whether a separate IAM is named. They are separate because conflating them IS the bug — a deployment with only a public issuer resolves a real address while naming no external IAM, and a caller inferring one from the other reaches for a store that is not there. TestIAMAddressHasOneReader walks the tree and fails on a second reader, so this cannot drift back. Also: pickCompletionsClient preferred the PUBLIC gateway over the in-cluster peer. `ai` is a plugin of this same binary running as its own process, so that ordering sent the pod out through Cloudflare and back, minting an OAuth token to authenticate to its own deployment, to reach code one socket away. The peer is now first. Ordered, not gated: nothing sets CLOUD_AI_ZAP_ADDR today, so this is inert until an address is named and the gateway keeps answering. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
60 lines
2.9 KiB
Go
60 lines
2.9 KiB
Go
package cloud
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// IAMBaseURL resolves the IAM base URL a SERVER-SIDE caller inside the cluster
|
|
// should use — the split-horizon policy, stated once.
|
|
//
|
|
// The public issuer host (e.g. https://hanzo.id) is fronted by Cloudflare, which
|
|
// 403s a server-side loopback POST with edge error 1006 — so an in-cluster
|
|
// exchange against the public issuer fails and whatever depended on it (the KMS
|
|
// login broker, AI M2M minting, per-tenant identity provisioning) silently stays
|
|
// down (root-caused 2026-07-04: in-cluster POST to https://hanzo.id/... → 403,
|
|
// while http://iam.hanzo.svc/... → 200).
|
|
//
|
|
// Precedence: the in-cluster IAM service base (IAM_URL — already wired for
|
|
// JWKS), then the public issuer as a last resort (single-process / no
|
|
// split-horizon deploys). Returns "" only when no IAM is resolvable at all.
|
|
// This existed as three inlined copies (ai M2M, the KMS login broker, and the
|
|
// per-tenant identity provisioner would have been the fourth); a copy of a
|
|
// policy does not disagree until one is edited, so there is exactly one now.
|
|
// Endpoint-specific overrides (CLOUD_AI_IAM_TOKEN_URL, CLOUD_KMS_IAM_TOKEN_URL)
|
|
// stay with their endpoints — they override a URL, not this policy.
|
|
func IAMBaseURL(publicIssuer string) string {
|
|
if base := iamService(); base != "" {
|
|
return base
|
|
}
|
|
return strings.TrimRight(strings.TrimSpace(publicIssuer), "/")
|
|
}
|
|
|
|
// IAMIssuer is the PUBLIC identity host this deployment presents (hanzo.id).
|
|
// One name for one fact: the issuer stamped into a token and the issuer a
|
|
// validator checks are the same string, so they are read in one place.
|
|
func IAMIssuer() string { return strings.TrimSpace(os.Getenv("CLOUD_IAM_ISSUER")) }
|
|
|
|
// IAMBase is IAMBaseURL against this deployment's OWN issuer — what a caller
|
|
// with no Config value in hand uses. It exists so "which IAM do I call" has one
|
|
// answer whether or not the caller happens to be holding a Config: three callers
|
|
// resolved it themselves and grew three different fallbacks (the public issuer,
|
|
// nothing at all, and a hardcoded cluster address), which is the disagreement
|
|
// this file was written to prevent and did not.
|
|
func IAMBase() string { return IAMBaseURL(IAMIssuer()) }
|
|
|
|
// IAMExternal reports whether this deployment NAMES a separate IAM, as opposed
|
|
// to being the IAM itself (the embedded subsystem).
|
|
//
|
|
// It is a different question from IAMBaseURL — "is there another IAM" versus
|
|
// "what is its address" — and it is here because answering it separately is how
|
|
// the two disagree: a deployment with only a public issuer resolves a non-empty
|
|
// base yet names no external IAM, and a caller that inferred one from the other
|
|
// reached for a store that is not there.
|
|
func IAMExternal() bool { return iamService() != "" }
|
|
|
|
// iamService is the in-cluster IAM address, the ONE env read behind all three.
|
|
func iamService() string {
|
|
return strings.TrimRight(strings.TrimSpace(os.Getenv("IAM_URL")), "/")
|
|
}
|