* HH-437 harden auth and account authorization * HH-437 reject revoked platform access --------- Co-authored-by: Rogee <rogee@ipao.vip>
290 lines
8.6 KiB
Go
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)
|
|
}
|