package middleware import ( "fmt" "net/http" "strconv" "strings" "github.com/flatrender/render-svc/internal/models" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) const ( CtxUserID = "user_id" CtxTenantID = "tenant_id" CtxIsAdmin = "is_admin" CtxRole = "role" CtxMaxRenders = "max_renders" ) func JWTAuth(secret string) gin.HandlerFunc { return func(c *gin.Context) { hdr := c.GetHeader("Authorization") if !strings.HasPrefix(hdr, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "missing bearer token"}) return } tokenStr := hdr[7:] token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(secret), nil }) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "invalid token"}) return } claims, ok := token.Claims.(jwt.MapClaims) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "bad claims"}) return } userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) // 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) // max_renders: concurrent-render ceiling. Identity emits it as a string; // also accept a JSON number. Default 1 when absent/unparseable. maxRenders := 1 switch v := claims["max_renders"].(type) { case string: if n, err := strconv.Atoi(v); err == nil && n > 0 { maxRenders = n } case float64: if v >= 1 { maxRenders = int(v) } } c.Set(CtxUserID, userID) c.Set(CtxTenantID, tenantID) c.Set(CtxIsAdmin, isAdmin) c.Set(CtxRole, role) c.Set(CtxMaxRenders, maxRenders) c.Next() } } func RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { isAdmin, _ := c.Get(CtxIsAdmin) b, _ := isAdmin.(bool) if !b { c.AbortWithStatusJSON(http.StatusForbidden, models.APIError{Code: "forbidden", Message: "admin required"}) return } c.Next() } } // RequireServiceRole allows callers presenting a token with role="Service" func RequireServiceRole() gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get(CtxRole) isAdmin, _ := c.Get(CtxIsAdmin) b, _ := isAdmin.(bool) if role != "Service" && !b { c.AbortWithStatusJSON(http.StatusForbidden, models.APIError{Code: "forbidden", Message: "service role required"}) return } c.Next() } } // NodeHMAC verifies the X-Node-Signature header for node-agent calls func NodeHMAC(nodeSecret string) gin.HandlerFunc { return func(c *gin.Context) { sig := c.GetHeader("X-Node-Signature") if sig == "" || sig != nodeSecret { c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "invalid node signature"}) return } c.Next() } } func GetUserID(c *gin.Context) uuid.UUID { v, _ := c.Get(CtxUserID) id, _ := v.(uuid.UUID) return id } func GetTenantID(c *gin.Context) uuid.UUID { v, _ := c.Get(CtxTenantID) id, _ := v.(uuid.UUID) return id } // GetMaxRenders returns the user's concurrent-render ceiling (default 1). func GetMaxRenders(c *gin.Context) int { v, ok := c.Get(CtxMaxRenders) if !ok { return 1 } n, _ := v.(int) if n < 1 { return 1 } return n }