Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
253 lines
7.3 KiB
Go
253 lines
7.3 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) {
|
|
// 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
|
|
roleStr := "agent" // default fallback
|
|
role, roleExists := c.Get("user_role")
|
|
if roleExists {
|
|
if s, ok := role.(string); ok {
|
|
roleStr = s
|
|
}
|
|
}
|
|
|
|
// Step 5: Get custom_role_id from JWT claims
|
|
customRoleID := uint(0)
|
|
claims, claimsExists := c.Get("auth_claims")
|
|
if claimsExists {
|
|
if jwtClaims, ok := claims.(*auth.Claims); ok {
|
|
if jwtClaims.CustomRoleID != 0 {
|
|
customRoleID = jwtClaims.CustomRoleID
|
|
}
|
|
if jwtClaims.Role != "" {
|
|
roleStr = jwtClaims.Role
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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, 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
|
|
}
|
|
|
|
c.Set("account_id", accountID)
|
|
|
|
// Look up AccountUser to get role and CustomRoleID
|
|
accountUser, 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 accountUser.CustomRoleID > 0 && accountUser.Role != "administrator" {
|
|
pm, err := lookup.GetCustomRolePermissions(accountUser.CustomRoleID)
|
|
if err != nil {
|
|
// Fallback to agent defaults if custom role not found
|
|
permissions = auth.AgentDefaultPermissions
|
|
} else {
|
|
permissions = pm
|
|
}
|
|
}
|
|
|
|
effectiveRole := accountUser.Role
|
|
if accountUser.CustomRoleID > 0 && effectiveRole != "administrator" {
|
|
effectiveRole = "custom_role"
|
|
}
|
|
|
|
policyCtx := auth.NewPolicyContext(
|
|
userID.(uint),
|
|
accountID,
|
|
effectiveRole,
|
|
accountUser.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 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
|
|
}
|
|
|
|
// --- 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) (*AccountUserRole, error)
|
|
GetCustomRolePermissions(customRoleID uint) (auth.PermissionMatrixMap, error)
|
|
}
|
|
|
|
// AccountUserRole holds the essential AccountUser data needed by middleware.
|
|
// This is a lightweight DTO that the service populates from the model.AccountUser.
|
|
type AccountUserRole struct {
|
|
UserID uint
|
|
AccountID uint
|
|
Role string
|
|
CustomRoleID uint
|
|
Availability string
|
|
}
|