Files
gochat/backend/internal/middleware/account_scope.go
T
Rogeeandrogee cf263d10b4 HH-437: harden production auth and tenant authorization (#84)
* HH-437 harden auth and account authorization

* HH-437 reject revoked platform access

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-21 19:13:10 +08:00

290 lines
8.6 KiB
Go

package middleware
// Reference: P2E §1.3 — AccountScope middleware
// Extracts account_id from JWT claims and injects it plus the PolicyContext
// into the Gin context for downstream RBAC enforcement.
//
// IMPORTANT: This middleware package does NOT import internal/model or internal/service
// to avoid potential circular dependencies. Instead, it defines its own interface
// (RBACLookup) that the service layer implements. This follows Go's interface segregation
// pattern — the middleware defines what it needs, and the service provides it.
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/pkg/response"
)
// AccountScope validates and injects account context from JWT claims or
// X-Account-ID header. It also constructs the PolicyContext from the
// AccountUser association so that downstream PolicyMiddleware can check permissions.
//
// Basic version (without service lookup) uses JWT claims directly.
// For production, use AccountScopeWithService() which looks up AccountUser from DB.
//
// Usage:
//
// router.Use(AuthRequired(jwtSvc), AccountScope())
func AccountScope() gin.HandlerFunc {
return func(c *gin.Context) {
if IsConnectorService(c) {
if authorized, _ := c.Get("connector_account_authorized"); authorized != true {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account")
return
}
c.Next()
return
}
// Step 1: Get user_id from JWT claims (set by AuthRequired)
userID, exists := c.Get("user_id")
if !exists {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
"User ID not found in context — ensure AuthRequired middleware is applied first")
return
}
// Step 2: Determine account_id.
// Account-scoped Chatwoot routes carry :account_id in the URL. The token/header
// account context must match that URL account instead of silently allowing a
// token scoped to one account to read another account's route.
accountID, ok := resolveScopedAccountID(c)
if !ok {
return
}
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest,
"Account ID required — provide via X-Account-ID header or JWT claims")
return
}
// Step 3: Override JWT claims account_id if header was provided
c.Set("account_id", accountID)
// Step 4: Get role from JWT claims. AuthMiddleware stores the current
// keys as "role" and "claims"; retain the legacy names for callers that
// still construct middleware contexts directly.
roleStr := "agent" // default fallback
for _, key := range []string{"role", "user_role"} {
if role, roleExists := c.Get(key); roleExists {
if s, ok := role.(string); ok && s != "" {
roleStr = s
break
}
}
}
if isSuperAdminContext(c) {
roleStr = "super_admin"
}
// Step 5: Get custom_role_id from JWT claims
customRoleID := uint(0)
for _, key := range []string{"claims", "auth_claims"} {
if claims, claimsExists := c.Get(key); claimsExists {
if jwtClaims, ok := claims.(*auth.Claims); ok {
if jwtClaims.CustomRoleID != 0 {
customRoleID = jwtClaims.CustomRoleID
}
if jwtClaims.Role != "" {
roleStr = jwtClaims.Role
}
break
}
}
}
// Step 6: Build PolicyContext from JWT claims data
// For custom_role, permissions will be nil (loaded by service layer)
var permissions auth.PermissionMatrixMap
policyCtx := auth.NewPolicyContext(
userID.(uint),
accountID,
roleStr,
customRoleID,
permissions,
)
c.Set("policy_context", policyCtx)
c.Next()
}
}
// AccountScopeWithService creates an AccountScope middleware that uses
// a RBACLookup service to look up the user's AccountUser record and CustomRole
// permissions from the database. This is the production-grade version.
//
// Usage:
//
// rbacSvc := service.NewRBACService(db)
// router.Use(AuthRequired(jwtSvc), AccountScopeWithService(rbacSvc))
func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc {
return func(c *gin.Context) {
if IsConnectorService(c) {
if authorized, _ := c.Get("connector_account_authorized"); authorized != true {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Connector does not have access to this account")
return
}
c.Next()
return
}
userID, exists := c.Get("user_id")
if !exists {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
"User ID not found in context — ensure AuthRequired middleware is applied first")
return
}
accountID := getAccountID(c)
routeID, hasRouteID, routeOK := routeAccountID(c)
if !routeOK {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
if hasRouteID {
accountID = routeID
}
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest,
"Account ID required — provide via X-Account-ID header or JWT claims")
return
}
c.Set("account_id", accountID)
if isSuperAdminContext(c) {
c.Set("role", "super_admin")
c.Set("custom_role_id", uint(0))
c.Set("policy_context", auth.NewPolicyContext(userID.(uint), accountID, "super_admin", 0, nil))
c.Next()
return
}
// Look up AccountUser to get role and CustomRoleID
role, customRoleID, err := lookup.GetAccountUserRole(userID.(uint), accountID)
if err != nil {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden,
"User does not belong to this account")
return
}
// Build permissions matrix based on role
permissions := auth.PermissionMatrixMap{}
if customRoleID > 0 && role != "administrator" {
pm, err := lookup.GetCustomRolePermissions(customRoleID)
if err != nil {
// Fallback to agent defaults if custom role not found
permissions = auth.AgentDefaultPermissions
} else {
permissions = pm
}
}
effectiveRole := role
if customRoleID > 0 && effectiveRole != "administrator" {
effectiveRole = "custom_role"
}
c.Set("role", effectiveRole)
c.Set("custom_role_id", customRoleID)
policyCtx := auth.NewPolicyContext(
userID.(uint),
accountID,
effectiveRole,
customRoleID,
permissions,
)
c.Set("policy_context", policyCtx)
c.Next()
}
}
func resolveScopedAccountID(c *gin.Context) (uint, bool) {
contextAccountID := getAccountID(c)
routeAccountID, hasRouteAccountID, routeOK := routeAccountID(c)
if !routeOK {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return 0, false
}
if !hasRouteAccountID {
return contextAccountID, true
}
if isSuperAdminContext(c) {
return routeAccountID, true
}
if contextAccountID == 0 {
return routeAccountID, true
}
if contextAccountID != routeAccountID {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "User does not belong to this account")
return 0, false
}
return routeAccountID, true
}
func routeAccountID(c *gin.Context) (uint, bool, bool) {
for _, param := range []string{"account_id", "id"} {
raw := c.Param(param)
if raw == "" {
continue
}
id, err := strconv.ParseUint(raw, 10, 32)
if err != nil || id == 0 {
return 0, true, false
}
return uint(id), true, true
}
return 0, false, true
}
// getAccountID extracts account ID from the request.
// Priority: X-Account-ID header > JWT claims account_id
func getAccountID(c *gin.Context) uint {
// Try X-Account-ID header first (for account switching)
headerAccountID := c.GetHeader("X-Account-ID")
if headerAccountID != "" {
id, err := strconv.ParseUint(headerAccountID, 10, 32)
if err == nil && id != 0 {
return uint(id)
}
}
// Fall back to JWT claims (set by AuthRequired middleware)
if accountID, exists := c.Get("account_id"); exists {
switch v := accountID.(type) {
case uint:
if v != 0 {
return v
}
case float64:
if v != 0 {
return uint(v)
}
case int:
if v != 0 {
return uint(v)
}
}
}
return 0
}
func isSuperAdminContext(c *gin.Context) bool {
userType, exists := c.Get("user_type")
return exists && isSuperAdminType(userType)
}
// --- RBACLookup Interface ---
// Defines what the middleware needs from the service layer.
// The service package implements this interface, avoiding circular imports.
// RBACLookup is the interface that the RBAC service must implement
// for use with AccountScopeWithService middleware.
type RBACLookup interface {
GetAccountUserRole(userID, accountID uint) (role string, customRoleID uint, err error)
GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error)
}