Files
gateway/internal/plugin/jose/gin/jose.go
zeekayandHanzo Dev a17d70a0f5 gateway: the platform header comes from platform authority
THE LIVE ESCALATION, in the path that ships. `X-User-IsAdmin` is the fleet's
PLATFORM-authority header — cloud gates its cross-tenant surfaces on it and the
money gates read the bit-field beside it. This edge minted it from
claims.IsAdmin, which is IAM's ORG-ROLE bit: an org owner carries it inside their
own org. So every org owner reached the fleet as a platform admin.

I reported this fixed earlier. It was not. I patched iamauth.InjectIdentity,
which has ZERO non-test callers — the shipped KrakenD middleware and the ZAP
relay each carried their own inline mint, and both still read the org bit. Two
more statements of the same rule, and the one I fixed was the dead one.

Fixed where they run: the HTTP edge (auth_middleware.go) now applies
edge.Mint, and the relay (gate.go) sets f.IsAdmin — which forward re-emits as
X-User-IsAdmin — from PlatformSudo. X-User-IsOrgAdmin is minted for the first
time, so org-scoped self-service has its own header to read instead of borrowing
platform authority.

ONE DECISION. iamauth is DELETED (834 lines) along with the shim over it. What a
claim means, which org a request acts in, who pays, which headers get minted —
all of it is hanzoai/authz and hanzoai/authz/edge now, and net/http's own header
type satisfies edge.Headers, so applying it here is a loop rather than a second
implementation. What is left is the part genuinely this deployment's: package
token holds the issuer and the AUDIENCE ALLOWLIST (authz.Verify deliberately
does not check `aud`, because IAM sets it to the requesting CLIENT), and
identity.go holds the money bit vocabulary and the vendor-prefix strip.

ONE JWT LIBRARY on the identity path. Verification is golang-jwt/v5, the library
IAM SIGNS with, through authz.Verify + the edge's JWKS reader. go-jose is gone
from it, and the tests now mint with the same library they verify with — a test
signing with a different one is testing a signer the estate does not run. It also
makes `kid` mandatory, matching IAM (signClaims always stamps it).

THREE HEADERS DROPPED, because IAM never emitted the claims behind them: `roles`,
`permissions` and `phone` are absent from the signed claim set and from
claims_supported, so the three-shape parsers for the first two read a claim that
never arrived, and X-Roles / X-Phone-Number were always empty. All three names
STAY in the strip set, so a forged copy still never survives. Removing the
permissions parser also closes a latent grant: a token carrying that claim used
to be handed the bits it named.

The vendor-prefix backstop (X-IAM-*, X-HANZO-*) stays here rather than in the
estate's edge, because sweeping prefixes must ENUMERATE header names — which is
transport-specific, while edge.Headers is deliberately Get/Set/Del. It is folded
into one stripClaimed call with the exact-name strip, because two calls would be
an ordering trap that fails silently.

Falsified both ways: restoring `if cl.IsAdmin` in edge.Mint turns
TestOrgOwnerIsNotAPlatformAdmin and TestAdminOrgMachineGetsNeitherAdminHeader
red; granting the admin bit from the org role turns
TestOrgAdminNeverMintsPlatformAuthority and TestPermissions_OrgAdminGetsNoAdminBit
red. Green on both build tags, including the `legacy` tag production ships.
tests/ stays red on the KrakenD fixture suite — verified identical on a stashed
tree, so it is not from this change.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 21:47:50 -07:00

277 lines
8.2 KiB
Go

