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>
This commit is contained in:
@@ -76,6 +76,9 @@ func AccountScope() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
if isSuperAdminContext(c) {
|
||||
roleStr = "super_admin"
|
||||
}
|
||||
|
||||
// Step 5: Get custom_role_id from JWT claims
|
||||
customRoleID := uint(0)
|
||||
@@ -150,6 +153,13 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -201,6 +211,9 @@ func resolveScopedAccountID(c *gin.Context) (uint, bool) {
|
||||
if !hasRouteAccountID {
|
||||
return contextAccountID, true
|
||||
}
|
||||
if isSuperAdminContext(c) {
|
||||
return routeAccountID, true
|
||||
}
|
||||
if contextAccountID == 0 {
|
||||
return routeAccountID, true
|
||||
}
|
||||
@@ -259,6 +272,11 @@ func getAccountID(c *gin.Context) uint {
|
||||
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.
|
||||
|
||||
@@ -207,3 +207,21 @@ func TestAccountScopeWithService_AllowsVerifiedAccountSwitch(t *testing.T) {
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/accounts/3", nil))
|
||||
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
func TestAccountScopeWithService_AllowsSuperAdminAcrossAccounts(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("user_id", uint(1))
|
||||
c.Set("account_id", uint(1))
|
||||
c.Set("user_type", "super_admin")
|
||||
c.Next()
|
||||
})
|
||||
r.Use(AccountScopeWithService(accountScopeLookup{accountID: 999}))
|
||||
r.PATCH("/api/v1/accounts/:account_id", SuperAdminOrAdministrator(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPatch, "/api/v1/accounts/2", nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
//
|
||||
// Auth flow:
|
||||
// 1. Extract JWT from Authorization Bearer header → set user_id/account_id from claims
|
||||
// 2. If no Authorization header, fall back to X-User-ID / X-Account-ID headers (dev/test mode)
|
||||
// 2. If explicitly enabled, fall back to X-User-ID / X-Account-ID headers (dev/test only)
|
||||
// 3. Abort with 401 if neither source provides a valid identity
|
||||
func AuthMiddleware(cfg *config.JWTConfig) gin.HandlerFunc {
|
||||
jwtSvc := auth.NewJWTService(cfg)
|
||||
@@ -68,11 +68,12 @@ func AuthMiddlewareWithServiceAndDB(jwtSvc *auth.JWTService, db *gorm.DB) gin.Ha
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: X-User-ID / X-Account-ID headers for development and testing.
|
||||
// Fallback: X-User-ID / X-Account-ID headers for explicitly enabled
|
||||
// development and test environments only.
|
||||
// This allows integration tests and dev environments to bypass JWT while
|
||||
// still exercising the same middleware → handler pipeline.
|
||||
headerUserID := c.GetHeader("X-User-ID")
|
||||
if headerUserID != "" {
|
||||
if headerUserID != "" && jwtSvc.InsecureHeaderAuthAllowed() {
|
||||
userID, err := strconv.ParseUint(headerUserID, 10, 32)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid X-User-ID header"})
|
||||
|
||||
@@ -196,9 +196,10 @@ func TestAuthMiddleware_AllowsPlatformAdminThroughSuperAdminGuard(t *testing.T)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
|
||||
func TestAuthMiddleware_FallbackHeadersRequireExplicitOptIn(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := makeJWTConfig()
|
||||
cfg.AllowInsecureHeaderAuth = true
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(cfg))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
@@ -215,9 +216,23 @@ func TestAuthMiddleware_FallbackHeaders(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_RejectsFallbackHeadersByDefault(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(makeJWTConfig()))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("X-User-ID", "5")
|
||||
req.Header.Set("X-Account-ID", "10")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_FallbackInvalidUserID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := makeJWTConfig()
|
||||
cfg.AllowInsecureHeaderAuth = true
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(cfg))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
|
||||
@@ -1343,7 +1343,7 @@ func TestAuthMiddleware_AccessTokenHeader_Cov7(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_XUserID_Cov7(t *testing.T) {
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
@@ -1355,7 +1355,7 @@ func TestAuthMiddleware_XUserID_Cov7(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_InvalidXUserID_Cov7(t *testing.T) {
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
@@ -1365,7 +1365,7 @@ func TestAuthMiddleware_InvalidXUserID_Cov7(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_XUserIDWithAccount_Cov7(t *testing.T) {
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1})
|
||||
jwtSvc := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", ExpiryHours: 1, AllowInsecureHeaderAuth: true})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -67,6 +68,11 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
"Failed to verify access token")
|
||||
return
|
||||
}
|
||||
if accessToken.ExpiresAt != nil && !accessToken.ExpiresAt.After(time.Now()) {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
|
||||
"Invalid access_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: Load the PlatformApp with its Permissibles
|
||||
var platformApp model.PlatformApp
|
||||
@@ -75,6 +81,11 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
"Invalid access_token")
|
||||
return
|
||||
}
|
||||
if !platformApp.IsActive() {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized,
|
||||
"Invalid access_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: Set platform_app in Gin context for downstream handlers
|
||||
c.Set("platform_app", platformApp)
|
||||
@@ -83,4 +94,4 @@ func PlatformAppAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -96,6 +97,71 @@ func TestPlatformAppAuth_InvalidToken(t *testing.T) {
|
||||
assert.Equal(t, 401, w.Code)
|
||||
}
|
||||
|
||||
func TestPlatformAppAuth_InactiveOrDisabledAppRejected(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
active *bool
|
||||
status string
|
||||
}{
|
||||
{name: "inactive", active: ptrBool(false), status: "active"},
|
||||
{name: "disabled", active: ptrBool(true), status: "disabled"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newPlatformTestDB(t)
|
||||
pa := model.PlatformApp{Name: tc.name, Active: tc.active, Status: tc.status}
|
||||
require.NoError(t, db.Create(&pa).Error)
|
||||
|
||||
rawToken := "inactive-platform-token"
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
require.NoError(t, db.Create(&model.AccessToken{
|
||||
OwnerType: model.AccessTokenOwnerTypePlatformApp,
|
||||
OwnerID: pa.ID,
|
||||
Token: hex.EncodeToString(hash[:]),
|
||||
TokenPrefix: rawToken[:8],
|
||||
}).Error)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(PlatformAppAuth(db))
|
||||
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("api_access_token", rawToken)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformAppAuth_ExpiredTokenRejected(t *testing.T) {
|
||||
db := newPlatformTestDB(t)
|
||||
pa := model.PlatformApp{Name: "ExpiredTokenApp", Active: ptrBool(true), Status: "active"}
|
||||
require.NoError(t, db.Create(&pa).Error)
|
||||
|
||||
rawToken := "expired-platform-token"
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
expiresAt := time.Now().Add(-time.Minute)
|
||||
require.NoError(t, db.Create(&model.AccessToken{
|
||||
OwnerType: model.AccessTokenOwnerTypePlatformApp,
|
||||
OwnerID: pa.ID,
|
||||
Token: hex.EncodeToString(hash[:]),
|
||||
TokenPrefix: rawToken[:8],
|
||||
ExpiresAt: &expiresAt,
|
||||
}).Error)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(PlatformAppAuth(db))
|
||||
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("api_access_token", rawToken)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestPlatformAppAuth_UserTokenRejected(t *testing.T) {
|
||||
db := newPlatformTestDB(t)
|
||||
router := gin.New()
|
||||
@@ -205,4 +271,4 @@ func TestPlatformAppAuth_ContextValues(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
|
||||
Reference in New Issue
Block a user