diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index f610d26e2..962a4168a 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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) diff --git a/veza-backend-api/internal/config/config_test.go b/veza-backend-api/internal/config/config_test.go index b4233a693..5f04ef609 100644 --- a/veza-backend-api/internal/config/config_test.go +++ b/veza-backend-api/internal/config/config_test.go @@ -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 diff --git a/veza-backend-api/internal/middleware/auth.go b/veza-backend-api/internal/middleware/auth.go index 7bf38a4df..f4607c5a5 100644 --- a/veza-backend-api/internal/middleware/auth.go +++ b/veza-backend-api/internal/middleware/auth.go @@ -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 { diff --git a/veza-backend-api/internal/middleware/auth_middleware_test.go b/veza-backend-api/internal/middleware/auth_middleware_test.go index 8c96ec520..73e80c555 100644 --- a/veza-backend-api/internal/middleware/auth_middleware_test.go +++ b/veza-backend-api/internal/middleware/auth_middleware_test.go @@ -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) +}