package gin
import (
"context"
"fmt"
"net/http"
"net/textproto"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hanzoai/gateway/v2/internal/lura/config"
"github.com/hanzoai/gateway/v2/internal/lura/logging"
"github.com/hanzoai/gateway/v2/internal/lura/proxy"
ginlura "github.com/hanzoai/gateway/v2/internal/lura/router/gin"
auth0 "github.com/hanzoai/gateway/v2/internal/pkg/go-auth0"
krakendjose "github.com/hanzoai/gateway/v2/internal/plugin/jose"
)
func HandlerFactory(hf ginlura.HandlerFactory, logger logging.Logger, rejecterF krakendjose.RejecterFactory) ginlura.HandlerFactory {
return TokenSignatureValidator(TokenSigner(hf, logger), logger, rejecterF)
}
func TokenSigner(hf ginlura.HandlerFactory, logger logging.Logger) ginlura.HandlerFactory {
return func(cfg *config.EndpointConfig, prxy proxy.Proxy) gin.HandlerFunc {
logPrefix := "[ENDPOINT: " + cfg.Endpoint + "][JWTSigner]"
signerCfg, signer, err := krakendjose.NewSigner(cfg, nil)
if err == krakendjose.ErrNoSignerCfg {
logger.Debug(logPrefix, "Signer disabled")
return hf(cfg, prxy)
}
if err != nil {
logger.Error(logPrefix, "Unable to create the signer:", err.Error())
return erroredHandler
}
logger.Debug(logPrefix, "Signer enabled")
return func(c *gin.Context) {
proxyReq := ginlura.NewRequest(cfg.HeadersToPass)(c, cfg.QueryString)
ctx, cancel := context.WithTimeout(c, cfg.Timeout)
defer cancel()
response, err := prxy(ctx, proxyReq)
if err != nil {
logger.Error(logPrefix, "Proxy response:", err.Error())
c.AbortWithStatus(http.StatusBadRequest)
return
}
if response == nil {
logger.Error(logPrefix, "Empty proxy response")
c.AbortWithStatus(http.StatusBadRequest)
return
}
if err := krakendjose.SignFields(signerCfg.KeysToSign, signer, response); err != nil {
logger.Error(logPrefix, "Signing fields:", err.Error())
c.AbortWithStatus(http.StatusBadRequest)
return
}
for k, v := range response.Metadata.Headers {
c.Header(k, v[0])
}
c.JSON(response.Metadata.StatusCode, response.Data)
}
}
}
func TokenSignatureValidator(hf ginlura.HandlerFactory, logger logging.Logger, rejecterF krakendjose.RejecterFactory) ginlura.HandlerFactory {
return func(cfg *config.EndpointConfig, prxy proxy.Proxy) gin.HandlerFunc {
logPrefix := "[ENDPOINT: " + cfg.Endpoint + "][JWTValidator]"
if rejecterF == nil {
rejecterF = new(krakendjose.NopRejecterFactory)
}
rejecter := rejecterF.New(logger, cfg)
handler := hf(cfg, prxy)
scfg, err := krakendjose.GetSignatureConfig(cfg)
if err == krakendjose.ErrNoValidatorCfg {
logger.Info(logPrefix, "Validator disabled for this endpoint")
return handler
}
if err != nil {
logger.Warning(logPrefix, "Unable to parse the configuration:", err.Error())
return erroredHandler
}
validator, err := krakendjose.NewValidator(scfg, FromCookie, FromHeader)
if err != nil {
logger.Fatal(logPrefix, "Unable to create the validator:", err.Error())
return erroredHandler
}
var aclCheck func(string, map[string]interface{}, []string) bool
if scfg.RolesKeyIsNested && strings.Contains(scfg.RolesKey, ".") && scfg.RolesKey[:4] != "http" {
logger.Debug(logPrefix, fmt.Sprintf("Roles will be matched against the nested key: '%s'", scfg.RolesKey))
aclCheck = krakendjose.CanAccessNested
} else {
logger.Debug(logPrefix, fmt.Sprintf("Roles will be matched against the key: '%s'", scfg.RolesKey))
aclCheck = krakendjose.CanAccess
}
var scopesMatcher func(string, map[string]interface{}, []string) bool
if len(scfg.Scopes) > 0 && scfg.ScopesKey != "" {
if scfg.ScopesMatcher == "all" {
logger.Debug(logPrefix, fmt.Sprintf("Constraint added: tokens must contain a claim '%s' with all these scopes: %v", scfg.ScopesKey, scfg.Scopes))
scopesMatcher = krakendjose.ScopesAllMatcher
} else {
logger.Debug(logPrefix, fmt.Sprintf("Constraint added: tokens must contain a claim '%s' with any of these scopes: %v", scfg.ScopesKey, scfg.Scopes))
scopesMatcher = krakendjose.ScopesAnyMatcher
}
} else {
logger.Debug(logPrefix, "No scope validation required")
scopesMatcher = krakendjose.ScopesDefaultMatcher
}
if scfg.OperationDebug {
logger.Debug(logPrefix, "Validator enabled for this endpoint. Operation debug is enabled")
} else {
logger.Debug(logPrefix, "Validator enabled for this endpoint")
}
paramExtractor := extractRequiredJWTClaims(cfg)
return func(c *gin.Context) {
token, err := validator.ValidateRequest(c.Request)
if err != nil {
if scfg.OperationDebug {
logger.Error(logPrefix, "Unable to validate the token:", err.Error())
}
c.AbortWithStatus(http.StatusUnauthorized)
return
}
claims := map[string]interface{}{}
err = validator.Claims(c.Request, token, &claims)
if err != nil {
if scfg.OperationDebug {
logger.Error(logPrefix, "Token sent by client is invalid:", err.Error())
}
c.AbortWithStatus(http.StatusUnauthorized)
return
}
if rejecter.Reject(claims) {
if scfg.OperationDebug {
logger.Error(logPrefix, "Token sent by client rejected")
}
c.AbortWithStatus(http.StatusUnauthorized)
return
}
if !aclCheck(scfg.RolesKey, claims, scfg.Roles) {
if scfg.OperationDebug {
logger.Error(logPrefix, "Token sent by client does not have sufficient roles")
}
c.AbortWithStatus(http.StatusForbidden)
return
}
if !scopesMatcher(scfg.ScopesKey, claims, scfg.Scopes) {
if scfg.OperationDebug {
logger.Error(logPrefix, "Token sent by client does not have the required scopes")
}
c.AbortWithStatus(http.StatusForbidden)
return
}
propagateHeaders(cfg, scfg.PropagateClaimsToHeader, scfg.PropagateClaimsPreserveArray, claims, c, logger)
paramExtractor(c, claims)
handler(c)
}
}
}
func erroredHandler(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
func propagateHeaders(
cfg *config.EndpointConfig,
propagationCfg [][]string,
propagationPreserveArrays bool,
claims map[string]interface{},
c *gin.Context,
logger logging.Logger,
) {
logPrefix := "[ENDPOINT: " + cfg.Endpoint + "][PropagateHeaders]"
if len(propagationCfg) > 0 {
if !propagationPreserveArrays {
headersToPropagate, err := krakendjose.CalculateHeadersToPropagate(propagationCfg, claims)
if err != nil {
logger.Warning(logPrefix, err.Error())
}
for k, v := range headersToPropagate {
// Set header value - replaces existing one
c.Request.Header.Set(k, v)
}
return
}
headersToPropagate, err := krakendjose.CalculateArrayHeadersToPropagate(propagationCfg, claims)
if err != nil {
logger.Warning(logPrefix, err.Error())
}
for k, v := range headersToPropagate {
c.Request.Header[textproto.CanonicalMIMEHeaderKey(k)] = v
}
}
}
var jwtParamsPattern = regexp.MustCompile(`{{\.JWT\.([^}]*)}}`)
func extractRequiredJWTClaims(cfg *config.EndpointConfig) func(*gin.Context, map[string]interface{}) {
var required []string
for _, backend := range cfg.Backend {
for _, match := range jwtParamsPattern.FindAllStringSubmatch(backend.URLPattern, -1) {
if len(match) < 2 {
continue
}
required = append(required, match[1])
}
}
if len(required) == 0 {
return func(_ *gin.Context, _ map[string]interface{}) {}
}
return func(c *gin.Context, claims map[string]interface{}) {
cl := krakendjose.Claims(claims)
for _, param := range required {
// TODO: check for nested claims
v, ok := cl.Get(param)
if !ok {
continue
}
c.Params = append(c.Params, gin.Param{Key: "JWT." + param, Value: v})
}
}
}
func FromCookie(key string) func(r *http.Request) (*jwt.JSONWebToken, error) {
if key == "" {
key = "access_token"
}
return func(r *http.Request) (*jwt.JSONWebToken, error) {
cookie, err := r.Cookie(key)
if err != nil {
return nil, auth0.ErrTokenNotFound
}
return jwt.ParseSigned(cookie.Value)
}
}
func FromHeader(header string) func(r *http.Request) (*jwt.JSONWebToken, error) {
if header == "" {
header = "Authorization"
}
return func(r *http.Request) (*jwt.JSONWebToken, error) {
raw := r.Header.Get(header)
if len(raw) > 7 && strings.EqualFold(raw[0:7], "BEARER ") {
raw = raw[7:]
}
if raw == "" {
return nil, auth0.ErrTokenNotFound
}
return jwt.ParseSigned(raw)
}
}