From cd95ca2c6f148abb8ac1faf40b7efa051af6782f Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 11:26:44 +0330 Subject: [PATCH] =?UTF-8?q?fix(gateway/services):=20admin=20node/render=20?= =?UTF-8?q?pages=20500=20=E2=80=94=20redirect=20loop=20+=20is=5Fadmin=20cl?= =?UTF-8?q?aim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway proxy: trim trailing slash before forwarding upstream. gin's RedirectTrailingSlash adds /nodes → /nodes/ while render-svc redirects /nodes/ → /nodes, forming an infinite redirect loop (admin pages 500'd) - accept is_admin as bool OR string "true" in render/file/notification/gateway auth middleware (identity emits it as a string) — admin endpoints were 403'ing Co-Authored-By: Claude Opus 4.8 --- services/file/internal/middleware/auth.go | 8 +++++++- services/gateway/internal/middleware/auth.go | 16 ++++++++++++++-- services/gateway/internal/proxy/proxy.go | 7 +++++++ .../notification/internal/middleware/auth.go | 8 +++++++- services/render/internal/middleware/auth.go | 10 +++++++++- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/services/file/internal/middleware/auth.go b/services/file/internal/middleware/auth.go index 0905e82..327450a 100644 --- a/services/file/internal/middleware/auth.go +++ b/services/file/internal/middleware/auth.go @@ -57,7 +57,13 @@ func Auth(jwtSecret string) gin.HandlerFunc { } tenantID, _ := uuid.Parse(claims["tenant_id"].(string)) - isAdmin, _ := claims["is_admin"].(bool) + isAdmin := false + switch v := claims["is_admin"].(type) { + case bool: + isAdmin = v + case string: + isAdmin = v == "true" + } c.Set(KeyUserID, userID) c.Set(KeyTenantID, tenantID) diff --git a/services/gateway/internal/middleware/auth.go b/services/gateway/internal/middleware/auth.go index 13c9abe..12177bc 100644 --- a/services/gateway/internal/middleware/auth.go +++ b/services/gateway/internal/middleware/auth.go @@ -75,7 +75,13 @@ func validateAndInject(c *gin.Context, secret string) bool { claims, _ := token.Claims.(jwt.MapClaims) userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) - isAdmin, _ := claims["is_admin"].(bool) + isAdmin := false + switch v := claims["is_admin"].(type) { + case bool: + isAdmin = v + case string: + isAdmin = v == "true" + } role, _ := claims["role"].(string) c.Set(CtxUserID, userID) @@ -116,7 +122,13 @@ func OptionalJWTAuth(secret string) gin.HandlerFunc { claims, _ := token.Claims.(jwt.MapClaims) userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) - isAdmin, _ := claims["is_admin"].(bool) + isAdmin := false + switch v := claims["is_admin"].(type) { + case bool: + isAdmin = v + case string: + isAdmin = v == "true" + } c.Request.Header.Set(HeaderUserID, userID.String()) c.Request.Header.Set(HeaderTenantID, tenantID.String()) if isAdmin { diff --git a/services/gateway/internal/proxy/proxy.go b/services/gateway/internal/proxy/proxy.go index 8902b4d..f8df3fd 100644 --- a/services/gateway/internal/proxy/proxy.go +++ b/services/gateway/internal/proxy/proxy.go @@ -38,6 +38,13 @@ func New(name, target string) *Upstream { orig := rp.Director rp.Director = func(req *http.Request) { orig(req) + // Normalise away a trailing slash (gin's RedirectTrailingSlash adds one when a + // route is registered as catch-all, e.g. /v1/nodes/*path, but upstream services + // register the canonical no-slash form and redirect /nodes/ → /nodes — without + // this the two redirects form an infinite loop). + if len(req.URL.Path) > 1 && strings.HasSuffix(req.URL.Path, "/") { + req.URL.Path = strings.TrimRight(req.URL.Path, "/") + } req.Header.Set("X-Forwarded-Host", req.Host) req.Header.Del("X-Forwarded-For") // let httputil re-add it properly req.Host = u.Host diff --git a/services/notification/internal/middleware/auth.go b/services/notification/internal/middleware/auth.go index 639040a..9c7d12a 100644 --- a/services/notification/internal/middleware/auth.go +++ b/services/notification/internal/middleware/auth.go @@ -37,7 +37,13 @@ func JWTAuth(secret string) gin.HandlerFunc { claims, _ := token.Claims.(jwt.MapClaims) userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) - isAdmin, _ := claims["is_admin"].(bool) + isAdmin := false + switch v := claims["is_admin"].(type) { + case bool: + isAdmin = v + case string: + isAdmin = v == "true" + } c.Set(CtxUserID, userID) c.Set(CtxTenantID, tenantID) c.Set(CtxIsAdmin, isAdmin) diff --git a/services/render/internal/middleware/auth.go b/services/render/internal/middleware/auth.go index 7ce9ca1..a266c0d 100644 --- a/services/render/internal/middleware/auth.go +++ b/services/render/internal/middleware/auth.go @@ -43,7 +43,15 @@ func JWTAuth(secret string) gin.HandlerFunc { } userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) - isAdmin, _ := claims["is_admin"].(bool) + // is_admin may arrive as a JSON bool or as the string "true" (identity emits a + // string). Accept both so [RequireAdmin] works regardless of token encoding. + isAdmin := false + switch v := claims["is_admin"].(type) { + case bool: + isAdmin = v + case string: + isAdmin = v == "true" + } role, _ := claims["role"].(string) c.Set(CtxUserID, userID)