stabilizing veza-backend-api: P0
This commit is contained in:
parent
3c534a59a0
commit
54a570bb74
4 changed files with 249 additions and 5 deletions
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
|
|
@ -304,7 +305,15 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
|
|||
if r.config.AuthMiddleware != nil {
|
||||
protected := users.Group("")
|
||||
protected.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
protected.PUT("/:id", profileHandler.UpdateProfile)
|
||||
|
||||
// MOD-P0-003: Apply ownership middleware for PUT /users/:id
|
||||
// Resolver: For users, the :id param is directly the user_id
|
||||
userOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||
userIDStr := c.Param("id")
|
||||
return uuid.Parse(userIDStr)
|
||||
}
|
||||
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("user", userOwnerResolver), profileHandler.UpdateProfile)
|
||||
|
||||
protected.GET("/:id/completion", profileHandler.GetProfileCompletion)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,8 +377,24 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
|
|||
uploadGroup := protected.Group("")
|
||||
uploadGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole())
|
||||
uploadGroup.POST("", trackHandler.UploadTrack)
|
||||
protected.PUT("/:id", trackHandler.UpdateTrack)
|
||||
protected.DELETE("/:id", trackHandler.DeleteTrack)
|
||||
|
||||
// MOD-P0-003: Apply ownership middleware for PUT/DELETE /tracks/:id
|
||||
// Resolver: Load track from DB to get its user_id
|
||||
trackOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||
trackIDStr := c.Param("id")
|
||||
trackID, err := uuid.Parse(trackIDStr)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// Load track to get owner ID
|
||||
track, err := trackService.GetTrackByID(c.Request.Context(), trackID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return track.UserID, nil
|
||||
}
|
||||
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateTrack)
|
||||
protected.DELETE("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.DeleteTrack)
|
||||
|
||||
// Upload
|
||||
protected.GET("/:id/status", trackHandler.GetUploadStatus)
|
||||
|
|
|
|||
|
|
@ -510,8 +510,8 @@ func TestNewConfig_ProductionCORSRequired(t *testing.T) {
|
|||
os.Setenv("APP_ENV", "production")
|
||||
os.Setenv("JWT_SECRET", "test-jwt-secret-key-minimum-32-characters-long")
|
||||
os.Setenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test_db")
|
||||
os.Unsetenv("CORS_ALLOWED_ORIGINS") // Manquant intentionnellement
|
||||
os.Setenv("REDIS_ENABLE", "false") // Désactiver Redis pour éviter erreur de connexion
|
||||
os.Unsetenv("CORS_ALLOWED_ORIGINS") // Manquant intentionnellement
|
||||
os.Setenv("REDIS_ENABLE", "false") // Désactiver Redis pour éviter erreur de connexion
|
||||
os.Setenv("RABBITMQ_ENABLE", "false") // Désactiver RabbitMQ pour éviter erreur de connexion
|
||||
|
||||
// MOD-P0-001: NewConfig() doit retourner une erreur car CORS est vide en production
|
||||
|
|
|
|||
|
|
@ -372,6 +372,76 @@ func (am *AuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// ResourceOwnerResolver est une fonction qui récupère l'ID du propriétaire d'une ressource
|
||||
// depuis le contexte de la requête (paramètres de route, etc.)
|
||||
// Retourne l'UUID du propriétaire et une erreur si la ressource n'existe pas
|
||||
type ResourceOwnerResolver func(c *gin.Context) (uuid.UUID, error)
|
||||
|
||||
// RequireOwnershipOrAdmin middleware qui vérifie que l'utilisateur authentifié est le propriétaire
|
||||
// de la ressource ou qu'il a le rôle admin
|
||||
// MOD-P0-003: Middleware générique pour vérification ownership centralisée
|
||||
func (am *AuthMiddleware) RequireOwnershipOrAdmin(resourceType string, resolver ResourceOwnerResolver) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Authentifier l'utilisateur
|
||||
userID, ok := am.authenticate(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'ID du propriétaire de la ressource via le resolver
|
||||
resourceOwnerID, err := resolver(c)
|
||||
if err != nil {
|
||||
am.logger.Warn("Failed to resolve resource owner",
|
||||
zap.Error(err),
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.String("user_id", userID.String()),
|
||||
)
|
||||
response.NotFound(c, "Resource not found")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Si l'utilisateur est le propriétaire, autoriser
|
||||
if userID == resourceOwnerID {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur est admin
|
||||
hasRole, err := am.permissionService.HasRole(c.Request.Context(), userID, "admin")
|
||||
if err != nil {
|
||||
am.logger.Error("Failed to check admin role for ownership",
|
||||
zap.Error(err),
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.String("user_id", userID.String()),
|
||||
)
|
||||
response.InternalServerError(c, "Internal server error")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if hasRole {
|
||||
am.logger.Info("Admin override for ownership check",
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("resource_owner_id", resourceOwnerID.String()),
|
||||
)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// L'utilisateur n'est ni le propriétaire ni admin → Forbidden
|
||||
am.logger.Warn("Ownership check failed",
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("resource_owner_id", resourceOwnerID.String()),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
)
|
||||
response.Forbidden(c, "You do not have permission to access this resource")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshToken middleware pour rafraîchir les tokens
|
||||
// MIGRATION UUID: Simplifié pour UUID
|
||||
func (am *AuthMiddleware) RefreshToken() gin.HandlerFunc {
|
||||
|
|
|
|||
|
|
@ -629,3 +629,152 @@ func TestAuthMiddleware_P1_StrictClaims(t *testing.T) {
|
|||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRequireOwnershipOrAdmin_OwnerAccess teste que le propriétaire peut accéder à sa ressource
|
||||
// MOD-P0-003: Test ownership middleware avec owner
|
||||
func TestRequireOwnershipOrAdmin_OwnerAccess(t *testing.T) {
|
||||
jwtService := setupTestJWTService(t)
|
||||
authMiddleware, mockSessionService, _, _, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
|
||||
|
||||
ownerID := uuid.New()
|
||||
requesterID := ownerID // Même utilisateur
|
||||
|
||||
// Mock user
|
||||
user := &models.User{
|
||||
ID: requesterID,
|
||||
Email: "owner@test.com",
|
||||
TokenVersion: 0,
|
||||
}
|
||||
mockUserRepository.On("GetByID", requesterID.String()).Return(user, nil)
|
||||
|
||||
// Mock session
|
||||
session := &services.Session{
|
||||
ID: uuid.New(),
|
||||
UserID: requesterID,
|
||||
}
|
||||
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
|
||||
|
||||
// Resolver qui retourne l'owner ID depuis les paramètres
|
||||
resolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||
idStr := c.Param("id")
|
||||
return uuid.Parse(idStr)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
|
||||
router.PUT("/test/:id", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
|
||||
token := generateTestToken(t, requesterID, 1*time.Hour)
|
||||
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockUserRepository.AssertExpectations(t)
|
||||
mockSessionService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestRequireOwnershipOrAdmin_AdminAccess teste qu'un admin peut accéder à n'importe quelle ressource
|
||||
// MOD-P0-003: Test ownership middleware avec admin override
|
||||
func TestRequireOwnershipOrAdmin_AdminAccess(t *testing.T) {
|
||||
jwtService := setupTestJWTService(t)
|
||||
authMiddleware, mockSessionService, _, mockPermissionService, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
|
||||
|
||||
ownerID := uuid.New()
|
||||
adminID := uuid.New() // Admin différent du owner
|
||||
|
||||
// Mock user (admin)
|
||||
user := &models.User{
|
||||
ID: adminID,
|
||||
Email: "admin@test.com",
|
||||
TokenVersion: 0,
|
||||
}
|
||||
mockUserRepository.On("GetByID", adminID.String()).Return(user, nil)
|
||||
|
||||
// Mock session
|
||||
session := &services.Session{
|
||||
ID: uuid.New(),
|
||||
UserID: adminID,
|
||||
}
|
||||
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
|
||||
|
||||
// Mock admin role
|
||||
mockPermissionService.On("HasRole", mock.Anything, adminID, "admin").Return(true, nil)
|
||||
|
||||
// Resolver qui retourne l'owner ID depuis les paramètres
|
||||
resolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||
idStr := c.Param("id")
|
||||
return uuid.Parse(idStr)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
|
||||
router.PUT("/test/:id", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
|
||||
token := generateTestToken(t, adminID, 1*time.Hour)
|
||||
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
mockUserRepository.AssertExpectations(t)
|
||||
mockSessionService.AssertExpectations(t)
|
||||
mockPermissionService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestRequireOwnershipOrAdmin_ForbiddenAccess teste qu'un utilisateur non-owner et non-admin est rejeté
|
||||
// MOD-P0-003: Test ownership middleware avec accès interdit
|
||||
func TestRequireOwnershipOrAdmin_ForbiddenAccess(t *testing.T) {
|
||||
jwtService := setupTestJWTService(t)
|
||||
authMiddleware, mockSessionService, _, mockPermissionService, mockUserRepository := setupTestAuthMiddleware(t, jwtService)
|
||||
|
||||
ownerID := uuid.New()
|
||||
otherUserID := uuid.New() // Utilisateur différent du owner
|
||||
|
||||
// Mock user (non-owner, non-admin)
|
||||
user := &models.User{
|
||||
ID: otherUserID,
|
||||
Email: "other@test.com",
|
||||
TokenVersion: 0,
|
||||
}
|
||||
mockUserRepository.On("GetByID", otherUserID.String()).Return(user, nil)
|
||||
|
||||
// Mock session
|
||||
session := &services.Session{
|
||||
ID: uuid.New(),
|
||||
UserID: otherUserID,
|
||||
}
|
||||
mockSessionService.On("ValidateSession", mock.Anything, mock.Anything).Return(session, nil)
|
||||
|
||||
// Mock non-admin role
|
||||
mockPermissionService.On("HasRole", mock.Anything, otherUserID, "admin").Return(false, nil)
|
||||
|
||||
// Resolver qui retourne l'owner ID depuis les paramètres
|
||||
resolver := func(c *gin.Context) (uuid.UUID, error) {
|
||||
idStr := c.Param("id")
|
||||
return uuid.Parse(idStr)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(authMiddleware.RequireOwnershipOrAdmin("test_resource", resolver))
|
||||
router.PUT("/test/:id", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
|
||||
token := generateTestToken(t, otherUserID, 1*time.Hour)
|
||||
req := httptest.NewRequest("PUT", "/test/"+ownerID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
mockUserRepository.AssertExpectations(t)
|
||||
mockSessionService.AssertExpectations(t)
|
||||
mockPermissionService.AssertExpectations(t)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue