diff --git a/backend/.air.toml b/backend/.air.toml index 08662fb..688667c 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -23,7 +23,7 @@ tmp_dir = "tmp" exclude_dir = ["assets", "tmp", "frontend", "node_modules"] # Include file extensions to watch - include_ext = ["go", "tpl", "tmpl", "html"] + include_ext = ["go", "env", "tpl", "tmpl", "html"] # Exclude file patterns exclude_file = ["*_test.go"] diff --git a/backend/.env.example b/backend/.env.example index ab53910..40ed579 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,9 +11,9 @@ SERVER_WRITE_TIMEOUT=10s # ============================================================================== DB_HOST=localhost DB_PORT=5432 -DB_USER=aurganize -DB_PASSWORD=aurganize_dev_pass_change_in_production -DB_NAME=aurganize_v62 +DB_USER=aurganize_backend_api +DB_PASSWORD=dev_backend_pass_v6.2 +DB_NAME=aurganize_dev DB_SSLMODE=disable # Connection Pool @@ -26,16 +26,24 @@ DB_CONN_MAX_LIFETIME=5m # ============================================================================== # IMPORTANT: Change these secrets in production! # Generate with: openssl rand -base64 32 -JWT_ACCESS_SECRET=your-super-secret-access-key-min-32-chars-change-in-production -JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars-must-be-different +JWT_ACCESS_SECRET=Qv2vA663YrdO5mX5gufIqLD5uyqkeaYpbiJP/2XC8I0= +JWT_REFRESH_SECRET=ZpOhrMoUAn5MtRpuEPHM9n+Ddv8Y/96WTwleWCej3r8= JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=168h JWT_ISSUER=aurganize-v62 + +# ============================================================================== +# COOKIE SETTING +# ============================================================================== +# Populated with suggested default values since im not sure about them +COOKIE_DOMAIN=aurganize.in +COOKIE_SAMESITE=lax + # ============================================================================== # REDIS (Caching & Sessions) # ============================================================================== -REDIS_HOST=redis +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 @@ -43,29 +51,15 @@ REDIS_DB=0 # ============================================================================== # NATS (Event Messaging) # ============================================================================== -NATS_URL=nats://nats:4222 -NATS_CLUSTER_ID=aurganize-cluster +NATS_URL=nats://localhost:4222 # ============================================================================== # MINIO (S3-Compatible Storage) # ============================================================================== -MINIO_ENDPOINT=minio:9000 +MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=aurganize MINIO_USE_SSL=false -MINIO_BUCKET=aurganize-media -#------------------------------------------------------------ -# Development Tools -#------------------------------------------------------------ -# Enable hot reload polling -CHOKIDAR_USEPOLLING=true -WATCHPACK_POLLING=true - -# Air configuration -AIR_DELAY=1000 - -# Docker resource limits -POSTGRES_MEMORY_LIMIT=512m -REDIS_MEMORY_LIMIT=256m \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2c853a7..0468fd7 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -10,9 +10,18 @@ import ( "time" "github.com/creativenoz/aurganize-v62/backend/internal/config" + "github.com/creativenoz/aurganize-v62/backend/internal/handlers" + "github.com/creativenoz/aurganize-v62/backend/internal/middleware" + "github.com/creativenoz/aurganize-v62/backend/internal/repositories" + "github.com/creativenoz/aurganize-v62/backend/internal/routes" + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/creativenoz/aurganize-v62/backend/jobs" "github.com/creativenoz/aurganize-v62/backend/pkg/logger" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + + echomiddleware "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog/log" ) @@ -36,13 +45,95 @@ func main() { Str("environment", cfg.Server.Environment). Msg("Starting Aurganize v6.2 API server") + // ========================================================================= + // Database Connection + // ========================================================================= + log.Info(). + Str("host", cfg.DatabaseDSN()) + db, err := sqlx.Connect("postgres", cfg.DatabaseDSN()) + if err != nil { + log.Fatal(). + Err(err). + Str("dsn", cfg.DatabaseDSN()). + Msg("failed to connect to database") + } + + defer db.Close() + + db.SetMaxOpenConns(cfg.Database.MaxOpenConns) + db.SetMaxIdleConns(cfg.Database.MaxIdleConns) + db.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime) + + if err := db.Ping(); err != nil { + log.Fatal(). + Err(err). + Str("dsn", cfg.DatabaseDSN()). + Msg("failed to ping database") + } + log.Info(). + Str("host", cfg.Database.Host). + Str("database", cfg.Database.DBName). + Msg("database connected successfully") + + // ========================================================================= + // Initialize Repositories + // ========================================================================= + userRepo := repositories.NewUserRepository(db) + sessionRepo := repositories.NewSessionRepository(db) + tenantRepo := repositories.NewTenantRepository(db) + log.Info(). + Msg("repositories initialized") + + // ========================================================================= + // Initialize Handlers + // ========================================================================= + + authService := services.NewAuthService(cfg, sessionRepo, userRepo) + userService := services.NewUserService(userRepo) + tenantService := services.NewTenantService(cfg, tenantRepo, userRepo, db) + + log.Info(). + Msg("services initialized") + + // ========================================================================= + // Initialize Handlers + // ========================================================================= + + authHandler := handlers.NewAuthHandler(cfg, authService, userService) + userHandler := handlers.NewUserRegisterHandler(cfg, authService, userService, tenantService) + tenantHandler := handlers.NewTenantHanlder(tenantService) + + log.Info(). + Msg("handlers initialized") + + // ========================================================================= + // Initialize Middleware + // ========================================================================= + authMiddleware := middleware.NewAuthMiddleware(authService) + globalrateLimitterMiddleware := middleware.NewRateLimiter(5, time.Minute) + log.Info(). + Msg("middleware initialized") + + // ========================================================================= + // Background Jobs + // ========================================================================= + sessionCleanUpJob := jobs.NewSessionCleanUpJob(sessionRepo) + + go func() { + sessionCleanUpJob.Start(context.Background()) + }() + + log.Info(). + Dur("interval", 12*time.Hour). + Msg("session clean up job started") + // ========================================================================= // Create Echo Instance // ========================================================================= e := echo.New() e.HideBanner = true e.HidePort = true - + e.Validator = &customValidator{validator: validator.New()} e.HTTPErrorHandler = customHTTPErrorHandler // we are using a custom error handler e.Server.ReadTimeout = cfg.Server.ReadTimeout @@ -54,19 +145,19 @@ func main() { // ========================================================================= // Setting safe recover middleware - e.Use(middleware.Recover()) + e.Use(echomiddleware.Recover()) // Middleware catches panic // Returns 500 Internal Server Error // Server keeps running // ------------------------------------------------------------------------- // Setting request ID middleware - e.Use(middleware.RequestID()) + e.Use(echomiddleware.RequestID()) // Trace request through entire system // Link frontend error to backend logs // This adds a header : X-Request-ID: abc-123-def-456 // ------------------------------------------------------------------------ // Setting Logger format - e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + e.Use(echomiddleware.LoggerWithConfig(echomiddleware.LoggerConfig{ Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}",` + `"status":${status},"latency_ms":${latency_ms},"error":"${error}"}` + "\n", Output: log.Logger, @@ -82,36 +173,11 @@ func main() { // } // ----------------------------------------------------------------------- // Setting CORS (Cross-Origin Resource Sharing) middleware - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{ - "http://localhost:5173", // (Development) Svelte dev server : this is the port suggested to be used with front-end - "http://localhost:3000", // (Developement) Alternative dev port : this is an alternative port kept aside - "https://app.aurganize.com", // (Production) Production frontend : we can use this subdomain itself for front-end - }, - AllowMethods: []string{ - http.MethodGet, - http.MethodPost, - http.MethodPut, - http.MethodDelete, - http.MethodPatch, - http.MethodOptions, - }, - AllowHeaders: []string{ - "Origin", - "Content-Type", - "Accept", - "Authorization", - "X-Request-ID", - }, - - AllowCredentials: true, // Not sure about why are using this option - - MaxAge: 3600, // 1 hour in seconds - })) + e.Use(middleware.NewCORSMiddleware()) // Prevents malicious sites from calling your API // ---------------------------------------------------------------------- // Setting Security Headers middleware - e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ + e.Use(echomiddleware.SecureWithConfig(echomiddleware.SecureConfig{ XSSProtection: "1; mode=block", ContentTypeNosniff: "nosniff", XFrameOptions: "SAMEORIGIN", @@ -144,7 +210,7 @@ func main() { // - Additional layer of XSS protection // ------------------------------------------------------------------- // Setting Gzip compression middleware - e.Use(middleware.Gzip()) + e.Use(echomiddleware.Gzip()) // TODO : Rate Limiting middleware (planning to use redis for custom rate limiter) @@ -165,6 +231,15 @@ func main() { }) }) + routes.SetUpRoutes( + e, + authHandler, + userHandler, + tenantHandler, + authMiddleware, + globalrateLimitterMiddleware, + ) + log.Info().Msg("Routes configured") // ========================================================================= // Start Server in a new thread @@ -231,6 +306,18 @@ func main() { } +type customValidator struct { + validator *validator.Validate +} + +func (cv *customValidator) Validate(i interface{}) error { + if err := cv.validator.Struct(i); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + + } + return nil +} + // HealthCheck Handler function // This endpoint is to be used by: // - Load balancers to determine if instance is healthy diff --git a/backend/go.mod b/backend/go.mod index 6f23f3f..a0f88fc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,12 +7,21 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.4 + github.com/lib/pq v1.10.9 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 ) +require ( + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect +) + require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 github.com/jmoiron/sqlx v1.4.0 github.com/kr/pretty v0.3.0 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -21,10 +30,10 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.31.0 golang.org/x/time v0.11.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 66b41b9..151592d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,16 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -27,6 +37,8 @@ github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcX github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3839ac8..477c451 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -383,7 +383,7 @@ func parseBool(s string) bool { // This will cause connection failures! Should be fixed to: "host=%s port=%s ..." func (c *Config) DatabaseDSN() string { return fmt.Sprintf( - "host=%s port %s user=%s password=%s dbname=%s sslmode=%s", // BUG: "post" should be "port" + "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", // BUG: "post" should be "port" c.Database.Host, c.Database.Port, c.Database.User, diff --git a/backend/internal/handlers/auth_handler.go b/backend/internal/handlers/auth_handler.go index a571caf..4c70c55 100644 --- a/backend/internal/handlers/auth_handler.go +++ b/backend/internal/handlers/auth_handler.go @@ -7,6 +7,7 @@ import ( "github.com/creativenoz/aurganize-v62/backend/internal/config" "github.com/creativenoz/aurganize-v62/backend/internal/services" "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" ) // AuthHandler handles all authentication-related HTTP requests. @@ -49,6 +50,13 @@ func NewAuthHandler( authServ *services.AuthService, userServ *services.UserService, ) *AuthHandler { + log.Info(). + Str("handler", "auth"). + Str("cookie_domain", cfg.Cookie.CookieDomain). + Bool("cookie_secure", cfg.Cookie.CookieSecure). + Dur("access_expiry", cfg.JWT.AccessExpiry). + Dur("refresh_expiry", cfg.JWT.RefreshExpiry). + Msg("auth handler initialized") return &AuthHandler{ config: cfg, authService: authServ, @@ -151,6 +159,13 @@ type TokenRefreshResponse struct { // - 403: Account exists but not active (suspended, pending verification, etc.) // - 500: Server errors (token generation failure, database errors) func (h *AuthHandler) Login(c echo.Context) error { + log.Info(). + Str("handler", "auth"). + Str("action", "login_attempt"). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("login attempt started") + // Step 1: Parse request body into LoginRequest struct var req LoginRequest @@ -160,6 +175,12 @@ func (h *AuthHandler) Login(c echo.Context) error { // - Type conversion // - Field mapping based on json tags if err := c.Bind(&req); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "login_bind_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to bind login request") // Return 400 Bad Request if JSON is malformed or doesn't match struct return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } @@ -169,6 +190,13 @@ func (h *AuthHandler) Login(c echo.Context) error { // - required: Email and password must be present // - email: Email must be valid format (contains @, proper structure) if err := c.Validate(&req); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "login_validation_failed"). + Str("email", req.Email). + Str("validation_error", err.Error()). + Str("ip", c.RealIP()). + Msg("login request validation failed") // Return 400 Bad Request with specific validation error // err.Error() contains details like "Email is required" or "Email is invalid" return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -180,6 +208,12 @@ func (h *AuthHandler) Login(c echo.Context) error { // - Timeout enforcement // - Value passing (trace IDs, user info, etc.) ctx := c.Request().Context() + log.Info(). + Str("handler", "auth"). + Str("action", "authenticate_attempt"). + Str("email", req.Email). + Str("ip", c.RealIP()). + Msg("attempting to authenticate user") // Step 3: Authenticate user by email and password // This calls the user service which: @@ -188,6 +222,13 @@ func (h *AuthHandler) Login(c echo.Context) error { // 3. Returns user object if valid user, err := h.userService.AuthenticateUserByEmail(ctx, req.Email, req.Password) if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "authentication_failed"). + Str("email", req.Email). + Str("ip", c.RealIP()). + Err(err). + Msg("user authentication failed") // Return 401 Unauthorized with generic message // We use a generic message to prevent email enumeration attacks // (attacker can't tell if email exists but password is wrong) @@ -198,10 +239,26 @@ func (h *AuthHandler) Login(c echo.Context) error { // Status could be: "active", "suspended", "pending_verification", "deleted", etc. // Only "active" users can log in if user.Status != "active" { + log.Warn(). + Str("handler", "auth"). + Str("action", "inactive_account_login"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("status", user.Status). + Str("ip", c.RealIP()). + Msg("login attempt on inactive account") // Return 403 Forbidden (authenticated but not authorized) // Different from 401 because we know who they are, but they can't access return echo.NewHTTPError(http.StatusForbidden, "account is not active") } + log.Info(). + Str("handler", "auth"). + Str("action", "authentication_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Msg("user authenticated successfully, generating tokens") // Step 5: Generate access token // Access token is short-lived (typically 15 minutes) and contains: @@ -211,6 +268,13 @@ func (h *AuthHandler) Login(c echo.Context) error { // Used for authenticating API requests accessToken, err := h.authService.GenerateAccessToken(user) if err != nil { + log.Error(). + Str("handler", "auth"). + Str("action", "access_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("failed to generate access token") // Return 500 Internal Server Error // Token generation should rarely fail unless there's a configuration issue return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token") @@ -221,6 +285,13 @@ func (h *AuthHandler) Login(c echo.Context) error { userAgent := c.Request().UserAgent() // Real IP handles proxies and load balancers to get actual client IP ipAddress := c.RealIP() + log.Info(). + Str("handler", "auth"). + Str("action", "generating_refresh_token"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("generating refresh token and creating session") // Step 6: Generate refresh token // Refresh token is long-lived (typically 7 days) and: @@ -230,6 +301,14 @@ func (h *AuthHandler) Login(c echo.Context) error { // - Enables session management (list active sessions, revoke specific sessions) refreshToken, _, err := h.authService.GenerateRefreshToken(ctx, user, &userAgent, &ipAddress) if err != nil { + log.Error(). + Str("handler", "auth"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("failed to generate refresh token") + // Return 500 Internal Server Error // This could fail due to database issues return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate refresh token") @@ -243,14 +322,36 @@ func (h *AuthHandler) Login(c echo.Context) error { // Cookies are automatically sent with requests, no client-side token management needed h.setAccessTokenCookie(c, accessToken) h.setRefreshTokenCookie(c, refreshToken) - + log.Debug(). + Str("handler", "auth"). + Str("action", "updating_last_login"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Msg("updating user last login timestamp") // Step 8: Update user's last login information // Track when and from where user logged in for: // - Security audit trail // - User awareness (show "last login" in UI) // - Suspicious activity detection // We ignore errors here (don't fail login if this update fails) - _ = h.userService.UpdateLastLogin(ctx, user.ID, &ipAddress) + if err = h.userService.UpdateLastLogin(ctx, user.ID, &ipAddress); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "last_login_update_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("failed to update last login (non-critical)") + } + log.Info(). + Str("handler", "auth"). + Str("action", "login_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("user logged in successfully") // Step 9: Return successful response with user data and tokens // Response includes: @@ -291,11 +392,21 @@ func (h *AuthHandler) Login(c echo.Context) error { // - 401: Missing refresh token, invalid token, expired token, user not found // - 500: Token generation failure func (h *AuthHandler) Refresh(c echo.Context) error { + log.Info(). + Str("handler", "auth"). + Str("action", "refresh_attempt"). + Str("ip", c.RealIP()). + Msg("token refresh attempt started") // Step 1: Extract refresh token from cookie // Cookie name must match what was set during login ("refresh_token") // Cookies are automatically parsed by Echo from Cookie header cookie, err := c.Cookie("refresh_token") if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_missing_token"). + Str("ip", c.RealIP()). + Msg("refresh token cookie not found") // Return 401 if cookie is missing // This means user is not authenticated or cookie expired/was deleted return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token") @@ -304,6 +415,11 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // Get request context ctx := c.Request().Context() + log.Debug(). + Str("handler", "auth"). + Str("action", "validating_refresh_token"). + Str("ip", c.RealIP()). + Msg("validating refresh token") // Step 2: Validate the refresh token // This process: // 1. Verifies JWT signature using refresh secret @@ -314,11 +430,22 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // Returns claims (user ID, session ID, etc.) and session object claims, _, err := h.authService.ValidateRefreshToken(ctx, cookie.Value) if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_validation_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("refresh token validation failed") // Return 401 with error details // Could be: "invalid token", "token expired", "token revoked" return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) } - + log.Debug(). + Str("handler", "auth"). + Str("action", "refresh_fetching_user"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Msg("refresh token validated, fetching user data") // Step 3: Fetch current user from database // We re-fetch the user to ensure: // - User still exists (not deleted) @@ -326,6 +453,13 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // - User is still active (account not suspended) user, err := h.userService.GetByID(ctx, claims.UserID) if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "refresh_user_not_found"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("user not found during token refresh") // Return 401 if user not found // This could mean user was deleted since token was issued return echo.NewHTTPError(http.StatusUnauthorized, "user not found") @@ -335,6 +469,12 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // New token contains current user data (including any role changes) accessToken, err := h.authService.GenerateAccessToken(user) // Note: typo "Tokken" if err != nil { + log.Error(). + Str("handler", "auth"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("failed to generate new access token during refresh") // Return 500 if token generation fails return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token") } @@ -343,7 +483,13 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // Only the access token is refreshed, refresh token remains the same // This is more secure - refresh token changes only on login/explicit refresh h.setAccessTokenCookie(c, accessToken) - + log.Info(). + Str("handler", "auth"). + Str("action", "refresh_success"). + Str("user_id", user.ID.String()). + Str("session_id", claims.SessionID.String()). + Str("ip", c.RealIP()). + Msg("access token refreshed successfully") // Step 6: Return new access token // Response contains: // - New access token (for non-cookie clients) @@ -398,9 +544,13 @@ func (h *AuthHandler) Refresh(c echo.Context) error { // - MUST NOT retry with old token if refresh fails // - SHOULD implement secure token storage (keychain, secure storage, etc.) func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { + log.Info(). + Str("handler", "auth"). + Str("action", "token_rotation_attempt"). + Str("ip", c.RealIP()). + Msg("token rotation with refresh attempt started") // Step 1: Parse request body (optional - might use cookie instead) var req TokenRefreshRequest - // Attempt to bind JSON from request body // This is optional - we'll also check cookies // Bind error is not fatal here @@ -414,19 +564,36 @@ func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { // Check request body first, then fall back to cookie // This supports both browser and non-browser clients var refreshToken string + + var tokenSource string + if req.RefreshToken != "" { // Token provided in request body (mobile/SPA clients) refreshToken = req.RefreshToken + tokenSource = "request_body" } else { // Try to get token from HTTP-only cookie (browser clients) cookie, err := c.Cookie("refresh_token") if err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_missing_token"). + Str("ip", c.RealIP()). + Msg("refresh token not found in body or cookie") // No token in body or cookie return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token") } refreshToken = cookie.Value + tokenSource = "cookie" } + log.Debug(). + Str("handler", "auth"). + Str("action", "rotation_token_source"). + Str("token_source", tokenSource). + Str("ip", c.RealIP()). + Msg("refresh token source identified") + // Step 3: Validate that we have a token if refreshToken == "" { return echo.NewHTTPError(http.StatusUnauthorized, "missing refresh token") @@ -442,6 +609,13 @@ func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { userAgent := c.Request().UserAgent() ipAddress := c.RealIP() + log.Info(). + Str("handler", "auth"). + Str("action", "rotating_refresh_token"). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Str("token_source", tokenSource). + Msg("rotating refresh token and creating new session") // Step 5: Rotate the refresh token // This process: // 1. Validates old token (JWT + session check) @@ -459,17 +633,43 @@ func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { // Handle specific error types with appropriate HTTP status and messages // Using errors.Is() for proper error comparison if errors.Is(err, services.ErrExpiredToken) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_expired_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("refresh token expired during rotation") + // Refresh token has expired (needs re-login) return echo.NewHTTPError(http.StatusUnauthorized, "refresh token expired") } if errors.Is(err, services.ErrRevokedToken) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_revoked_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("revoked refresh token used in rotation attempt") // Session was revoked (logout, password change, etc.) return echo.NewHTTPError(http.StatusUnauthorized, "refresh token revoked") } if errors.Is(err, services.ErrInvalidToken) { + log.Warn(). + Str("handler", "auth"). + Str("action", "rotation_invalid_token"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Msg("invalid refresh token in rotation attempt") // Token signature invalid or malformed return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token") } + log.Error(). + Str("handler", "auth"). + Str("action", "rotation_failed"). + Str("ip", ipAddress). + Str("token_source", tokenSource). + Err(err). + Msg("token rotation failed with unexpected error") // Generic error for unexpected cases return echo.NewHTTPError(http.StatusUnauthorized, "invalid refresh token") } @@ -483,6 +683,14 @@ func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { h.setAccessTokenCookie(c, newAccessToken) h.setRefreshTokenCookie(c, newRefreshToken) + log.Info(). + Str("handler", "auth"). + Str("action", "rotation_success"). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Str("token_source", tokenSource). + Msg("refresh token rotated successfully, new tokens issued") + // Step 7: Return new tokens in response body // Both browser and non-browser clients receive tokens // Non-browser clients MUST store the new refresh token @@ -518,9 +726,19 @@ func (h *AuthHandler) RefreshTokenWithRotation(c echo.Context) error { // - No errors returned to client (always succeeds) // - Errors are silently handled to prevent information disclosure func (h *AuthHandler) Logout(c echo.Context) error { + log.Info(). + Str("handler", "auth"). + Str("action", "logout_attempt"). + Str("ip", c.RealIP()). + Msg("user logout attempt") // Step 1: Attempt to get refresh token from cookie cookie, err := c.Cookie("refresh_token") if err != nil { + log.Debug(). + Str("handler", "auth"). + Str("action", "logout_no_token"). + Str("ip", c.RealIP()). + Msg("logout attempt without refresh token cookie") // No cookie found - user might have already logged out or session expired // Still clear cookies (might be stale access token) and return success h.clearAuthCookies(c) @@ -529,18 +747,34 @@ func (h *AuthHandler) Logout(c echo.Context) error { // Get request context ctx := c.Request().Context() + log.Info(). + Str("handler", "auth"). + Str("action", "revoking_refresh_token"). + Str("ip", c.RealIP()). + Msg("revoking refresh token for logout") // Step 2: Revoke the refresh token in database // This marks the session as revoked with reason "user_logout" // Updates: is_revoked=true, revoked_at=NOW(), revoked_reason='user_logout' // We ignore errors here - even if revocation fails, we clear client cookies - _ = h.authService.RevokeRefreshToken(ctx, cookie.Value) + if err = h.authService.RevokeRefreshToken(ctx, cookie.Value); err != nil { + log.Warn(). + Str("handler", "auth"). + Str("action", "revocation_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to revoke refresh token during logout (continuing anyway)") + } // Step 3: Clear authentication cookies from browser // Sets MaxAge=-1 which tells browser to immediately delete cookies // Clears both access_token and refresh_token cookies h.clearAuthCookies(c) - + log.Info(). + Str("handler", "auth"). + Str("action", "logout_success"). + Str("ip", c.RealIP()). + Msg("user logged out successfully") // Step 4: Return success with no content // 204 No Content is appropriate for successful logout // No response body needed @@ -566,6 +800,14 @@ func (h *AuthHandler) Logout(c echo.Context) error { // - SameSite: Prevents CSRF attacks by controlling cross-site cookie sending // - Path=/: Makes cookie available to all API endpoints func (h *AuthHandler) setAccessTokenCookie(c echo.Context, token string) { + log.Debug(). + Str("handler", "auth"). + Str("action", "set_access_cookie"). + Str("domain", h.config.Cookie.CookieDomain). + Bool("secure", h.config.Cookie.CookieSecure). + Int("max_age", int(h.config.JWT.AccessExpiry.Seconds())). + Msg("setting access token cookie") + cookie := &http.Cookie{ Name: "access_token", Value: token, @@ -592,6 +834,14 @@ func (h *AuthHandler) setAccessTokenCookie(c echo.Context, token string) { // - Can revoke one without affecting the other // - Follows OAuth 2.0 best practices func (h *AuthHandler) setRefreshTokenCookie(c echo.Context, token string) { + log.Debug(). + Str("handler", "auth"). + Str("action", "set_refresh_cookie"). + Str("domain", h.config.Cookie.CookieDomain). + Bool("secure", h.config.Cookie.CookieSecure). + Int("max_age", int(h.config.JWT.RefreshExpiry.Seconds())). + Msg("setting refresh token cookie") + cookie := &http.Cookie{ Name: "refresh_token", Value: token, @@ -618,6 +868,11 @@ func (h *AuthHandler) setRefreshTokenCookie(c echo.Context, token string) { // - Browser needs these to match original cookie attributes for deletion // - Ensures cookie is properly identified and removed func (h *AuthHandler) clearAuthCookies(c echo.Context) { + log.Debug(). + Str("handler", "auth"). + Str("action", "clear_auth_cookies"). + Msg("clearing access and refresh token cookies") + // Create cookie with MaxAge=-1 to delete access token accessCookie := &http.Cookie{ Name: "access_token", diff --git a/backend/internal/handlers/tenant_handler.go b/backend/internal/handlers/tenant_handler.go new file mode 100644 index 0000000..22fc31d --- /dev/null +++ b/backend/internal/handlers/tenant_handler.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "net/http" + + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +type TenantHandler struct { + tenantService *services.TenantService +} + +func NewTenantHanlder(tenantService *services.TenantService) *TenantHandler { + log.Info(). + Str("handler", "tenant"). + Str("component", "handler_init"). + Msg("tenant handler initialized") + return &TenantHandler{ + tenantService: tenantService, + } +} + +func (th *TenantHandler) GetTenant(c echo.Context) error { + rawTenantId := c.Param("id") + log.Info(). + Str("handler", "tenant"). + Str("action", "get_tenant_attempt"). + Str("tenant_id", rawTenantId). + Str("ip", c.RealIP()). + Msg("attempting to get tenant by id") + + tenantId, err := uuid.Parse(rawTenantId) + if err != nil { + log.Warn(). + Str("handler", "tenant"). + Str("action", "invalid_tenant_id_format"). + Str("invalid_id", rawTenantId). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to parse tenant id - invalid uuid format") + return echo.NewHTTPError(http.StatusBadRequest, "invalid tenant id") + } + + ctx := c.Request().Context() + log.Debug(). + Str("handler", "tenant"). + Str("action", "fetching_tenant_from_service"). + Str("tenant_id", tenantId.String()). + Msg("querying tenant service for tenant data") + + tenant, err := th.tenantService.GetByID(ctx, tenantId) + if err != nil { + log.Warn(). + Str("handler", "tenant"). + Str("action", "tenant_not_found"). + Str("tenant_id", tenantId.String()). + Str("ip", c.RealIP()). + Err(err). + Msg("tenant not found in database") + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "checking_tenant_authorization"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Msg("tenant found, verifying user authorization") + + userTenantId, ok := c.Get("tenant_id").(uuid.UUID) + if !ok { + log.Error(). + Str("handler", "tenant"). + Str("action", "missing_user_tenant_context"). + Interface("context_value", c.Get("tenant_id")). + Str("ip", c.RealIP()). + Msg("user tenant id missing or invalid in request context - middleware issue") + return echo.NewHTTPError(http.StatusBadRequest, "invalid user tenant id") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "tenant_authorization_check"). + Str("requested_tenant_id", tenant.ID.String()). + Str("user_tenant_id", userTenantId.String()). + Bool("match", tenant.ID == userTenantId). + Msg("comparing requested tenant with user's tenant") + + if tenant.ID != userTenantId { + log.Warn(). + Str("handler", "tenant"). + Str("action", "tenant_access_denied"). + Str("requested_tenant_id", tenant.ID.String()). + Str("user_tenant_id", userTenantId.String()). + Str("requested_tenant_name", tenant.Name). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("access denied - user attempted to access different tenant") + return echo.NewHTTPError(http.StatusForbidden, "acces denied") + } + log.Info(). + Str("handler", "tenant"). + Str("action", "get_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("user_tenant_id", userTenantId.String()). + Str("ip", c.RealIP()). + Msg("tenant retrieved successfully") + return c.JSON(http.StatusOK, tenant.ToResponse()) +} + +func (th *TenantHandler) GetMyTenant(c echo.Context) error { + log.Info(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_attempt"). + Str("ip", c.RealIP()). + Msg("user requesting their own tenant information") + tenantID, ok := c.Get("tenant_id").(uuid.UUID) + if !ok { + log.Error(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_missing_context"). + Interface("context_value", c.Get("tenant_id")). + Str("ip", c.RealIP()). + Msg("tenant id missing from authenticated request context - authentication issue") + return echo.NewHTTPError(http.StatusBadRequest, "invalid tenant id") + } + log.Debug(). + Str("handler", "tenant"). + Str("action", "fetching_my_tenant_from_service"). + Str("tenant_id", tenantID.String()). + Msg("querying tenant service for user's tenant") + + ctx := c.Request().Context() + + tenant, err := th.tenantService.GetByID(ctx, tenantID) + if err != nil { + log.Error(). + Str("handler", "tenant"). + Str("action", "my_tenant_not_found"). + Str("tenant_id", tenantID.String()). + Str("ip", c.RealIP()). + Err(err). + Msg("CRITICAL: user's tenant not found in database - data consistency issue") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to retrieve tenant") + } + log.Info(). + Str("handler", "tenant"). + Str("action", "get_my_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("ip", c.RealIP()). + Msg("user's tenant retrieved successfully") + return c.JSON(http.StatusOK, tenant.ToResponse()) + +} diff --git a/backend/internal/handlers/user_handler.go b/backend/internal/handlers/user_handler.go new file mode 100644 index 0000000..a302824 --- /dev/null +++ b/backend/internal/handlers/user_handler.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "net/http" + + "github.com/creativenoz/aurganize-v62/backend/internal/config" + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/creativenoz/aurganize-v62/backend/internal/services" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +type UserRegisterHander struct { + config *config.Config + authService *services.AuthService + userService *services.UserService + tenantService *services.TenantService +} + +func NewUserRegisterHandler(config *config.Config, authService *services.AuthService, userService *services.UserService, tenantService *services.TenantService) *UserRegisterHander { + log.Info(). + Str("handler", "user_register"). + Str("component", "handler_init"). + Bool("has_auth_service", authService != nil). + Bool("has_user_service", userService != nil). + Bool("has_tenant_service", tenantService != nil). + Msg("user registration handler initialized") + return &UserRegisterHander{ + config: config, + authService: authService, + userService: userService, + tenantService: tenantService, + } +} + +type RegisterUserRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + TenantName string `json:"tenant_name" validate:"required"` +} + +type RegisterUserResponse struct { + User *models.UserResponse `json:"user"` + Tenant interface{} `json:"tenant"` + AccessToken string `json:"access_token"` + RefershToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +func (h *UserRegisterHander) Register(c echo.Context) error { + log.Info(). + Str("handler", "user_register"). + Str("action", "registration_attempt"). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("new user registration attempt started") + + var req RegisterUserRequest + if err := c.Bind(&req); err != nil { + log.Warn(). + Str("handler", "user_register"). + Str("action", "registration_bind_failed"). + Str("ip", c.RealIP()). + Err(err). + Msg("failed to bind registration request - malformed json or content-type") + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + } + + if err := c.Validate(&req); err != nil { + log.Warn(). + Str("handler", "user_register"). + Str("action", "registration_validation_failed"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Str("validation_error", err.Error()). + Bool("has_first_name", req.FirstName != nil). + Bool("has_last_name", req.LastName != nil). + Str("ip", c.RealIP()). + Msg("registration validation failed") + + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + log.Info(). + Str("handler", "user_register"). + Str("action", "checking_tenant_availability"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Msg("validated registration request, checking tenant name availability") + + ctx := c.Request().Context() + + tenantExists, err := h.tenantService.SlugExists(ctx, req.TenantName) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "tenant_check_failed"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Err(err). + Msg("failed to check tenant name availability - database or service error") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to check tenant") + } + + if tenantExists { + log.Warn(). + Str("handler", "user_register"). + Str("action", "tenant_name_conflict"). + Str("requested_tenant_name", req.TenantName). + Str("email", req.Email). + Str("ip", c.RealIP()). + Msg("registration failed - organization name already taken") + return echo.NewHTTPError(http.StatusConflict, "organization name already taken") + } + log.Info(). + Str("handler", "user_register"). + Str("action", "creating_tenant_and_user"). + Str("tenant_name", req.TenantName). + Str("email", req.Email). + Bool("has_first_name", req.FirstName != nil). + Bool("has_last_name", req.LastName != nil). + Msg("tenant available, creating organization and user account") + + tenant, user, err := h.tenantService.CreateWithUser(ctx, &models.CreateTenantWithUserInput{ + TenantName: req.TenantName, + Email: &req.Email, + Password: &req.Password, + FirstName: req.FirstName, + LastName: req.LastName, + }) + + if err != nil { + if err == services.ErrEmailAlreadyExists { + log.Warn(). + Str("handler", "user_register"). + Str("action", "email_already_exists"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Str("ip", c.RealIP()). + Msg("registration failed - email already registered") + return echo.NewHTTPError(http.StatusConflict, "email already registered") + } + if err == services.ErrWeakPassword { + log.Warn(). + Str("handler", "user_register"). + Str("action", "weak_password_rejected"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Int("password_length", len(req.Password)). + Msg("registration failed - password too weak") + return echo.NewHTTPError(http.StatusBadRequest, "password is too weak") + } + log.Error(). + Str("handler", "user_register"). + Str("action", "registration_failed_unexpected"). + Str("email", req.Email). + Str("tenant_name", req.TenantName). + Err(err). + Msg("registration failed - unexpected error during account creation") + return echo.NewHTTPError(http.StatusInternalServerError, "registration failed") + } + log.Info(). + Str("handler", "user_register"). + Str("action", "account_created_successfully"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("tenant_name", tenant.Name). + Str("email", user.Email). + Str("user_role", user.Role). + Msg("account created successfully, generating authentication tokens") + // Generate Tokens + accessToken, err := h.authService.GenerateAccessToken(user) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "access_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Err(err). + Msg("CRITICAL: account created but failed to generate access token") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate access token") + } + + userAgent := c.Request().UserAgent() + ipAddress := c.RealIP() + + log.Debug(). + Str("handler", "user_register"). + Str("action", "generating_refresh_token"). + Str("user_id", user.ID.String()). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Msg("generating refresh token and creating first session") + + refreshToken, _, err := h.authService.GenerateRefreshToken(ctx, user, &userAgent, &ipAddress) + if err != nil { + log.Error(). + Str("handler", "user_register"). + Str("action", "refresh_token_generation_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Err(err). + Msg("CRITICAL: account created but failed to generate refresh token") + + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate refresh token") + } + log.Debug(). + Str("handler", "user_register"). + Str("action", "setting_auth_cookies"). + Str("user_id", user.ID.String()). + Str("cookie_domain", h.config.Cookie.CookieDomain). + Bool("cookie_secure", h.config.Cookie.CookieSecure). + Msg("setting access and refresh token cookies") + + h.setAccessTokenCookie(c, accessToken) + h.setRefreshTokenCookie(c, refreshToken) + log.Info(). + Str("handler", "user_register"). + Str("action", "registration_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("tenant_name", tenant.Name). + Str("user_role", user.Role). + Str("ip", ipAddress). + Str("user_agent", userAgent). + Bool("has_full_name", req.FirstName != nil && req.LastName != nil). + Msg("user registration completed successfully") + + return c.JSON( + http.StatusCreated, RegisterUserResponse{ + User: user.ToResponse(), + Tenant: tenant.ToResponse(), + AccessToken: accessToken, + RefershToken: refreshToken, + ExpiresIn: int(h.config.JWT.AccessExpiry.Seconds()), + }, + ) + +} + +func (h *UserRegisterHander) setAccessTokenCookie(c echo.Context, token string) { + cookie := &http.Cookie{ + Name: "access_token", + Value: token, + Path: "/", // Available to all paths + Domain: h.config.Cookie.CookieDomain, + MaxAge: int(h.config.JWT.AccessExpiry.Seconds()), // Browser deletes after this time + Secure: h.config.Cookie.CookieSecure, // HTTPS only in production + HttpOnly: true, // JavaScript cannot access (XSS protection) + SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection + } + + c.SetCookie(cookie) +} + +func (h *UserRegisterHander) setRefreshTokenCookie(c echo.Context, token string) { + cookie := &http.Cookie{ + Name: "refresh_token", + Value: token, + Path: "/", // Available to all paths + Domain: h.config.Cookie.CookieDomain, + MaxAge: int(h.config.JWT.RefreshExpiry.Seconds()), // Much longer than access token + Secure: h.config.Cookie.CookieSecure, // HTTPS only in production + HttpOnly: true, // JavaScript cannot access (XSS protection) + SameSite: h.parseSameSite(h.config.Cookie.CookieSameSite), // CSRF protection + } + + c.SetCookie(cookie) +} + +func (h *UserRegisterHander) parseSameSite(s string) http.SameSite { + switch s { + case "strict": + return http.SameSiteStrictMode // Never send cookie cross-site + case "lax": + return http.SameSiteLaxMode // Send on top-level navigation only + case "none": + return http.SameSiteNoneMode // Always send (requires Secure=true) + default: + return http.SameSiteDefaultMode // Let browser decide + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 19f176f..26531f4 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -6,6 +6,7 @@ import ( "github.com/creativenoz/aurganize-v62/backend/internal/services" "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" ) // AuthMiddleware provides authentication middleware for protecting routes. @@ -64,6 +65,11 @@ type AuthMiddleware struct { // authMiddleware := middleware.NewAuthMiddleware(authService) // e.GET("/protected", handler, authMiddleware.Authenticate) func NewAuthMiddleware(authService *services.AuthService) *AuthMiddleware { + log.Info(). + Str("middleware", "auth"). + Str("component", "middleware_init"). + Bool("has_auth_service", authService != nil). + Msg("authentication middleware initialized") return &AuthMiddleware{ authService: authService, } @@ -133,19 +139,48 @@ func NewAuthMiddleware(authService *services.AuthService) *AuthMiddleware { // protected.POST("/posts", createPost) func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + log.Debug(). + Str("middleware", "auth"). + Str("action", "authenticate_check_started"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("checking authentication for protected route") // Step 1: Try to get token from cookie first (browser clients) // This is the preferred method for web applications token, err := c.Cookie("access_token") var tokenString string + var tokenSource string if err == nil { + tokenSource = "cookie" // Cookie found - use its value // This path is taken by browser-based clients tokenString = token.Value + log.Debug(). + Str("middleware", "auth"). + Str("action", "token_found_in_cookie"). + Str("path", c.Request().URL.Path). + Msg("access token found in cookie") } else { + log.Debug(). + Str("middleware", "auth"). + Str("action", "no_cookie_checking_header"). + Str("path", c.Request().URL.Path). + Msg("no cookie found, checking authorization header") // Step 2: Cookie not found, try Authorization header (mobile/API clients) // Expected format: "Authorization: Bearer " authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { + log.Warn(). + Str("middleware", "auth"). + Str("action", "missing_authentication"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("authentication required but no token provided") + // No cookie AND no header - user is not authenticated return echo.NewHTTPError(http.StatusUnauthorized, "missing authentication token") } @@ -155,6 +190,14 @@ func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { parts := strings.Split(authHeader, " ") // Validate header format if len(parts) != 2 || parts[0] != "Bearer" { + log.Warn(). + Str("middleware", "auth"). + Str("action", "invalid_auth_header_format"). + Str("invalid_header", authHeader). + Int("header_parts_count", len(parts)). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("authorization header present but format is invalid") // Invalid format examples: // - "Bearer" (no token) // - "Bearer token extra" (too many parts) @@ -164,7 +207,21 @@ func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { } // Extract token (second part after "Bearer ") tokenString = parts[1] + tokenSource = "header" + + log.Debug(). + Str("middleware", "auth"). + Str("action", "token_found_in_header"). + Str("path", c.Request().URL.Path). + Msg("access token found in authorization header") } + + log.Debug(). + Str("middleware", "auth"). + Str("action", "validating_access_token"). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Msg("validating access token") // Step 4: Validate the access token // This checks: // - JWT signature (proves token wasn't tampered) @@ -175,15 +232,46 @@ func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { if err != nil { // Handle specific error types if err == services.ErrExpiredToken { + log.Warn(). + Str("middleware", "auth"). + Str("action", "expired_token"). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("access token has expired") + // Token is valid but expired // Client should use refresh token to get new access token // Return specific message so client knows to refresh return echo.NewHTTPError(http.StatusUnauthorized, "token has expired") } + log.Warn(). + Str("middleware", "auth"). + Str("action", "invalid_token"). + Err(err). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("token validation failed - invalid or tampered token") // Other errors: invalid signature, wrong type, malformed, etc. // Return generic error to avoid leaking information return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") } + + log.Info(). + Str("middleware", "auth"). + Str("action", "authentication_success"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("email", claims.Email). + Str("role", claims.Role). + Str("token_source", tokenSource). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Msg("user authenticated successfully") // Step 5: Token is valid - store claims in context // Context values can be retrieved by downstream handlers // This avoids re-validating token in every handler @@ -288,23 +376,50 @@ func (m *AuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { // } func (m *AuthMiddleware) OptionalAuth(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_check_started"). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Str("ip", c.RealIP()). + Msg("checking optional authentication") // Step 1: Try to get token from cookie // We only check cookies for optional auth (not Authorization header) // This is intentional - optional auth is primarily for browser clients token, err := c.Cookie("access_token") if err != nil { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_anonymous"). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Str("user_agent", c.Request().UserAgent()). + Msg("no authentication cookie - proceeding as anonymous user") // No cookie found - user is anonymous // This is OKAY for optional auth // Proceed to handler without setting context values // Handler will see nil values and know user is not authenticated return next(c) } + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_validating_token"). + Str("path", c.Request().URL.Path). + Msg("cookie found in optional auth, validating token") // Step 2: Cookie found - validate the token // Even though auth is optional, we validate if token is present // This ensures we don't use invalid/expired tokens claims, err := m.authService.ValidateAccessToken(token.Value) if err != nil { + log.Debug(). + Str("middleware", "auth"). + Str("action", "optional_auth_token_invalid"). + Err(err). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("token validation failed in optional auth - proceeding as anonymous") + // Token is invalid or expired // For optional auth, we don't return error // Just proceed without setting context values @@ -312,7 +427,16 @@ func (m *AuthMiddleware) OptionalAuth(next echo.HandlerFunc) echo.HandlerFunc { // This provides graceful degradation return next(c) } - + log.Info(). + Str("middleware", "auth"). + Str("action", "optional_auth_authenticated"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("email", claims.Email). + Str("role", claims.Role). + Str("path", c.Request().URL.Path). + Str("ip", c.RealIP()). + Msg("authenticated user accessing optionally-protected route") // Step 3: Token is valid - store claims in context // Handler can now detect authenticated user via c.Get("user_id") // Same values as Authenticate middleware diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index 7c5dff7..1be4b9f 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -3,6 +3,7 @@ package middleware import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog/log" ) // NewCORSMiddleware creates and configures Cross-Origin Resource Sharing (CORS) middleware @@ -54,7 +55,22 @@ import ( // // Echo middleware function that handles CORS for all routes func NewCORSMiddleware() echo.MiddlewareFunc { + log.Info(). + Str("middleware", "cors"). + Str("component", "middleware_init"). + Strs("allowed_origins", []string{"http://localhost:5173", "http://localhost:3000"}). + Bool("allow_credentials", true). + Int("max_age", 3600). + Str("allowed_methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"). + Str("allowed_headers", "Origin, Content-Type, Accept, Authorization"). + Msg("CORS middleware initialized with security configuration") + + log.Warn(). + Str("middleware", "cors"). + Str("action", "development_origins_configured"). + Msg("CORS configured with localhost origins - ensure this is updated for production") return middleware.CORSWithConfig(middleware.CORSConfig{ + // AllowOrigins specifies which frontend domains can make requests to this API. // These are the URLs where your React/Vue/Angular frontend is hosted. // Browser will reject requests from any origin not in this list. diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go index 6c569bf..3c28fbd 100644 --- a/backend/internal/middleware/rate_limiter.go +++ b/backend/internal/middleware/rate_limiter.go @@ -5,6 +5,7 @@ import ( "time" "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" ) // RateLimiter implements a sliding window rate limiting algorithm to prevent abuse. @@ -120,6 +121,35 @@ type RateLimiter struct { // - Old timestamps cleaned automatically // - No manual cleanup needed func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + log.Info(). + Str("middleware", "rate_limiter"). + Str("component", "middleware_init"). + Int("limit", limit). + Dur("window", window). + Float64("requests_per_second", float64(limit)/window.Seconds()). + Msg("rate limiter initialized with security limits") + if limit <= 0 { + log.Error(). + Str("middleware", "rate_limiter"). + Str("action", "invalid_limit_config"). + Int("limit", limit). + Msg("CRITICAL: rate limiter configured with zero or negative limit - all requests will be blocked!") + } else if limit > 1000 { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "very_high_limit"). + Int("limit", limit). + Dur("window", window). + Msg("rate limiter configured with very high limit - may not prevent abuse effectively") + } else if limit < 3 && window < time.Minute { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "very_strict_limit"). + Int("limit", limit). + Dur("window", window). + Msg("rate limiter configured with very strict limit - may impact legitimate users") + } + return &RateLimiter{ requests: make(map[string][]time.Time), limit: limit, @@ -139,13 +169,7 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter { // 6. If exceeded: Return 429 Too Many Requests // 7. If allowed: Add current request and proceed // 8. Unlock mutex (allow next request to be processed) -// -// Sliding window algorithm explained: -// Window: [----------1 minute----------] -// Now: ^ -// Window start: ^ -// Only count requests between window start and now -// + // Example timeline (limit=3, window=1 minute): // 10:00:00 - Request 1 ✅ Count: 1 // 10:00:20 - Request 2 ✅ Count: 2 @@ -207,6 +231,7 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter { // - Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining) func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + // Step 1: Get client's IP address // RealIP() handles: // - X-Forwarded-For header (proxies) @@ -214,7 +239,13 @@ func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { // - RemoteAddr (direct connections) // Example: "192.168.1.100" or "2001:db8::1" (IPv6) ip := c.RealIP() - + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_check_started"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Msg("checking rate limit for request") // Step 2: Lock the mutex for thread-safe map access // CRITICAL: This prevents race conditions when multiple requests arrive simultaneously // What happens without lock: @@ -248,6 +279,8 @@ func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { // Create a new slice to store valid (recent) requests validRequests := []time.Time{} + expiredCount := 0 + // Iterate through all previous requests from this IP for _, req := range request { // Check if request timestamp is after the window start @@ -259,11 +292,32 @@ func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { // Request at 10:04:20 ❌ Before window start (discard) if req.After(windowStart) { validRequests = append(validRequests, req) + } else { + expiredCount++ } // Old requests are automatically garbage collected // This keeps memory usage bounded } + if expiredCount > 0 { + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "expired_requests_cleaned"). + Str("ip", ip). + Int("expired_count", expiredCount). + Int("remaining_count", len(validRequests)). + Msg("cleaned up expired requests from sliding window") + } + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_decision"). + Str("ip", ip). + Int("current_count", len(validRequests)). + Int("limit", rl.limit). + Dur("window", rl.window). + Bool("will_allow", len(validRequests) < rl.limit). + Msg("evaluating rate limit threshold") + // Step 7: Check if rate limit is exceeded // Count how many valid requests exist // If count >= limit, block the request @@ -273,6 +327,33 @@ func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { // validRequests length=5 ❌ Block (5 >= 5) // validRequests length=6 ❌ Block (6 >= 5) if len(validRequests) >= rl.limit { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_exceeded"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Str("method", c.Request().Method). + Int("current_count", len(validRequests)). + Int("limit", rl.limit). + Dur("window", rl.window). + Str("user_agent", c.Request().UserAgent()). + Msg("rate limit exceeded - request blocked") + + path := c.Request().URL.Path + if path == "/auth/login" || path == "/api/auth/login" { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "login_rate_limit_hit"). + Str("ip", ip). + Int("attempt_count", len(validRequests)). + Msg("SECURITY: multiple failed login attempts - possible brute force attack") + } else if path == "/auth/register" || path == "/api/auth/register" { + log.Warn(). + Str("middleware", "rate_limiter"). + Str("action", "register_rate_limit_hit"). + Str("ip", ip). + Msg("SECURITY: multiple registration attempts - possible spam or abuse") + } // IMPORTANT: Unlock mutex before returning error // Without this, mutex stays locked forever (deadlock!) rl.mu.Unlock() @@ -298,7 +379,25 @@ func (rl *RateLimiter) Limit(next echo.HandlerFunc) echo.HandlerFunc { // - Current request (just made) // Old requests outside window are now garbage collected rl.requests[ip] = validRequests + uniqueIpCount := len(rl.requests) + if uniqueIpCount%100 == 0 { + log.Info(). + Str("middleware", "rate_limiter"). + Str("action", "unique_ip_milestone"). + Int("unique_ip_count", uniqueIpCount). + Int("limit", rl.limit). + Dur("window", rl.window). + Msg("rate limiter tracking milestone reached") + } + log.Debug(). + Str("middleware", "rate_limiter"). + Str("action", "rate_limit_allowed"). + Str("ip", ip). + Str("path", c.Request().URL.Path). + Int("current_count", len(validRequests)). + Int("remaining", rl.limit-len(validRequests)). + Msg("request allowed through rate limiter") // Step 10: Unlock the mutex // CRITICAL: Must unlock before calling next handler // Why: Next handler might take time (database query, etc.) diff --git a/backend/internal/models/tenants.go b/backend/internal/models/tenants.go new file mode 100644 index 0000000..6be2c3d --- /dev/null +++ b/backend/internal/models/tenants.go @@ -0,0 +1,82 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Tenant struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Email *string `json:"email" db:"email"` + Phone *string `json:"phone" db:"phone"` + Website *string `json:"website" db:"website"` + AddressLine1 *string `json:"address_line1" db:"address_line1"` + AddressLine2 *string `json:"address_line2" db:"address_line2"` + City *string `json:"city" db:"city"` + State *string `json:"state" db:"state"` + Country *string `json:"country" db:"country"` + PostalCode *string `json:"postal_code" db:"postal_code"` + Timezone string `json:"timezone" db:"timezone"` + Currency string `json:"currency" db:"currency"` + Locale string `json:"locale" db:"locale"` + SubscriptionStatus string `json:"subscription_status" db:"subscription_status"` + SubscriptionPlan string `json:"subscription_plan" db:"subscription_plan"` + SubscriptionExpiresAt *time.Time `json:"subscription_expires_at" db:"subscription_expires_at"` + TrialEnds *time.Time `json:"trial_ends_at" db:"trial_ends_at"` + MaxUsers int `json:"max_users" db:"max_users"` + MaxContracts int `json:"max_contracts" db:"max_contracts"` + MaxStorageMB int `json:"max_storage_mb" db:"max_storage_mb"` + Status string `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` + DeletedAt *time.Time `json:"deleted_at" db:"deleted_at"` +} + +type CreateTenantInput struct { + Name string + Email *string + Timezone string + Currency string + Locale string +} + +type CreateTenantWithUserInput struct { + TenantName string + Email *string + Password *string + FirstName *string + LastName *string +} + +type TenantResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Email *string `json:"email"` + Timezone string `json:"timezone"` + Currency string `json:"currency"` + SubscriptionStatus string `json:"subscription_status"` + SubscriptionPlan string `json:"subscription_plan"` + TrialEnds *time.Time `json:"trial_ends_at"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *Tenant) ToResponse() *TenantResponse { + return &TenantResponse{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Email: t.Email, + Timezone: t.Timezone, + Currency: t.Currency, + SubscriptionStatus: t.SubscriptionStatus, + SubscriptionPlan: t.SubscriptionPlan, + TrialEnds: t.TrialEnds, + Status: t.Status, + CreatedAt: t.CreatedAt, + } +} diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index dd4a3a9..ff7442e 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -46,7 +46,7 @@ type User struct { // Multi-tenancy: Isolates data between organizations // Required: Every user must belong to a tenant // Used for: Filtering queries, enforcing data isolation - TenantID uuid.UUID `json:"tenant_id" bson:"tenant_id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` // Email is the user's email address (unique identifier for login) // Type: String (validated format, max 254 chars) diff --git a/backend/internal/repositories/session_repository.go b/backend/internal/repositories/session_repository.go index 4d933e3..b012fb6 100644 --- a/backend/internal/repositories/session_repository.go +++ b/backend/internal/repositories/session_repository.go @@ -5,10 +5,12 @@ import ( "crypto/sha256" "database/sql" "encoding/base64" + "time" "github.com/creativenoz/aurganize-v62/backend/internal/models" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" ) // SessionRepository handles all database operations related to user sessions. @@ -48,6 +50,11 @@ type SessionRepository struct { // Returns: // - Initialized SessionRepository ready to perform database operations func NewSessionRepository(db *sqlx.DB) *SessionRepository { + log.Info(). + Str("repository", "session"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("session repository initialized") return &SessionRepository{db: db} } @@ -105,6 +112,16 @@ func (r *SessionRepository) Create(ctx context.Context, input *models.CreateSess // - Example: Hash("mytoken") always gives "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" // // NEW CODE: Hash token using SHA-256 for deterministic lookup + + log.Info(). + Str("repository", "session"). + Str("action", "create_session_started"). + Str("user_id", input.UserID.String()). + Str("device_type", input.DeviceType). + Str("ip_address", *input.IPAddress). + Bool("has_device_name", input.DeviceName != nil). + Msg("creating new session record") + hash := hashToken(input.RefreshToken) // Prepare session struct to receive database response @@ -147,6 +164,26 @@ func (r *SessionRepository) Create(ctx context.Context, input *models.CreateSess input.DeviceType, input.ExpiresAt, ) + + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "create_session_failed"). + Str("user_id", input.UserID.String()). + Str("device_type", input.DeviceType). + Err(err). + Msg("failed to create session record in database") + return nil, err + } + log.Info(). + Str("repository", "session"). + Str("action", "create_session_success"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("device_type", session.DeviceType). + Str("ip_address", *session.IPAddress). + Time("expires_at", session.ExpiresAt). + Msg("session created successfully") return session, err } @@ -181,6 +218,11 @@ func (r *SessionRepository) Create(ctx context.Context, input *models.CreateSess // - (nil, nil): Session not found (not revoked/expired, or doesn't exist) // - (nil, error): Database error occurred func (r *SessionRepository) FindBySessionIDAndToken(ctx context.Context, sessionId uuid.UUID, token string) (*models.Session, error) { + log.Debug(). + Str("repository", "session"). + Str("action", "find_session_by_token_started"). + Str("session_id", sessionId.String()). + Msg("looking up session by session id and refresh token") session := &models.Session{} // SQL query with multiple conditions for security @@ -212,9 +254,31 @@ func (r *SessionRepository) FindBySessionIDAndToken(ctx context.Context, session // sql.ErrNoRows means query executed successfully but returned no rows // This is not an error condition - it just means session doesn't exist or is invalid if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "session"). + Str("action", "session_not_found"). + Str("session_id", sessionId.String()). + Msg("session not found or invalid - may be expired, revoked, or token mismatch") return nil, nil // Return nil session and nil error } + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "find_session_error"). + Str("session_id", sessionId.String()). + Err(err). + Msg("database error while looking up session") + return nil, err + } + log.Debug(). + Str("repository", "session"). + Str("action", "session_found_valid"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("device_type", session.DeviceType). + Time("last_used_at", session.LastUsedAt). + Msg("session found and validated successfully") return session, err } @@ -237,6 +301,12 @@ func (r *SessionRepository) FindBySessionIDAndToken(ctx context.Context, session // - (nil, nil): Session not found or is invalid // - (nil, error): Database error occurred func (r *SessionRepository) FindById(ctx context.Context, id uuid.UUID) (*models.Session, error) { + log.Debug(). + Str("repository", "session"). + Str("action", "find_session_by_id_started"). + Str("session_id", id.String()). + Msg("looking up session by id") + session := &models.Session{} // Query for session by ID with validation checks @@ -259,9 +329,29 @@ func (r *SessionRepository) FindById(ctx context.Context, id uuid.UUID) (*models // Handle "not found" case if err == sql.ErrNoRows { + log.Debug(). + Str("repository", "session"). + Str("action", "session_not_found_by_id"). + Str("session_id", id.String()). + Msg("session not found by id") return nil, nil // Not found is not an error } - return session, err + + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "find_session_by_id_error"). + Str("session_id", id.String()). + Err(err). + Msg("database error while looking up session by id") + } + log.Debug(). + Str("repository", "session"). + Str("action", "session_found_by_id"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Msg("session found by id") + return session, nil } // UpdateLastUsed updates the last_used_at timestamp for a session. @@ -284,6 +374,12 @@ func (r *SessionRepository) FindById(ctx context.Context, id uuid.UUID) (*models // - Returns error if update fails (database error) // - Caller usually ignores this error (not critical for token refresh) func (r *SessionRepository) UpdateLastUsed(ctx context.Context, id uuid.UUID) error { + + log.Debug(). + Str("repository", "session"). + Str("action", "update_last_used_started"). + Str("session_id", id.String()). + Msg("updating session last_used_at timestamp") // SQL update query // Uses NOW() for database-consistent timestamp (not Go's time.Now()) query := ` @@ -296,13 +392,35 @@ func (r *SessionRepository) UpdateLastUsed(ctx context.Context, id uuid.UUID) er // Returns: // - sql.Result: Contains rows affected, last insert ID, etc. // - error: Database error if query fails - _, err := r.db.ExecContext( + result, err := r.db.ExecContext( ctx, query, id, // $1 - Session ID ) + if err != nil { + log.Warn(). + Str("repository", "session"). + Str("action", "update_last_used_failed"). + Str("session_id", id.String()). + Err(err). + Msg("failed to update session last_used_at timestamp") + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "session"). + Str("action", "update_last_used_no_rows"). + Str("session_id", id.String()). + Msg("update succeeded but no session was modified - session may not exist") + } else { + log.Debug(). + Str("repository", "session"). + Str("action", "update_last_used_success"). + Str("session_id", id.String()). + Msg("session last_used_at updated successfully") + } - return err + return nil } // Revoke marks a session as revoked, preventing its refresh token from being used. @@ -337,6 +455,12 @@ func (r *SessionRepository) UpdateLastUsed(ctx context.Context, id uuid.UUID) er // - Returns error if update fails // - No error if session doesn't exist (idempotent operation) func (r *SessionRepository) Revoke(ctx context.Context, token string, reason string) error { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_session_started"). + Str("revoke_reason", reason). + Msg("revoking session by token") + // Hash the token to find corresponding session // We store hashed tokens, so we must hash to look up tokenHash := hashToken(token) @@ -353,14 +477,39 @@ func (r *SessionRepository) Revoke(ctx context.Context, token string, reason str // Execute update // Note: UPDATE returns success even if no rows matched // This makes the operation idempotent (safe to call multiple times) - _, err := r.db.ExecContext( + results, err := r.db.ExecContext( ctx, query, tokenHash, // $1 - Token hash to find session reason, // $2 - Why session is being revoked ) + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "revoke_session_failed"). + Str("revoke_reason", reason). + Err(err). + Msg("failed to revoke session") + return err + } - return err + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "session"). + Str("action", "revoke_no_session_found"). + Str("revoke_reason", reason). + Msg("revocation succeeded but no session was modified - token may not exist or already revoked") + } else { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_session_success"). + Str("revoke_reason", reason). + Int64("rows_affected", rowsAffected). + Msg("session revoked successfully") + } + + return nil } // RevokeByUserId revokes all sessions for a specific user. @@ -392,6 +541,12 @@ func (r *SessionRepository) Revoke(ctx context.Context, token string, reason str // - Returns error if update fails // - No error if user has no sessions (idempotent) func (r *SessionRepository) RevokeByUserId(ctx context.Context, userID uuid.UUID, reason string) error { + log.Info(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_started"). + Str("user_id", userID.String()). + Str("revoke_reason", reason). + Msg("revoking all sessions for user") // SQL query to revoke all user's sessions query := ` UPDATE sessions @@ -404,14 +559,33 @@ func (r *SessionRepository) RevokeByUserId(ctx context.Context, userID uuid.UUID // Execute update // Could affect 0 to many rows depending on how many sessions user has - _, err := r.db.ExecContext( + result, err := r.db.ExecContext( ctx, query, userID, // $1 - User whose sessions to revoke reason, // $2 - Reason for revocation ) - return err + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_failed"). + Str("user_id", userID.String()). + Str("revoke_reason", reason). + Err(err). + Msg("CRITICAL: failed to revoke all user sessions") + return err + } + rowsAffected, _ := result.RowsAffected() + log.Info(). + Str("repository", "session"). + Str("action", "revoke_all_user_sessions_success"). + Str("user_id", userID.String()). + Int64("sessions_revoked", rowsAffected). + Str("revoke_reason", reason). + Msg("all user sessions revoked successfully") + + return nil } // DeleteExpired removes expired and old revoked sessions from database. @@ -448,6 +622,11 @@ func (r *SessionRepository) RevokeByUserId(ctx context.Context, userID uuid.UUID // - Number of rows deleted (for logging/monitoring) // - Error if delete fails func (r *SessionRepository) DeleteExpired(ctx context.Context) (int64, error) { + log.Info(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_started"). + Msg("starting cleanup of expired and old revoked sessions") + // SQL delete query with two conditions (connected by OR) // Deletes sessions that meet EITHER condition query := ` @@ -464,12 +643,23 @@ func (r *SessionRepository) DeleteExpired(ctx context.Context) (int64, error) { result, err := r.db.ExecContext(ctx, query) if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_failed"). + Err(err). + Msg("failed to delete expired sessions") return 0, err // Return 0 and error if delete fails } + rowsAffected, _ := result.RowsAffected() + log.Info(). + Str("repository", "session"). + Str("action", "delete_expired_sessions_success"). + Int64("sessions_deleted", rowsAffected). + Msg("expired sessions cleanup completed") // Extract number of rows deleted // This is useful for logging: "Deleted 1,234 expired sessions" - return result.RowsAffected() + return rowsAffected, nil } // ListByUserID retrieves all sessions for a specific user. @@ -508,6 +698,12 @@ func (r *SessionRepository) DeleteExpired(ctx context.Context) (int64, error) { // - Slice of sessions (empty slice if user has no sessions) // - Error if query fails func (r *SessionRepository) ListByUserID(ctx context.Context, userId uuid.UUID) ([]*models.Session, error) { + log.Debug(). + Str("repository", "session"). + Str("action", "list_user_sessions_started"). + Str("user_id", userId.String()). + Msg("listing all sessions for user") + // Slice to hold returned sessions var sessions []*models.Session @@ -532,8 +728,31 @@ func (r *SessionRepository) ListByUserID(ctx context.Context, userId uuid.UUID) query, userId, // $1 - User ID ) + if err != nil { + log.Error(). + Str("repository", "session"). + Str("action", "list_user_sessions_failed"). + Str("user_id", userId.String()). + Err(err). + Msg("failed to list user sessions") + return nil, err + } + activeCount := 0 + for _, sesion := range sessions { + if !sesion.IsRevoked && sesion.ExpiresAt.After(time.Now()) { + activeCount++ + } + } - return sessions, err + log.Info(). + Str("repository", "session"). + Str("action", "list_user_sessions_success"). + Str("user_id", userId.String()). + Int("total_sessions", len(sessions)). + Int("active_sessions", activeCount). + Msg("user sessions listed successfully") + + return sessions, nil } // hashToken creates a SHA-256 hash of a token and returns it as a base64-encoded string. diff --git a/backend/internal/repositories/tenant_repository.go b/backend/internal/repositories/tenant_repository.go new file mode 100644 index 0000000..8900d10 --- /dev/null +++ b/backend/internal/repositories/tenant_repository.go @@ -0,0 +1,392 @@ +package repositories + +import ( + "context" + "database/sql" + "time" + + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" +) + +type TenantRepository struct { + db *sqlx.DB +} + +func NewTenantRepository(db *sqlx.DB) *TenantRepository { + log.Info(). + Str("repository", "tenant"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("tenant repository initialized") + return &TenantRepository{ + db: db, + } +} + +type Execer interface { + GetContext(ctx context.Context, des interface{}, query string, args ...interface{}) error + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row +} + +func (tr *TenantRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateTenantInput, slug string) (*models.Tenant, error) { + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_started"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Str("timezone", input.Timezone). + Str("subscription_plan", "basic"). + Str("subscription_status", "trial"). + Bool("in_transaction", true). + Msg("creating tenant within transaction") + tenant := &models.Tenant{} + // trailEndsAt := time.Now().Add(14 * 24 * time.Hour) + loc, err := time.LoadLocation(input.Timezone) + if err != nil { + log.Warn(). + Str("repository", "tenant"). + Str("action", "invalid_timezone_fallback"). + Str("invalid_timezone", input.Timezone). + Str("fallback_timezone", "UTC"). + Str("tenant_name", input.Name). + Msg("invalid timezone provided, falling back to UTC") + + loc = time.UTC + } + now := time.Now().In(loc) + trialEndsAt := now.Add(14 * 24 * time.Hour) + + log.Debug(). + Str("repository", "tenant"). + Str("action", "trial_period_calculated"). + Int("trial_duration_days", 14). + Time("trial_ends_at", trialEndsAt). + Str("tenant_name", input.Name). + Msg("trial period configured for new tenant") + + query := ` + INSERT INTO tenants ( + name, + slug, + email, + timezone, + currency, + locale, + subscription_status, + subscription_plan, + trial_ends_at, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + ` + err = tx.GetContext( + ctx, + tenant, + query, + input.Name, + slug, + input.Email, + input.Timezone, + input.Currency, + input.Locale, + "trial", + "basic", + trialEndsAt, + "active", + ) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_failed"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Bool("in_transaction", true). + Err(err). + Msg("CRITICAL: failed to create tenant in transaction - registration will fail") + return nil, err + } + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_tx_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("email", *tenant.Email). + Str("subscription_status", tenant.SubscriptionStatus). + Str("subscription_plan", tenant.SubscriptionPlan). + Time("trial_ends_at", *tenant.TrialEnds). + Str("currency", tenant.Currency). + Str("timezone", tenant.Timezone). + Bool("in_transaction", true). + Msg("tenant created successfully in transaction") + + return tenant, nil +} + +// Create creates a new tenant +func (tr *TenantRepository) Create(ctx context.Context, input *models.CreateTenantInput, slug string) (*models.Tenant, error) { + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_started"). + Str("tenant_name", input.Name). + Str("slug", slug). + Str("email", *input.Email). + Str("timezone", input.Timezone). + Bool("in_transaction", false). + Msg("creating tenant (standalone, not in transaction)") + + tenant := &models.Tenant{} + + // trailEndsAt := time.Now().Add(14 * 24 * time.Hour) + + loc, err := time.LoadLocation(input.Timezone) + if err != nil { + log.Warn(). + Str("repository", "tenant"). + Str("action", "invalid_timezone_fallback"). + Str("invalid_timezone", input.Timezone). + Str("fallback_timezone", "UTC"). + Str("tenant_name", input.Name). + Msg("invalid timezone provided, falling back to UTC") + loc = time.UTC + } + now := time.Now().In(loc) + trialEndsAt := now.Add(14 * 24 * time.Hour) + + log.Debug(). + Str("repository", "tenant"). + Str("action", "trial_period_calculated"). + Int("trial_duration_days", 14). + Time("trial_ends_at", trialEndsAt). + Str("tenant_name", input.Name). + Msg("trial period configured for new tenant") + + query := ` + INSERT INTO tenants ( + name, + slug, + email, + timezone, + currency, + locale, + subscription_status, + subscription_plan, + trial_ends_at, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + ` + + err = tr.db.GetContext( + ctx, + tenant, + query, + input.Name, + slug, + input.Email, + input.Timezone, + input.Currency, + input.Locale, + "trial", + "basic", + trialEndsAt, + "active", + ) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "create_tenant_failed"). + Str("tenant_name", input.Name). + Str("slug", slug). + Err(err). + Msg("failed to create tenant") + return nil, err + } + log.Info(). + Str("repository", "tenant"). + Str("action", "create_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("subscription_status", tenant.SubscriptionStatus). + Time("trial_ends_at", *tenant.TrialEnds). + Bool("in_transaction", false). + Msg("tenant created successfully") + return tenant, nil +} + +func (tr *TenantRepository) FindByID(ctx context.Context, tenantId uuid.UUID) (*models.Tenant, error) { + log.Debug(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_id_started"). + Str("tenant_id", tenantId.String()). + Msg("looking up tenant by id") + + tenant := &models.Tenant{} + + query := ` + SELECT id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + FROM tenants + WHERE id = $1 + AND deleted_at IS NULL + ` + + err := tr.db.GetContext(ctx, tenant, query, tenantId) + + if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "tenant"). + Str("action", "tenant_not_found_by_id"). + Str("tenant_id", tenantId.String()). + Msg("tenant not found - may be deleted or never existed") + return nil, nil + } + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_id_error"). + Str("tenant_id", tenantId.String()). + Err(err). + Msg("database error while looking up tenant by id") + return nil, err + } + log.Debug(). + Str("repository", "tenant"). + Str("action", "tenant_found_by_id"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("subscription_status", tenant.SubscriptionStatus). + Str("subscription_plan", tenant.SubscriptionPlan). + Str("status", tenant.Status). + Msg("tenant found by id") + + return tenant, nil +} + +func (tr *TenantRepository) FindBySlug(ctx context.Context, slug string) (*models.Tenant, error) { + + log.Debug(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_slug_started"). + Str("slug", slug). + Msg("looking up tenant by slug") + + tenant := &models.Tenant{} + + query := ` + SELECT id, name, slug, email, phone, website, + address_line1, address_line2, city, state, country, postal_code, + timezone, currency, locale, subscription_status, subscription_plan, + subscription_expires_at, trial_ends_at, max_users, max_contracts, + max_storage_mb, status, created_at, updated_at, deleted_at + FROM tenants + WHERE slug = $1 + AND deleted_at IS NULL + ` + err := tr.db.GetContext(ctx, tenant, query, slug) + if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "tenant"). + Str("action", "tenant_not_found_by_slug"). + Str("slug", slug). + Msg("tenant not found by slug") + + return nil, nil + } + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "find_tenant_by_slug_error"). + Str("slug", slug). + Err(err). + Msg("database error while looking up tenant by slug") + return nil, err + } + + log.Debug(). + Str("repository", "tenant"). + Str("action", "tenant_found_by_slug"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("subscription_status", tenant.SubscriptionStatus). + Msg("tenant found by slug") + return tenant, nil + +} + +func (tr *TenantRepository) SlugExists(ctx context.Context, slug string) (bool, error) { + + log.Debug(). + Str("repository", "tenant"). + Str("action", "checking_slug_exists"). + Str("slug", slug). + Msg("checking if tenant slug is available") + + var exists bool = false + + query := ` + SELECT EXISTS ( + SELECT 1 FROM tenants + WHERE slug = $1 + AND deleted_at IS NULL + ) + ` + + err := tr.db.GetContext(ctx, &exists, query, slug) + + if err != nil { + log.Error(). + Str("repository", "tenant"). + Str("action", "slug_exists_check_error"). + Str("slug", slug). + Err(err). + Msg("database error while checking slug existence") + return true, err + } + + if exists { + log.Info(). + Str("repository", "tenant"). + Str("action", "slug_exists_conflict"). + Str("slug", slug). + Bool("exists", true). + Msg("slug already taken - registration will require different name") + } else { + log.Debug(). + Str("repository", "tenant"). + Str("action", "slug_available"). + Str("slug", slug). + Bool("exists", false). + Msg("slug is available for registration") + } + return exists, nil +} + +func (tr TenantRepository) Update(ctx context.Context, id uuid.UUID, updates map[string]interface{}) error { + // TODO + return nil +} diff --git a/backend/internal/repositories/user_repository.go b/backend/internal/repositories/user_repository.go index 28c4d4c..3bc0788 100644 --- a/backend/internal/repositories/user_repository.go +++ b/backend/internal/repositories/user_repository.go @@ -8,6 +8,7 @@ import ( "github.com/creativenoz/aurganize-v62/backend/internal/models" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) @@ -51,6 +52,11 @@ type UserRepository struct { // Returns: // - Initialized UserRepository ready for use func NewUserRepository(db *sqlx.DB) *UserRepository { + log.Info(). + Str("repository", "user"). + Str("component", "repository_init"). + Bool("has_db_connection", db != nil). + Msg("user repository initialized") return &UserRepository{db: db} } @@ -96,17 +102,43 @@ func NewUserRepository(db *sqlx.DB) *UserRepository { // 3. Scan returned row into user struct // 4. Return populated user object func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInput) (*models.User, error) { + log.Info(). + Str("repository", "user"). + Str("action", "create_user_started"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Str("status", input.Status). + Bool("has_first_name", input.FirstName != nil). + Bool("has_last_name", input.LastName != nil). + Msg("creating new user") // Step 1: Hash the password using bcrypt // bcrypt.GenerateFromPassword: // - Takes password as []byte // - Takes cost factor (DefaultCost = 10) // - Returns hash as []byte (e.g., "$2a$10$...") // - Returns error if hashing fails (very rare, maybe out of memory) + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing user password with bcrypt") hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "password_hashing_failed"). + Str("email", input.Email). + Int("bcrypt_cost", bcrypt.DefaultCost). + Err(err). + Msg("CRITICAL: failed to hash password with bcrypt") // Wrap error with context for better debugging return nil, fmt.Errorf("failed to hash password : %w", err) } + log.Debug(). + Str("repository", "user"). + Str("action", "password_hashed"). + Msg("password hashed successfully") // Prepare user struct to receive database response user := &models.User{} @@ -148,9 +180,141 @@ func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInp input.Role, input.Status, ) + + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "create_user_failed"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Err(err). + Msg("failed to create user in database") + return nil, err + } + log.Info(). + Str("repository", "user"). + Str("action", "create_user_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("role", user.Role). + Str("status", user.Status). + Str("full_name", user.FullName). + Bool("email_verified", user.EmailVerified). + Msg("user created successfully") + return user, err } +// CreateTx creates a new user within an existing transaction. +// This is used during registration to ensure tenant and user are created atomically. +// +// Why this exists: +// - Registration creates tenant + user in one transaction +// - User INSERT has FK constraint to tenants.id +// - FK check must run within the same transaction to see uncommitted tenant +// - Using r.db.GetContext() would use a different session (FK validation fails) +// - Using tx.GetContext() keeps everything in the same transaction +// +// Parameters: +// - ctx: Context for cancellation/timeout +// - tx: Transaction object (implements Execer interface) +// - input: User creation data +// +// Returns: +// - (*User, nil): User created successfully within transaction +// - (nil, error): Failed (password hashing or database error) +func (r *UserRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateUserInput) (*models.User, error) { + log.Info(). + Str("repository", "user"). + Str("action", "create_user_in_transaction"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Str("role", input.Role). + Bool("in_transaction", true). + Msg("creating new user within transaction") + + // Hash password (same as Create method) + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing user password with bcrypt") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "password_hashing_failed"). + Str("email", input.Email). + Err(err). + Msg("CRITICAL: failed to hash password with bcrypt in transaction") + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + log.Debug(). + Str("repository", "user"). + Str("action", "password_hashed"). + Msg("password hashed successfully") + + user := &models.User{} + + query := ` + INSERT INTO users ( + tenant_id, + email, + password_hash, + first_name, + last_name, + role, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, tenant_id, email, password_hash, first_name, last_name, + full_name, avatar_url, phone, role, status, email_verified, + email_verified_at, is_onboarded, last_login_at, last_login_ip, + created_at, updated_at, deleted_at + ` + + // ✅ CRITICAL: Use tx.GetContext() to stay within transaction + err = tx.GetContext( + ctx, + user, + query, + input.TenantID, + input.Email, + string(hashedPassword), + input.FirstName, + input.LastName, + input.Role, + input.Status, + ) + + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "create_user_in_transaction_failed"). + Str("tenant_id", input.TenantID.String()). + Str("email", input.Email). + Bool("in_transaction", true). + Err(err). + Msg("failed to create user within transaction") + return nil, err + } + + log.Info(). + Str("repository", "user"). + Str("action", "create_user_in_transaction_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("role", user.Role). + Bool("in_transaction", true). + Msg("user created successfully within transaction") + + return user, nil +} + // FindByEmail finds a user by their email address. // This is used during login and email existence checks. // @@ -180,6 +344,12 @@ func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInp // - Allows caller to distinguish between "doesn't exist" and "database error" // - Follows repository pattern best practices func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) { + log.Debug(). + Str("repository", "user"). + Str("action", "find_user_by_email_started"). + Str("email", email). + Msg("looking up user by email") + user := &models.User{} // SQL SELECT query @@ -206,13 +376,35 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models // sql.ErrNoRows means query executed but returned no rows // This is expected when user doesn't exist if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "user"). + Str("action", "user_not_found_by_email"). + Str("email", email). + Msg("user not found by email") + return nil, nil // User not found (not an error) } if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "find_user_by_email_error"). + Str("email", email). + Err(err). + Msg("database error while looking up user by email") return nil, err // Real database error } + log.Debug(). + Str("repository", "user"). + Str("action", "user_found_by_email"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Bool("email_verified", user.EmailVerified). + Msg("user found by email") - return user, err + return user, nil } // FindByID finds a user by their unique ID. @@ -241,6 +433,12 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models // - (nil, nil): User not found or deleted // - (nil, error): Database error occurred func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.User, error) { + log.Debug(). + Str("repository", "user"). + Str("action", "find_user_by_id_started"). + Str("user_id", id.String()). + Msg("looking up user by id") + user := &models.User{} // SQL SELECT query by ID @@ -264,9 +462,24 @@ func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.Us // Handle "not found" case if err == sql.ErrNoRows { + log.Warn(). + Str("repository", "user"). + Str("action", "user_not_found_by_id"). + Str("user_id", id.String()). + Msg("user not found by id - may be deleted") + return nil, nil // User not found (not an error) } if err != nil { + log.Debug(). + Str("repository", "user"). + Str("action", "user_found_by_id"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Msg("user found by id") return nil, err // Database error } return user, nil @@ -297,6 +510,12 @@ func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.Us // - (false, nil): Email available (can register) // - (false, error): Database error occurred func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) { + log.Debug(). + Str("repository", "user"). + Str("action", "checking_email_exists"). + Str("email", email). + Msg("checking if email already exists") + var email_already_exists bool // SQL EXISTS query @@ -317,6 +536,31 @@ func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, e query, email, // $1 - Email to check ) + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "email_exists_check_error"). + Str("email", email). + Err(err). + Msg("database error while checking email existence") + + return true, err + } + if email_already_exists { + log.Info(). + Str("repository", "user"). + Str("action", "email_already_exists"). + Str("email", email). + Bool("exists", true). + Msg("email already registered - registration will be blocked") + } else { + log.Debug(). + Str("repository", "user"). + Str("action", "email_available"). + Str("email", email). + Bool("exists", false). + Msg("email is available for registration") + } return email_already_exists, err } @@ -352,6 +596,17 @@ func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, e // - Caller might ignore (login succeeds even if this fails) // - Non-critical operation (login more important than tracking) func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip *string) error { + var ipStr string + if ip != nil { + ipStr = *ip + } + log.Debug(). + Str("repository", "user"). + Str("action", "update_last_login_started"). + Str("user_id", id.String()). + Str("ip", ipStr). + Msg("updating user last login timestamp and ip") + // SQL UPDATE query // Uses NOW() for consistent database timestamp query := ` @@ -364,13 +619,36 @@ func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip * // Execute update // ExecContext for queries that don't return rows (UPDATE, DELETE) - _, err := r.db.ExecContext( + results, err := r.db.ExecContext( ctx, query, - id, // $1 - User ID - ip, // $2 - IP address (pointer allows NULL) + id, // $1 - User ID + ipStr, // $2 - IP address ) - return err + if err != nil { + log.Warn(). + Str("repository", "user"). + Str("action", "update_last_login_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update last login timestamp") + return err + } + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "user"). + Str("action", "update_last_login_no_rows"). + Str("user_id", id.String()). + Msg("update succeeded but no user was modified - user may not exist") + } else { + log.Debug(). + Str("repository", "user"). + Str("action", "update_last_login_success"). + Str("user_id", id.String()). + Msg("last login updated successfully") + } + return nil } // UpdatePassword updates a user's password. @@ -406,10 +684,27 @@ func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip * // - Returns error if hashing fails (rare, memory issues) // - Returns error if update fails (user not found, database error) func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, password string) error { + log.Info(). + Str("repository", "user"). + Str("action", "update_password_started"). + Str("user_id", id.String()). + Msg("updating user password") + log.Debug(). + Str("repository", "user"). + Str("action", "hashing_new_password"). + Int("bcrypt_cost", bcrypt.DefaultCost). + Msg("hashing new password with bcrypt") // Step 1: Hash the new password // Always use bcrypt for password hashing (never plaintext!) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "password_hash_failed_on_update"). + Str("user_id", id.String()). + Err(err). + Msg("failed to hash new password during update") + // Wrap error with context return fmt.Errorf("failed to hash password: %w", err) } @@ -423,13 +718,39 @@ func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, passw ` // Execute update - _, err = r.db.ExecContext( + results, err := r.db.ExecContext( ctx, query, id, // $1 - User ID string(hashedPassword), // $2 - New password hash ) - return err + if err != nil { + log.Error(). + Str("repository", "user"). + Str("action", "update_password_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update password in database") + + return err + } + + rowsAffected, _ := results.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("repository", "user"). + Str("action", "update_password_no_rows"). + Str("user_id", id.String()). + Msg("password update succeeded but no user was modified") + } else { + log.Info(). + Str("repository", "user"). + Str("action", "update_password_success"). + Str("user_id", id.String()). + Msg("password updated successfully - all sessions should be revoked") + } + + return nil } // VerifyPassword checks if a provided password matches the user's stored password hash. @@ -472,9 +793,22 @@ func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, passw // - true: Password matches (authentication successful) // - false: Password doesn't match OR hash is nil (authentication failed) func (r *UserRepository) VerifyPassword(user *models.User, providedPassword string) bool { + log.Debug(). + Str("repository", "user"). + Str("action", "verify_password_started"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Bool("has_password_hash", user.PasswordHash != nil). + Msg("verifying user password") // Safety check: Prevent panic if password_hash is nil // This shouldn't happen in normal operation, but better safe than crashed if user.PasswordHash == nil { + log.Error(). + Str("repository", "user"). + Str("action", "missing_password_hash"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("CRITICAL: user has no password hash - data corruption or migration issue") return false // No hash = can't verify = authentication fails } @@ -486,7 +820,23 @@ func (r *UserRepository) VerifyPassword(user *models.User, providedPassword stri // - Handles salt extraction and timing-safe comparison err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(providedPassword)) - // Return true if err is nil (passwords match) - // Return false if err is not nil (passwords don't match) - return err == nil + if err != nil { + log.Warn(). + Str("repository", "user"). + Str("action", "password_verification_failed"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Err(err). + Msg("password verification failed - incorrect password") + return false + } + log.Info(). + Str("repository", "user"). + Str("action", "password_verification_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Msg("password verified successfully") + return true } diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index c787239..04cd577 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -62,7 +62,10 @@ import ( func SetUpRoutes( e *echo.Echo, authHandler *handlers.AuthHandler, + userHandler *handlers.UserRegisterHander, + tenantHandler *handlers.TenantHandler, authMiddleware *middleware.AuthMiddleware, + globalRateLimiterMiddleware *middleware.RateLimiter, ) { // Create API version 1 group // All routes will be prefixed with /api/v1 @@ -95,6 +98,7 @@ func SetUpRoutes( // // Base path: /api/v1/auth auth := api.Group("/auth") + auth.POST("/register", userHandler.Register, globalRateLimiterMiddleware.Limit) // POST /api/v1/auth/login // Authenticates user credentials and issues tokens @@ -134,7 +138,7 @@ func SetUpRoutes( // - Generic error messages (prevents email enumeration) // - HttpOnly cookies (XSS protection) // - Session tracking (device, IP, user agent) - auth.POST("/login", authHandler.Login) + auth.POST("/login", authHandler.Login, globalRateLimiterMiddleware.Limit) // POST /api/v1/auth/refresh // Rotates refresh token and issues new access token @@ -180,7 +184,7 @@ func SetUpRoutes( // // IMPORTANT: Client must store the new refresh token and discard the old one! // The old refresh token is immediately invalidated after successful rotation. - auth.POST("/refresh", authHandler.RefreshTokenWithRotation) + auth.POST("/refresh", authHandler.RefreshTokenWithRotation, globalRateLimiterMiddleware.Limit) // POST /api/v1/auth/logout // Revokes refresh token and clears authentication cookies @@ -216,7 +220,7 @@ func SetUpRoutes( // 1. Access tokens are short-lived // 2. Checking database on every request would be slow // 3. User is effectively logged out (can't get new access tokens) - auth.POST("/logout", authHandler.Logout) + auth.POST("/logout", authHandler.Logout, globalRateLimiterMiddleware.Limit) // ============================================================================ // PROTECTED ROUTES (Authentication Required) @@ -309,7 +313,7 @@ func SetUpRoutes( // // // User management routes (protected) // users := protected.Group("/users") - // users.GET("", userHandler.List) // GET /api/v1/users + // users.GET("", userHandler.List) // GET /api/v1/users // users.GET("/:id", userHandler.GetByID) // GET /api/v1/users/:id // users.PUT("/:id", userHandler.Update) // PUT /api/v1/users/:id // users.DELETE("/:id", userHandler.Delete) // DELETE /api/v1/users/:id @@ -335,4 +339,8 @@ func SetUpRoutes( // webhooks := api.Group("/webhooks") // webhooks.Use(webhookMiddleware.ValidateSignature) // webhooks.POST("/github", webhookHandler.HandleGitHub) + + tenants := protected.Group("/tenants") + tenants.GET("/mine", tenantHandler.GetMyTenant) + tenants.GET("/:id", tenantHandler.GetTenant) } diff --git a/backend/internal/services/auth_services.go b/backend/internal/services/auth_services.go index 69986cd..245766c 100644 --- a/backend/internal/services/auth_services.go +++ b/backend/internal/services/auth_services.go @@ -14,6 +14,7 @@ import ( "github.com/creativenoz/aurganize-v62/backend/pkg/auth" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/rs/zerolog/log" ) // Predefined errors for authentication operations. @@ -96,6 +97,14 @@ type AuthService struct { // Returns: // - Fully initialized AuthService func NewAuthService(config *config.Config, sessionRepo *repositories.SessionRepository, userRepo *repositories.UserRepository) *AuthService { + log.Info(). + Str("service", "auth"). + Str("component", "service_init"). + Dur("access_expiry", config.JWT.AccessExpiry). + Dur("refresh_expiry", config.JWT.RefreshExpiry). + Bool("has_session_repo", sessionRepo != nil). + Bool("has_user_repo", userRepo != nil). + Msg("auth service initialized with JWT configuration") return &AuthService{ config: config, sessionRepo: sessionRepo, @@ -149,6 +158,15 @@ func NewAuthService(config *config.Config, sessionRepo *repositories.SessionRepo // - (string, nil): Successfully generated token // - ("", error): Token generation failed (configuration error) func (a *AuthService) GenerateAccessToken(user *models.User) (string, error) { + log.Debug(). + Str("service", "auth"). + Str("action", "generate_access_token_started"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("token_type", "access"). + Dur("expiry_duration", a.config.JWT.AccessExpiry). + Msg("generating access token") // Get current time for timestamps now := time.Now() // Calculate expiration time (now + configured expiry duration) @@ -179,14 +197,34 @@ func (a *AuthService) GenerateAccessToken(user *models.User) (string, error) { // - Second param: Claims to include in token // Returns unsigned token object token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signedToken, err := token.SignedString([]byte(a.config.JWT.AccessSecret)) + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "generate_access_token_failed"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Err(err). + Msg("CRITICAL: failed to sign access token - authentication broken") + return "", nil + } + log.Debug(). + Str("service", "auth"). + Str("action", "generate_access_token_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Time("expires_at", expiresAt). + Int("token_length", len(signedToken)). + Msg("access token generated successfully") // Sign token with secret key to create final JWT string // SignedString: // - Takes secret key as []byte // - Creates signature using HMAC SHA-256 // - Returns complete JWT string: "header.payload.signature" // - Only holder of secret can create valid signatures - return token.SignedString([]byte(a.config.JWT.AccessSecret)) + return signedToken, nil } // GenerateRefreshToken creates a new refresh token and session record. @@ -243,6 +281,35 @@ func (a *AuthService) GenerateAccessToken(user *models.User) (string, error) { // - ("", nil, error): Failed to create session in database // - ("", nil, error): Failed to sign JWT func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.User, userAgent *string, ipAddress *string) (string, *models.Session, error) { + + var ipStr, uaStr, deviceType string + if ipAddress != nil { + ipStr = *ipAddress + } + if userAgent != nil { + uaStr = *userAgent + } + + deviceType = detectDeviceType(userAgent) + + log.Info(). + Str("service", "auth"). + Str("action", "generate_refresh_token_started"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("device_type", deviceType). + Str("ip", ipStr). + Str("user_agent", uaStr). + Dur("expiry_duration", a.config.JWT.RefreshExpiry). + Msg("generating refresh token and creating session") + + log.Debug(). + Str("service", "auth"). + Str("action", "generating_random_token_id"). + Int("token_bytes", 32). + Msg("generating cryptographically secure random token id") + // Step 1: Generate cryptographically secure random token ID // Create 32-byte buffer for random data tokenBytes := make([]byte, 32) @@ -251,6 +318,12 @@ func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.Use // crypto/rand.Read uses OS-provided randomness (very secure) // This is NOT like math/rand (which is predictable) if _, err := rand.Read(tokenBytes); err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "random_token_generation_failed"). + Str("user_id", user.ID.String()). + Err(err). + Msg("CRITICAL: failed to generate random token bytes - crypto RNG issue") return "", nil, err // Failed to generate random data (very rare) } @@ -264,6 +337,13 @@ func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.Use now := time.Now() expiresAt := now.Add(a.config.JWT.RefreshExpiry) // Usually 7 days + log.Debug(). + Str("service", "auth"). + Str("action", "creating_session_record"). + Str("user_id", user.ID.String()). + Str("device_type", deviceType). + Msg("creating session record in database") + // Step 3: Create session record in database // This stores: // - Hashed token (not plaintext for security) @@ -280,6 +360,13 @@ func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.Use }) if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "session_creation_failed"). + Str("user_id", user.ID.String()). + Str("device_type", deviceType). + Err(err). + Msg("failed to create session in database - login will fail") return "", nil, err // Database error } @@ -305,8 +392,27 @@ func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.Use token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signedToken, err := token.SignedString([]byte(a.config.JWT.RefreshSecret)) if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "refresh_token_signing_failed"). + Str("user_id", user.ID.String()). + Str("session_id", session.ID.String()). + Err(err). + Msg("CRITICAL: failed to sign refresh token JWT - session created but unusable") return "", nil, err // Failed to sign token } + log.Info(). + Str("service", "auth"). + Str("action", "generate_refresh_token_success"). + Str("user_id", user.ID.String()). + Str("tenant_id", user.TenantID.String()). + Str("email", user.Email). + Str("session_id", session.ID.String()). + Str("device_type", deviceType). + Str("ip", ipStr). + Time("expires_at", expiresAt). + Int("token_length", len(signedToken)). + Msg("refresh token generated successfully - user logged in") // Return signed JWT and session object return signedToken, session, err @@ -361,6 +467,11 @@ func (a *AuthService) GenerateRefreshToken(ctx context.Context, user *models.Use // - (nil, ErrExpiredToken): Token expired (client should refresh) // - (nil, ErrInvalidToken): Token invalid (malformed, wrong signature, wrong type) func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessTokenClaims, error) { + log.Debug(). + Str("service", "auth"). + Str("action", "validate_access_token_started"). + Int("token_length", len(tokenString)). + Msg("validating access token") // Parse and validate JWT token // ParseWithClaims: // - Parses JWT string @@ -376,6 +487,11 @@ func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessToken // Prevents "none" algorithm attack where attacker removes signature // Prevents algorithm confusion attacks (using public key as symmetric key) if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + log.Warn(). + Str("service", "auth"). + Str("action", "invalid_signing_algorithm"). + Str("algorithm", token.Method.Alg()). + Msg("token validation failed - invalid signing algorithm (possible attack)") return nil, ErrInvalidToken } // Return secret key for signature verification @@ -390,8 +506,18 @@ func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessToken if err != nil { // Check if error is specifically about expiration if errors.Is(err, jwt.ErrTokenExpired) { + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_expired"). + Msg("access token validation failed - token expired") return nil, ErrExpiredToken // Return specific expiration error } + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_validation_failed"). + Err(err). + Msg("access token validation failed - invalid token") + // Other errors: invalid signature, malformed JWT, etc. return nil, ErrInvalidToken } @@ -400,15 +526,33 @@ func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessToken // Type assertion: Convert interface{} to *AccessTokenClaims claims, ok := token.Claims.(*auth.AccessTokenClaims) if !ok || !token.Valid { + log.Warn(). + Str("service", "auth"). + Str("action", "access_token_claims_invalid"). + Msg("access token validation failed - claims invalid or token not valid") // Claims wrong type or token invalid return nil, ErrInvalidToken } // Verify token type (prevent refresh token being used as access token) if claims.TokenType != "access" { + log.Warn(). + Str("service", "auth"). + Str("action", "wrong_token_type_for_access"). + Str("provided_type", claims.TokenType). + Str("expected_type", "access"). + Msg("token validation failed - refresh token used as access token") return nil, ErrInvalidTokenType } - + tokenAge := time.Since(claims.IssuedAt.Time) + log.Debug(). + Str("service", "auth"). + Str("action", "validate_access_token_success"). + Str("user_id", claims.UserID.String()). + Str("tenant_id", claims.TenantID.String()). + Str("role", claims.Role). + Dur("token_age", tokenAge). + Msg("access token validated successfully") // Token is valid, return claims for use in authorization return claims, nil } @@ -463,6 +607,12 @@ func (a *AuthService) ValidateAccessToken(tokenString string) (*auth.AccessToken // - (nil, nil, ErrInvalidToken): Token invalid or session not found // - (nil, nil, ErrRevokedToken): Session has been revoked func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) { + log.Info(). + Str("service", "auth"). + Str("action", "validate_refresh_token_started"). + Int("token_length", len(tokenString)). + Msg("validating refresh token") + // Step 1: Parse and validate JWT token, err := jwt.ParseWithClaims( tokenString, @@ -470,6 +620,11 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri func(token *jwt.Token) (interface{}, error) { // Verify algorithm is HMAC (security check) if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + log.Warn(). + Str("service", "auth"). + Str("action", "invalid_signing_algorithm_refresh"). + Str("algorithm", token.Method.Alg()). + Msg("refresh token validation failed - invalid signing algorithm") return nil, ErrInvalidToken } // Return REFRESH secret (different from access secret!) @@ -482,21 +637,45 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_expired"). + Msg("refresh token validation failed - token expired, user must re-login") return nil, nil, ErrExpiredToken } + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_validation_failed"). + Err(err). + Msg("refresh token validation failed - invalid token") return nil, nil, ErrInvalidToken } // Step 2: Extract and validate claims claims, ok := token.Claims.(*auth.RefreshTokenClaims) if !ok || !token.Valid { + log.Warn(). + Str("service", "auth"). + Str("action", "refresh_token_claims_invalid"). + Msg("refresh token validation failed - claims invalid") return nil, nil, ErrInvalidToken } // Step 3: Verify token type if claims.TokenType != "refresh" { + log.Warn(). + Str("service", "auth"). + Str("action", "wrong_token_type_for_refresh"). + Str("provided_type", claims.TokenType). + Msg("token validation failed - access token used as refresh token") return nil, nil, ErrInvalidToken } + log.Debug(). + Str("service", "auth"). + Str("action", "looking_up_session"). + Str("session_id", claims.SessionID.String()). + Str("user_id", claims.UserID.String()). + Msg("looking up session in database for refresh token") // Step 4: Look up session in database // This checks: @@ -506,11 +685,23 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri // - Session not expired session, err := a.sessionRepo.FindBySessionIDAndToken(ctx, claims.SessionID, claims.TokenID) if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "session_lookup_error"). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("database error during session lookup") return nil, nil, err // Database error } // Step 5: Verify session was found if session == nil { + log.Warn(). + Str("service", "auth"). + Str("action", "session_not_found"). + Str("session_id", claims.SessionID.String()). + Str("user_id", claims.UserID.String()). + Msg("session not found - may be revoked, expired, or invalid") // Session doesn't exist or is invalid // Could mean: wrong token, session revoked, session expired return nil, nil, ErrInvalidToken @@ -518,12 +709,30 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri // Step 6: Verify session not revoked (redundant but explicit) if session.IsRevoked { + log.Warn(). + Str("service", "auth"). + Str("action", "session_revoked"). + Str("session_id", session.ID.String()). + Str("user_id", session.UserID.String()). + Str("revoked_reason", func() string { + if session.RevokedReason != nil { + return *session.RevokedReason + } + return "unknown" + }()). + Msg("session is revoked - refresh token invalid") // Session was explicitly revoked (logout, password change, etc.) return nil, nil, ErrRevokedToken } // Step 7: Verify session not expired (redundant but explicit) if session.ExpiresAt.Before(time.Now()) { + log.Warn(). + Str("service", "auth"). + Str("action", "session_expired"). + Str("session_id", session.ID.String()). + Time("expired_at", session.ExpiresAt). + Msg("session has expired") // Session expired (different from token expiration) return nil, nil, ErrRevokedToken } @@ -533,6 +742,13 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri // Useful for security monitoring and cleanup // We ignore error (not critical for validation) _ = a.sessionRepo.UpdateLastUsed(ctx, session.ID) + log.Info(). + Str("service", "auth"). + Str("action", "validate_refresh_token_success"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Time("session_last_used", session.LastUsedAt). + Msg("refresh token validated successfully") // All checks passed, return claims and session return claims, session, nil @@ -555,19 +771,40 @@ func (a *AuthService) ValidateRefreshToken(ctx context.Context, tokenString stri // rotated it, the system can detect this suspicious activity and revoke all // sessions for that user as a security precaution. func (a *AuthService) RotateRefreshToken(ctx context.Context, oldTokenString string, userAgent *string, ipAddress *string) (string, string, *models.Session, error) { + log.Info(). + Str("service", "auth"). + Str("action", "rotate_refresh_token_started"). + Msg("starting refresh token rotation") // Step 1: Validate the old refresh token claims, _, err := a.ValidateRefreshToken(ctx, oldTokenString) if err != nil { + log.Warn(). + Str("service", "auth"). + Str("action", "rotation_validation_failed"). + Err(err). + Msg("token rotation failed - old token invalid") return "", "", nil, err } // Step 2: Get user details user, err := a.userRepo.FindByID(ctx, claims.UserID) if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "rotation_user_not_found"). + Str("user_id", claims.UserID.String()). + Err(err). + Msg("token rotation failed - database error") return "", "", nil, err } if user == nil { + log.Error(). + Str("service", "auth"). + Str("action", "rotation_user_not_found"). + Str("user_id", claims.UserID.String()). + Err(err). + Msg("token rotation failed - user not found") return "", "", nil, ErrInvalidToken } @@ -583,11 +820,26 @@ func (a *AuthService) RotateRefreshToken(ctx context.Context, oldTokenString str return "", "", nil, err } + log.Info(). + Str("service", "auth"). + Str("action", "revoking_old_session_after_rotation"). + Str("old_session_id", claims.SessionID.String()). + Str("new_session_id", newSession.ID.String()). + Str("user_id", user.ID.String()). + Msg("revoking old session after successful token rotation") + // Step 5: Revoke the old session (invalidates old refresh token) // Use background context to ensure revocation completes even if request is cancelled go func() { _ = a.sessionRepo.Revoke(context.Background(), claims.TokenID, "token_rotation") }() + log.Info(). + Str("service", "auth"). + Str("action", "rotate_refresh_token_success"). + Str("user_id", user.ID.String()). + Str("old_session_id", claims.SessionID.String()). + Str("new_session_id", newSession.ID.String()). + Msg("refresh token rotated successfully") return newAccessToken, newRefreshToken, newSession, nil @@ -605,6 +857,10 @@ func (a *AuthService) RotateRefreshToken(ctx context.Context, oldTokenString str // // This is an optional enhancement for high-security requirements. func (a *AuthService) ValidateRefreshTokenWithRotationCheck(ctx context.Context, tokenString string) (*auth.RefreshTokenClaims, *models.Session, error) { + log.Info(). + Str("service", "auth"). + Str("action", "validate_with_theft_detection_started"). + Msg("validating refresh token with rotation theft detection") // Parse JWT to get claims token, err := jwt.ParseWithClaims( tokenString, @@ -650,6 +906,18 @@ func (a *AuthService) ValidateRefreshTokenWithRotationCheck(ctx context.Context, if session.IsRevoked { // Check if token was revoked due to rotation if session.RevokedReason != nil && *session.RevokedReason == "token_rotation" { + log.Error(). + Str("service", "auth"). + Str("action", "token_theft_detected"). + Str("user_id", session.UserID.String()). + Str("session_id", session.ID.String()). + Str("ip", func() string { + if session.IPAddress != nil { + return *session.IPAddress + } + return "unknown" + }()). + Msg("SECURITY ALERT: Revoked token reused after rotation - possible token theft! Revoking all user sessions") // SECURITY EVENT: Possible token theft detected // Revoke ALL sessions for this user as a precaution go func() { @@ -712,6 +980,11 @@ func (a *AuthService) ValidateRefreshTokenWithRotationCheck(ctx context.Context, // - nil: Successfully revoked (or already revoked) // - error: Failed to parse token or update database func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) error { + log.Info(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_started"). + Msg("revoking refresh token (logout)") + // Step 1: Parse JWT to extract claims // We need the token ID to find the session // We still use ParseWithClaims even though we're revoking @@ -733,6 +1006,11 @@ func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) e jwt.WithTimeFunc(time.Now), ) if err != nil { + log.Warn(). + Str("service", "auth"). + Str("action", "revoke_token_parse_failed"). + Err(err). + Msg("failed to parse token for revocation") // Could be expired token (that's okay for revocation) // But we still return error to indicate parsing failure return err @@ -743,12 +1021,27 @@ func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) e if !ok { return ErrInvalidToken } - + err = a.sessionRepo.Revoke(ctx, claims.TokenID, "user_logout") + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_failed"). + Str("session_id", claims.SessionID.String()). + Err(err). + Msg("failed to revoke refresh token") + return err + } + log.Info(). + Str("service", "auth"). + Str("action", "revoke_refresh_token_success"). + Str("user_id", claims.UserID.String()). + Str("session_id", claims.SessionID.String()). + Msg("refresh token revoked successfully - user logged out") // Step 3: Revoke the session in database // Uses token ID to find session // Marks as revoked with reason "user_logout" // Operation is idempotent (safe to call multiple times) - return a.sessionRepo.Revoke(ctx, claims.TokenID, "user_logout") + return nil } // RevokeAllUserToken revokes all refresh tokens for a user (logout all devices). @@ -796,10 +1089,31 @@ func (a *AuthService) RevokeRefreshToken(ctx context.Context, tokenJWT string) e // - nil: All tokens successfully revoked // - error: Database error occurred func (a *AuthService) RevokeAllUserToken(ctx context.Context, userId uuid.UUID) error { + log.Info(). + Str("service", "auth"). + Str("action", "revoke_all_user_tokens_started"). + Str("user_id", userId.String()). + Msg("revoking all refresh tokens for user (logout all devices)") + err := a.sessionRepo.RevokeByUserId(ctx, userId, "revoke_all") + if err != nil { + log.Error(). + Str("service", "auth"). + Str("action", "revoke_all_tokens_failed"). + Str("user_id", userId.String()). + Err(err). + Msg("CRITICAL: failed to revoke all user tokens") + return err + } + log.Info(). + Str("service", "auth"). + Str("action", "revoke_all_user_tokens_success"). + Str("user_id", userId.String()). + Msg("all user tokens revoked successfully - logged out from all devices") + // Revoke all sessions for user // Reason "revoke_all" indicates this was bulk revocation // Repository handles finding and updating all sessions - return a.sessionRepo.RevokeByUserId(ctx, userId, "revoke_all") + return nil } // detectDeviceType attempts to determine device type from user agent string. diff --git a/backend/internal/services/tenant_service.go b/backend/internal/services/tenant_service.go new file mode 100644 index 0000000..064e6e7 --- /dev/null +++ b/backend/internal/services/tenant_service.go @@ -0,0 +1,405 @@ +package services + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/creativenoz/aurganize-v62/backend/internal/config" + "github.com/creativenoz/aurganize-v62/backend/internal/models" + "github.com/creativenoz/aurganize-v62/backend/internal/repositories" + "github.com/creativenoz/aurganize-v62/backend/pkg/slug" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "github.com/rs/zerolog/log" +) + +var ( + ErrTenantNotFound = errors.New("tenant not found") + ErrSlugExists = errors.New("tenant slug already exists") +) + +type TenantService struct { + config *config.Config + tenantRepo *repositories.TenantRepository + userRepo *repositories.UserRepository + db *sqlx.DB +} + +func NewTenantService( + config *config.Config, + tenantRepo *repositories.TenantRepository, + userRepo *repositories.UserRepository, + db *sqlx.DB, +) *TenantService { + log.Info(). + Str("service", "tenant"). + Str("component", "service_init"). + Bool("has_tenant_repo", tenantRepo != nil). + Bool("has_user_repo", userRepo != nil). + Bool("has_db", db != nil). + Msg("tenant service initialized") + return &TenantService{ + config: config, + tenantRepo: tenantRepo, + userRepo: userRepo, + db: db, + } +} + +func (ts *TenantService) Create(ctx context.Context, input *models.CreateTenantInput) (*models.Tenant, error) { + const maxRetries = 5 + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_started"). + Str("tenant_name", input.Name). + Str("email", *input.Email). + Int("max_retries", maxRetries). + Msg("creating tenant (standalone, not with user)") + if input.Name == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "create_tenant_validation_failed"). + Str("validation_error", "empty_name"). + Msg("tenant creation failed - tenant name is required") + return nil, fmt.Errorf("tenant name is required") + } + if *input.Email == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "create_tenant_validation_failed"). + Str("validation_error", "empty_email"). + Msg("tenant creation failed - tenant email is required") + return nil, fmt.Errorf("tenant email is required") + } + + for attempt := 0; attempt < maxRetries; attempt++ { + baseSlug := slug.Generate(input.Name) + log.Debug(). + Str("service", "tenant"). + Str("action", "generating_unique_slug"). + Int("attempt", attempt+1). + Int("max_retries", maxRetries). + Str("tenant_name", input.Name). + Str("base_slug", baseSlug). + Msg("attempting to generate unique slug for tenant") + uniqueSlug := slug.GenerateUnique(baseSlug, func(candidate string) bool { + exits, err := ts.tenantRepo.SlugExists(ctx, candidate) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "slug_exists_check_error"). + Str("candidate_slug", candidate). + Err(err). + Msg("database error while checking slug existence") + return true + } + return exits + }) + + log.Debug(). + Str("service", "tenant"). + Str("action", "unique_slug_generated"). + Str("tenant_name", input.Name). + Str("unique_slug", uniqueSlug). + Int("attempt", attempt+1). + Msg("unique slug generated for tenant") + + tenant, err := ts.tenantRepo.Create(ctx, input, uniqueSlug) + + if err == nil { + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Int("attempts_needed", attempt+1). + Msg("tenant created successfully") + return tenant, nil + } + + if isUniqueViolationError(err, "tenant_slug_key") { + log.Warn(). + Str("service", "tenant"). + Str("action", "slug_collision_detected"). + Str("tenant_name", input.Name). + Str("attempted_slug", uniqueSlug). + Int("attempt", attempt+1). + Int("remaining_attempts", maxRetries-attempt-1). + Msg("slug collision detected, retrying with different slug") + continue + } + log.Error(). + Str("service", "tenant"). + Str("action", "create_tenant_database_error"). + Str("tenant_name", input.Name). + Str("slug", uniqueSlug). + Err(err). + Msg("tenant creation failed with database error") + + return nil, fmt.Errorf("failed to create tenant : %w", err) + } + + log.Error(). + Str("service", "tenant"). + Str("action", "create_tenant_max_retries_exceeded"). + Str("tenant_name", input.Name). + Int("max_retries", maxRetries). + Msg("failed to create tenant - exhausted all slug generation attempts") + return nil, fmt.Errorf("failed to create unique slug after %d attempts", maxRetries) +} + +func (ts *TenantService) CreateWithUser(ctx context.Context, input *models.CreateTenantWithUserInput) (*models.Tenant, *models.User, error) { + + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_with_user_started"). + Str("tenant_name", input.TenantName). + Str("email", *input.Email). + Bool("has_first_name", input.FirstName != nil). + Bool("has_last_name", input.LastName != nil). + Msg("starting tenant and user creation transaction (registration)") + + tx, err := ts.db.BeginTxx(ctx, nil) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "transaction_begin_failed"). + Err(err). + Msg("CRITICAL: failed to begin database transaction - registration broken") + return nil, nil, fmt.Errorf("failed to begin transaction : %w", err) + } + + defer func() { + if err != nil { + log.Info(). + Str("service", "tenant"). + Str("action", "transaction_rollback"). + Str("tenant_name", input.TenantName). + Msg("rolling back transaction due to error") + tx.Rollback() + } + }() + + if input.TenantName == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_tenant_name"). + Msg("registration failed - tenant name is required") + return nil, nil, fmt.Errorf("tenant name is required") + } + if *input.Email == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_email"). + Msg("registration failed - email is required") + return nil, nil, fmt.Errorf("tenant email is required") + } + if *input.Password == "" { + log.Warn(). + Str("service", "tenant"). + Str("action", "registration_validation_failed"). + Str("validation_error", "empty_password"). + Msg("registration failed - password is required") + return nil, nil, fmt.Errorf("password is required") + } + log.Debug(). + Str("service", "tenant"). + Str("action", "generating_registration_slug"). + Str("tenant_name", input.TenantName). + Msg("generating unique slug for registration") + + baseSlug := slug.Generate(input.TenantName) + uniqueSlug := slug.GenerateUnique(baseSlug, func(candidate string) bool { + exists, err := ts.tenantRepo.SlugExists(ctx, candidate) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "registration_slug_check_error"). + Str("candidate_slug", candidate). + Err(err). + Msg("error checking slug existence during registration") + return true + } + return exists + }) + + log.Debug(). + Str("service", "tenant"). + Str("action", "registration_slug_generated"). + Str("tenant_name", input.TenantName). + Str("unique_slug", uniqueSlug). + Msg("unique slug generated for registration") + + tenantInput := &models.CreateTenantInput{ + Name: input.TenantName, + Email: input.Email, + Timezone: "UTC", + Currency: "INR", + Locale: "en-US", + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "creating_tenant_in_transaction"). + Str("tenant_name", input.TenantName). + Str("slug", uniqueSlug). + Msg("creating tenant record in transaction") + + tenant, err := ts.tenantRepo.CreateTx(ctx, tx, tenantInput, uniqueSlug) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "tenant_creation_in_transaction_failed"). + Str("tenant_name", input.TenantName). + Str("slug", uniqueSlug). + Err(err). + Msg("failed to create tenant in transaction - registration will fail") + return nil, nil, fmt.Errorf("failed to create tenant: %w", err) + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "creating_user_in_transaction"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("email", *input.Email). + Msg("tenant created, now creating user in same transaction") + + userInput := &models.CreateUserInput{ + TenantID: tenant.ID, + Email: *input.Email, + Password: *input.Password, + FirstName: input.FirstName, + LastName: input.LastName, + Role: "admin", + Status: "active", + } + + user, err := ts.userRepo.CreateTx(ctx, tx, userInput) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "user_creation_in_transaction_failed"). + Str("tenant_id", tenant.ID.String()). + Str("email", *input.Email). + Err(err). + Msg("failed to create user in transaction - entire registration will rollback") + return nil, nil, fmt.Errorf("failed to create user: %w", err) + } + + log.Debug(). + Str("service", "tenant"). + Str("action", "committing_registration_transaction"). + Str("tenant_id", tenant.ID.String()). + Str("user_id", user.ID.String()). + Msg("committing registration transaction") + + if err = tx.Commit(); err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "transaction_commit_failed"). + Str("tenant_id", tenant.ID.String()). + Str("user_id", user.ID.String()). + Err(err). + Msg("CRITICAL: failed to commit registration transaction - data may be lost") + return nil, nil, fmt.Errorf("failed to commit transaction: %w", err) + } + log.Info(). + Str("service", "tenant"). + Str("action", "create_tenant_with_user_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Str("slug", tenant.Slug). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("role", user.Role). + Str("timezone", tenant.Timezone). + Str("currency", tenant.Currency). + Str("locale", tenant.Locale). + Msg("registration completed successfully - new tenant and user created") + return tenant, user, nil +} + +func (ts *TenantService) GetByID(ctx context.Context, id uuid.UUID) (*models.Tenant, error) { + log.Debug(). + Str("service", "tenant"). + Str("action", "get_tenant_by_id"). + Str("tenant_id", id.String()). + Msg("retrieving tenant by id") + + tenant, err := ts.tenantRepo.FindByID(ctx, id) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "get_tenant_error"). + Str("tenant_id", id.String()). + Err(err). + Msg("database error while retrieving tenant") + return nil, fmt.Errorf("database error : %w", err) + } + + if tenant == nil { + log.Warn(). + Str("service", "tenant"). + Str("action", "tenant_not_found"). + Str("tenant_id", id.String()). + Msg("tenant not found by id") + return nil, ErrTenantNotFound + } + log.Debug(). + Str("service", "tenant"). + Str("action", "get_tenant_success"). + Str("tenant_id", tenant.ID.String()). + Str("tenant_name", tenant.Name). + Msg("tenant retrieved successfully") + return tenant, nil +} + +func (ts *TenantService) SlugExists(ctx context.Context, name string) (bool, error) { + log.Debug(). + Str("service", "tenant"). + Str("action", "check_slug_exists"). + Str("name", name). + Msg("checking if tenant name/slug is available") + slug := slug.Generate(name) + exists, err := ts.tenantRepo.SlugExists(ctx, slug) + if err != nil { + log.Error(). + Str("service", "tenant"). + Str("action", "slug_exists_check_error"). + Str("name", name). + Str("slug", slug). + Err(err). + Msg("error checking slug existence") + } + if exists { + log.Info(). + Str("service", "tenant"). + Str("action", "slug_unavailable"). + Str("name", name). + Str("slug", slug). + Msg("tenant name/slug is already taken") + } else { + log.Debug(). + Str("service", "tenant"). + Str("action", "slug_available"). + Str("name", name). + Str("slug", slug). + Msg("tenant name/slug is available") + } + return exists, nil +} + +func isUniqueViolationError(err error, constraintName string) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code == "23505" && + (constraintName == "" || strings.Contains(pqErr.Constraint, constraintName)) + } + return false +} diff --git a/backend/internal/services/user_service.go b/backend/internal/services/user_service.go index e069836..d21301d 100644 --- a/backend/internal/services/user_service.go +++ b/backend/internal/services/user_service.go @@ -9,6 +9,7 @@ import ( "github.com/creativenoz/aurganize-v62/backend/internal/models" "github.com/creativenoz/aurganize-v62/backend/internal/repositories" "github.com/google/uuid" + "github.com/rs/zerolog/log" ) // Predefined errors for user operations. @@ -94,6 +95,11 @@ type UserService struct { // Returns: // - Fully initialized UserService func NewUserService(userRepo *repositories.UserRepository) *UserService { + log.Info(). + Str("service", "user"). + Str("component", "service_init"). + Bool("has_user_repo", userRepo != nil). + Msg("user service initialized") return &UserService{userRepo: userRepo} } @@ -145,12 +151,49 @@ func NewUserService(userRepo *repositories.UserRepository) *UserService { // - (nil, ErrWeakPassword): Password too weak // - (nil, error): Database error or other failure func (u *UserService) Register(ctx context.Context, userInput *models.CreateUserInput) (*models.User, error) { + log.Info(). + Str("service", "user"). + Str("action", "register_user_started"). + Str("tenant_id", userInput.TenantID.String()). + Str("email", userInput.Email). + Str("role", userInput.Role). + Bool("has_first_name", userInput.FirstName != nil). + Bool("has_last_name", userInput.LastName != nil). + Msg("user registration started") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_registration_input"). + Str("email", userInput.Email). + Msg("validating user registration input") + // Step 1: Validate registration input // This checks: // - Email format is valid // - Email not already registered // - Password meets strength requirements if err := u.ValidateRegistrationInput(ctx, userInput); err != nil { + var validationError string + switch { + case errors.Is(err, ErrInvalidEmail): + validationError = "invalid_email_format" + case errors.Is(err, ErrEmailAlreadyExists): + validationError = "email_already_taken" + case errors.Is(err, ErrWeakPassword): + validationError = "weak_password" + default: + validationError = "unknown_validation_error" + } + + log.Warn(). + Str("service", "user"). + Str("action", "register_validation_failed"). + Str("validation_error", validationError). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Err(err). + Msg("user registration validation failed") + return nil, err // Return specific validation error } @@ -158,8 +201,23 @@ func (u *UserService) Register(ctx context.Context, userInput *models.CreateUser // - TrimSpace: Remove leading/trailing whitespace // - ToLower: Convert to lowercase for case-insensitive matching // Why: "User@Example.COM " becomes "user@example.com" + originalEmail := userInput.Email userInput.Email = strings.ToLower(strings.TrimSpace(userInput.Email)) + if originalEmail != userInput.Email { + log.Debug(). + Str("service", "user"). + Str("action", "email_normalized"). + Str("original_email", originalEmail). + Str("normalized_email", userInput.Email). + Msg("email normalized for consistent storage") + } + log.Debug(). + Str("service", "user"). + Str("action", "creating_user_in_repository"). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Msg("creating user in database") // Step 3: Create user in database // Repository handles: // - Password hashing (bcrypt) @@ -167,13 +225,28 @@ func (u *UserService) Register(ctx context.Context, userInput *models.CreateUser // - Generating user ID and timestamps user, err := u.userRepo.Create(ctx, userInput) if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "register_user_failed"). + Str("email", userInput.Email). + Str("tenant_id", userInput.TenantID.String()). + Err(err). + Msg("failed to create user in database") // Wrap error with context for better debugging return nil, fmt.Errorf("failed to create user : %w", err) } - + log.Info(). + Str("service", "user"). + Str("action", "register_user_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("full_name", user.FullName). + Msg("user registered successfully") // Step 4: Return created user // User object includes generated ID, timestamps, etc. - return user, err + return user, nil } // AuthenticateUserByEmail verifies user credentials (email + password). @@ -225,13 +298,37 @@ func (u *UserService) Register(ctx context.Context, userInput *models.CreateUser // - (nil, ErrInvalidCredentials): Wrong email or password // - (nil, error): Database error (wrapped) func (u *UserService) AuthenticateUserByEmail(ctx context.Context, email string, password string) (*models.User, error) { + log.Info(). + Str("service", "user"). + Str("action", "authenticate_user_started"). + Str("email", email). + Msg("user authentication attempt") // Step 1: Normalize email // Must match normalization done during registration + originalEmail := email email = strings.ToLower(strings.TrimSpace(email)) - + if email != originalEmail { + log.Debug(). + Str("service", "user"). + Str("action", "auth_email_normalized"). + Str("original_email", originalEmail). + Str("normalized_email", email). + Msg("email normalized for authentication") + } + log.Debug(). + Str("service", "user"). + Str("action", "looking_up_user_for_auth"). + Str("email", email). + Msg("looking up user by email for authentication") // Step 2: Look up user by email user, err := u.userRepo.FindByEmail(ctx, email) if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "authenticate_db_error"). + Str("email", email). + Err(err). + Msg("database error during authentication") // Wrap error with context // This is a database error, not "user not found" return nil, fmt.Errorf("repository error : %w", err) @@ -239,20 +336,45 @@ func (u *UserService) AuthenticateUserByEmail(ctx context.Context, email string, // Step 3: Check if user exists if user == nil { + log.Warn(). + Str("service", "user"). + Str("action", "authenticate_failed_user_not_found"). + Str("email", email). + Msg("authentication failed - user not found (returning generic error)") // Email not found in database // Return generic error (don't reveal email doesn't exist) return nil, ErrInvalidCredentials } - + log.Debug(). + Str("service", "user"). + Str("action", "verifying_password"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user found, verifying password") // Step 4: Verify password // Repository method uses bcrypt to compare // Returns false if password doesn't match if !u.userRepo.VerifyPassword(user, password) { + log.Warn(). + Str("service", "user"). + Str("action", "authenticate_failed_wrong_password"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Msg("authentication failed - incorrect password (returning generic error)") // Password incorrect // Return generic error (don't reveal password was wrong) return nil, ErrInvalidCredentials } - + log.Info(). + Str("service", "user"). + Str("action", "authenticate_user_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Str("tenant_id", user.TenantID.String()). + Str("role", user.Role). + Str("status", user.Status). + Msg("user authenticated successfully") // Step 5: Authentication successful // Return user object for token generation return user, nil @@ -282,19 +404,41 @@ func (u *UserService) AuthenticateUserByEmail(ctx context.Context, email string, // - (nil, ErrUserNotFound): User doesn't exist (or is deleted) // - (nil, error): Database error (wrapped) func (u *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) { + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_id"). + Str("user_id", id.String()). + Msg("retrieving user by id") + // Look up user by ID user, err := u.userRepo.FindByID(ctx, id) if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "get_user_by_id_error"). + Str("user_id", id.String()). + Err(err). + Msg("database error while retrieving user") // Database error return nil, fmt.Errorf("repository error : %w", err) } // Check if user was found if user == nil { + log.Warn(). + Str("service", "user"). + Str("action", "user_not_found_by_id"). + Str("user_id", id.String()). + Msg("user not found by id") // User doesn't exist (or is soft-deleted) return nil, ErrUserNotFound } - + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_id_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user retrieved successfully") return user, nil } @@ -319,19 +463,42 @@ func (u *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, // - (nil, ErrUserNotFound): User doesn't exist // - (nil, error): Database error (wrapped) func (u *UserService) GetByEmail(ctx context.Context, email string) (*models.User, error) { + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_email"). + Str("email", email). + Msg("retrieving user by email") // Look up user by email user, err := u.userRepo.FindByEmail(ctx, email) if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "get_user_by_email_error"). + Str("email", email). + Err(err). + Msg("database error while retrieving user") // Database error return nil, fmt.Errorf("repository error : %w", err) } // Check if user was found if user == nil { + log.Warn(). + Str("service", "user"). + Str("action", "user_not_found_by_email"). + Str("email", email). + Msg("user not found by email") // User doesn't exist return nil, ErrUserNotFound } + log.Debug(). + Str("service", "user"). + Str("action", "get_user_by_email_success"). + Str("user_id", user.ID.String()). + Str("email", user.Email). + Msg("user retrieved successfully") + return user, nil } @@ -360,8 +527,38 @@ func (u *UserService) GetByEmail(ctx context.Context, email string) (*models.Use // Returns: // - error: Database error if update fails func (u *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID, ipAddress *string) error { + + var ipStr string + if ipAddress != nil { + ipStr = *ipAddress + } + + log.Debug(). + Str("service", "user"). + Str("action", "update_last_login"). + Str("user_id", id.String()). + Str("ip", ipStr). + Msg("updating user last login timestamp") + + err := u.userRepo.UpdateLastLogin(ctx, id, ipAddress) + + if err != nil { + log.Warn(). + Str("service", "user"). + Str("action", "update_last_login_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update last login timestamp") + return err + } + + log.Debug(). + Str("service", "user"). + Str("action", "update_last_login_success"). + Str("user_id", id.String()). + Msg("last login updated successfully") // Delegate to repository - return u.userRepo.UpdateLastLogin(ctx, id, ipAddress) + return nil } // UpdatePassword changes a user's password. @@ -392,9 +589,37 @@ func (u *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID, ipAddre // Returns: // - error: Hashing or database error func (u *UserService) UpdatePassword(ctx context.Context, id uuid.UUID, newPassword string) error { + + log.Info(). + Str("service", "user"). + Str("action", "update_password_started"). + Str("user_id", id.String()). + Msg("user password update started") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_new_password"). + Str("user_id", id.String()). + Msg("validating new password strength") + + err := u.userRepo.UpdatePassword(ctx, id, newPassword) + if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "update_password_failed"). + Str("user_id", id.String()). + Err(err). + Msg("failed to update user password") + return err + } + log.Info(). + Str("service", "user"). + Str("action", "update_password_success"). + Str("user_id", id.String()). + Msg("user password updated successfully - caller should revoke all sessions") // Delegate to repository // Repository handles bcrypt hashing - return u.userRepo.UpdatePassword(ctx, id, newPassword) + return nil } // ValidateRegistrationInput validates all user registration input. @@ -433,33 +658,81 @@ func (u *UserService) UpdatePassword(ctx context.Context, id uuid.UUID, newPassw // - ErrWeakPassword: Password doesn't meet requirements // - error: Database error during uniqueness check func (u *UserService) ValidateRegistrationInput(ctx context.Context, input *models.CreateUserInput) error { + log.Debug(). + Str("service", "user"). + Str("action", "validate_registration_started"). + Str("email", input.Email). + Msg("validating registration input") + + log.Debug(). + Str("service", "user"). + Str("action", "validating_email_format"). + Str("email", input.Email). + Msg("validating email format") // Step 1: Validate email format // Checks structure, length, basic format // This is cheap (no database query) if !isValidEmail(input.Email) { + log.Warn(). + Str("service", "user"). + Str("action", "invalid_email_format"). + Str("email", input.Email). + Msg("email format validation failed") return ErrInvalidEmail } - + log.Debug(). + Str("service", "user"). + Str("action", "checking_email_uniqueness"). + Str("email", strings.ToLower(input.Email)). + Msg("checking if email already exists") // Step 2: Check email uniqueness // Queries database to see if email already exists // Lowercase email for case-insensitive check exists, err := u.userRepo.EmailExists(ctx, strings.ToLower(input.Email)) if err != nil { + log.Error(). + Str("service", "user"). + Str("action", "email_uniqueness_check_error"). + Str("email", input.Email). + Err(err). + Msg("database error while checking email uniqueness") // Database error during uniqueness check // Wrap with context for debugging return fmt.Errorf("failed to check email uniqueness : %w email id [%s]", err, input.Email) } if exists { + log.Info(). + Str("service", "user"). + Str("action", "email_already_exists"). + Str("email", input.Email). + Msg("email already registered - validation failed") // Email already registered return ErrEmailAlreadyExists } + log.Debug(). + Str("service", "user"). + Str("action", "validating_password_strength"). + Int("password_length", len(input.Password)). + Msg("validating password strength") // Step 3: Validate password strength // Checks length, complexity, common passwords // Requires password, email (to prevent email in password), and first name (to prevent name in password) if !isStrongPassword(input.Password, input.Email, *input.FirstName) { + + log.Warn(). + Str("service", "user"). + Str("action", "weak_password_rejected"). + Str("email", input.Email). + Int("password_length", len(input.Password)). + Msg("password failed strength requirements") return ErrWeakPassword } + log.Debug(). + Str("service", "user"). + Str("action", "validate_registration_success"). + Str("email", input.Email). + Msg("registration input validated successfully") // All validation passed return nil diff --git a/backend/jobs/session_cleanup.go b/backend/jobs/session_cleanup.go new file mode 100644 index 0000000..9d17fa1 --- /dev/null +++ b/backend/jobs/session_cleanup.go @@ -0,0 +1,82 @@ +package jobs + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/creativenoz/aurganize-v62/backend/internal/repositories" +) + +type SesssionCleanUpJob struct { + sessionRep *repositories.SessionRepository + interval time.Duration + stopChannel chan struct{} +} + +func NewSessionCleanUpJob(sessionRepo *repositories.SessionRepository) *SesssionCleanUpJob { + return &SesssionCleanUpJob{ + sessionRep: sessionRepo, + interval: 12 * time.Hour, + stopChannel: make(chan struct{}), + } +} + +func (j *SesssionCleanUpJob) Start(ctx context.Context) { + log.Info().Msg("session cleanup job started") + ticker := time.NewTicker(j.interval) + + defer ticker.Stop() + for { + select { + case <-ticker.C: + j.run(ctx) + case <-j.stopChannel: + log.Info().Msg("stop cleanup session command recieved") + return + case <-ctx.Done(): + log.Info().Msg("session cleanup job cancelled") + return + } + } +} + +func (j *SesssionCleanUpJob) Stop() { + close(j.stopChannel) +} + +func (j *SesssionCleanUpJob) run(ctx context.Context) { + startTime := time.Now() + deleted, err := j.sessionRep.DeleteExpired(ctx) + if err != nil { + log.Info().Msg("failed to clean up sessions") + log.Error(). + Err(err). + Dur("duration", time.Since(startTime)). + Msg("failed to clean up sessions") + return + } + log.Info(). + Int64("deleted_count", deleted). + Dur("duration", time.Since(startTime)). + Msg("session clean up completed") +} + +func (j *SesssionCleanUpJob) RunOnce(ctx context.Context) error { + startTime := time.Now() + log.Info().Msg("manually triggered session cleanup") + deleted, err := j.sessionRep.DeleteExpired(ctx) + if err != nil { + log.Error(). + Err(err). + Dur("duration", time.Since(startTime)). + Msg("Failed to cleanup sessions") + return err + } + log.Info(). + Int64("deleted_count", deleted). + Dur("duration", time.Since(startTime)). + Msg("Manual session cleanup completed") + return nil +} diff --git a/backend/pkg/slug/slug.go b/backend/pkg/slug/slug.go new file mode 100644 index 0000000..8b6412c --- /dev/null +++ b/backend/pkg/slug/slug.go @@ -0,0 +1,128 @@ +package slug + +import ( + "crypto/rand" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/rs/zerolog/log" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +func Generate(text string) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_slug_started"). + Str("input_text", text). + Msg("starting slug generation") + text = strings.ToLower(text) + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + text, _, _ = transform.String(t, text) + + text = strings.Replace(text, " ", "-", -1) + text = strings.Replace(text, "_", "-", -1) + + reg := regexp.MustCompile(`[^a-z0-9-]+`) + text = reg.ReplaceAllString(text, "") + + reg = regexp.MustCompile(`-+`) + text = reg.ReplaceAllString(text, "-") + + text = strings.Trim(text, "-") + + if len(text) > 100 { + log.Debug(). + Str("service", "slugService"). + Str("action", "slug_truncated"). + Int("original_length", len(text)). + Msg("slug exceeded 100 characters and was truncated") + text = text[:100] + text = strings.TrimRight(text, "-") + } + + if text == "" { + log.Warn(). + Str("service", "slugService"). + Str("action", "empty_slug_generated"). + Str("input_text", text). + Msg("slug is empty after sanitization; generating random fallback") + text = "tenant" + generateRandomString(8) + } + + return text + +} + +func GenerateUnique(base string, exists func(string) bool) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_unique_slug_started"). + Str("base", base). + Msg("generating unique slug") + + slug := Generate(base) + + if !exists(slug) { + log.Debug(). + Str("service", "slugService"). + Str("action", "unique_slug_available"). + Str("slug", slug). + Msg("slug is available without modification") + return slug + } + + for i := 2; i < 1000; i++ { + candidate := slug + "-" + strconv.Itoa(i) + if !exists(candidate) { + log.Info(). + Str("service", "slugService"). + Str("action", "unique_slug_generated_with_suffix"). + Str("slug", candidate). + Int("attempt", i-1). + Msg("slug collision resolved with numeric suffix") + return candidate + } + } + fallback := slug + "-" + strconv.Itoa(int(time.Now().Unix())) + log.Warn(). + Str("service", "slugService"). + Str("action", "unique_slug_fallback_timestamp"). + Str("slug", fallback). + Msg("exhausted 1000 attempts; using timestamp fallback") + + return fallback +} + +func generateRandomString(length int) string { + log.Debug(). + Str("service", "slugService"). + Str("action", "generate_random_string"). + Int("length", length). + Msg("generating random slug string") + const charset = "abcdefghijklmopqrstuvwxyz0123456789" + randBytes := make([]byte, length) + if _, err := rand.Read(randBytes); err != nil { + log.Error(). + Str("service", "slugService"). + Str("action", "rand_read_failed"). + Err(err). + Msg("falling back to time-based random string") + return strconv.FormatInt(time.Now().UnixNano(), 36)[:length] + } + result := make([]byte, length) + for i := 0; i < length; i++ { + result[i] = charset[randBytes[i]%byte(len(charset))] + } + str := string(result) + log.Debug(). + Str("service", "slugService"). + Str("action", "random_string_generated"). + Str("value", str). + Msg("random slug string generated") + return str +} diff --git a/backend/tests/scripts/auth_test_api.sh b/backend/tests/scripts/auth_test_api.sh new file mode 100644 index 0000000..93d6f07 --- /dev/null +++ b/backend/tests/scripts/auth_test_api.sh @@ -0,0 +1,652 @@ +#!/bin/bash +# ============================================================================== +# AURGANIZE V6.2 - API TESTING SCRIPT +# ============================================================================== +# Purpose: Comprehensive testing of all API endpoints +# Usage: ./test-api.sh +# Requires: curl, jq (for JSON parsing) +# ============================================================================== + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# API Base URL +BASE_URL="http://localhost:8080/api/v1" + +# Test data +TEST_EMAIL="test-$(date +%s)@example.com" # Unique email with timestamp +TEST_PASSWORD="Test123!@#" +TEST_FIRST_NAME="Test" +TEST_LAST_NAME="User" +TEST_TENANT_NAME="Test Company $(date +%s)" + +# Variables to store tokens +ACCESS_TOKEN="" +REFRESH_TOKEN="" +USER_ID="" +TENANT_ID="" + +echo "==========================================" +echo "AURGANIZE V6.2 API TESTING" +echo "==========================================" +echo "" +echo "Base URL: $BASE_URL" +echo "Test Email: $TEST_EMAIL" +echo "" + +# ============================================================================== +# Helper Functions +# ============================================================================== + +# Function to print test header +print_test() { + echo "" + echo -e "${BLUE}==========================================" + echo "TEST: $1" + echo -e "==========================================${NC}" + echo "" +} + +# Function to print success +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +# Function to print error +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# Function to print warning +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +# Function to check if jq is installed +check_jq() { + if ! command -v jq &> /dev/null; then + print_warning "jq is not installed (JSON parsing will be limited)" + echo " Install: apt-get install jq or brew install jq" + return 1 + fi + return 0 +} + +# Check for jq +HAS_JQ=false +if check_jq; then + HAS_JQ=true +fi + +# ============================================================================== +# TEST 1: Health Check (Unprotected) +# ============================================================================== +print_test "1. Health Check (Unprotected Route)" + +echo "Request: GET /health" +RESPONSE=$(curl -s -w "\n%{http_code}" http://localhost:8080/health) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Health check passed" +else + print_error "Health check failed (expected 200, got $HTTP_CODE)" + exit 1 +fi + +# ============================================================================== +# TEST 2: User Registration +# ============================================================================== +print_test "2. User Registration" + +echo "Creating new user account..." +echo " Email: $TEST_EMAIL" +echo " Password: $TEST_PASSWORD" +echo " Name: $TEST_FIRST_NAME $TEST_LAST_NAME" +echo " Tenant: $TEST_TENANT_NAME" +echo "" + +REGISTER_PAYLOAD=$(cat </dev/null || echo "$REGISTER_PAYLOAD" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \ + -H "Content-Type: application/json" \ + -d "$REGISTER_PAYLOAD") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "201" ]; then + print_success "User registered successfully" + + # Extract tokens and IDs + if [ "$HAS_JQ" = true ]; then + ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + USER_ID=$(echo "$BODY" | jq -r '.user.id') + TENANT_ID=$(echo "$BODY" | jq -r '.tenant.id') + + echo "" + echo "Extracted data:" + echo " User ID: $USER_ID" + echo " Tenant ID: $TENANT_ID" + echo " Access Token: ${ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${REFRESH_TOKEN:0:30}..." + else + print_warning "Install jq to extract tokens automatically" + echo "Manually extract access_token and refresh_token from response above" + fi +else + print_error "Registration failed (expected 201, got $HTTP_CODE)" + echo "" + echo "Common issues:" + echo " 1. Validator not registered (check main.go line 128)" + echo " 2. Database user doesn't exist" + echo " 3. Transaction interface mismatch" + exit 1 +fi + +# ============================================================================== +# TEST 3: Duplicate Registration (Should Fail) +# ============================================================================== +print_test "3. Duplicate Registration (Should Fail with 409)" + +echo "Attempting to register same email again..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \ + -H "Content-Type: application/json" \ + -d "$REGISTER_PAYLOAD") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "409" ]; then + print_success "Correctly rejected duplicate email" +else + print_warning "Expected 409 Conflict, got $HTTP_CODE (check uniqueness constraint)" +fi + +# ============================================================================== +# TEST 4: Invalid Registration (Weak Password) +# ============================================================================== +print_test "4. Invalid Registration - Weak Password" + +WEAK_PASSWORD_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "400" ]; then + print_success "Correctly rejected weak password" +else + print_warning "Expected 400 Bad Request, got $HTTP_CODE (check validator)" +fi + +# ============================================================================== +# TEST 5: Rate Limiting (Registration) +# ============================================================================== +print_test "5. Rate Limiting on Registration (5 requests/minute)" + +echo "Sending 6 rapid registration requests..." +echo "(Rate limit: 5 requests/minute)" +echo "" + +RATE_LIMITED=false +for i in {1..6}; do + UNIQUE_EMAIL="ratelimit-$i-$(date +%s)@example.com" + RATE_LIMIT_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Login successful" + + # Update tokens (login gives new tokens) + if [ "$HAS_JQ" = true ]; then + ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + + echo "" + echo "New tokens received:" + echo " Access Token: ${ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${REFRESH_TOKEN:0:30}..." + fi +else + print_error "Login failed (expected 200, got $HTTP_CODE)" + exit 1 +fi + +# ============================================================================== +# TEST 7: Login with Wrong Password +# ============================================================================== +print_test "7. Login with Wrong Password (Should Fail)" + +WRONG_PASSWORD_PAYLOAD=$(cat </dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Correctly rejected invalid credentials" +else + print_warning "Expected 401 Unauthorized, got $HTTP_CODE" +fi + +# ============================================================================== +# TEST 8: Protected Route (Health with Auth) +# ============================================================================== +print_test "8. Protected Health Endpoint" + +if [ -z "$ACCESS_TOKEN" ]; then + print_error "No access token available (login may have failed)" + exit 1 +fi + +echo "Request: GET /api/v1/health" +echo "Authorization: Bearer ${ACCESS_TOKEN:0:30}..." +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/health" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Authentication middleware working" +else + print_error "Protected route failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 9: Protected Route WITHOUT Token (Should Fail) +# ============================================================================== +print_test "9. Protected Route Without Token (Should Fail)" + +echo "Request: GET /api/v1/health" +echo "Authorization: (none)" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/health") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Correctly rejected request without token" +else + print_warning "Expected 401 Unauthorized, got $HTTP_CODE" +fi + +# ============================================================================== +# TEST 10: Get My Tenant +# ============================================================================== +print_test "10. Get My Tenant (Row-Level Security Test)" + +echo "Request: GET /api/v1/tenants/mine" +echo "This tests:" +echo " - Authentication middleware" +echo " - Row-Level Security (RLS)" +echo " - Tenant context extraction" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/tenants/mine" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Tenant retrieval successful (RLS working)" + + if [ "$HAS_JQ" = true ]; then + RETRIEVED_TENANT_ID=$(echo "$BODY" | jq -r '.id') + TENANT_NAME=$(echo "$BODY" | jq -r '.name') + + echo "" + echo "Tenant details:" + echo " ID: $RETRIEVED_TENANT_ID" + echo " Name: $TENANT_NAME" + + if [ "$RETRIEVED_TENANT_ID" = "$TENANT_ID" ]; then + print_success "Tenant ID matches registration" + else + print_warning "Tenant ID mismatch!" + fi + fi +else + print_error "Get tenant failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 11: Get Specific Tenant by ID +# ============================================================================== +print_test "11. Get Specific Tenant by ID" + +if [ -z "$TENANT_ID" ]; then + print_warning "Skipping: No tenant ID available" +else + echo "Request: GET /api/v1/tenants/$TENANT_ID" + echo "" + + RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/tenants/$TENANT_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + echo "HTTP Status: $HTTP_CODE" + echo "Response Body:" + echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + + if [ "$HTTP_CODE" = "200" ]; then + print_success "Specific tenant retrieval working" + else + print_error "Get specific tenant failed (expected 200, got $HTTP_CODE)" + echo "" + echo "This might fail if route order is wrong:" + echo " /tenants/mine must come BEFORE /tenants/:id" + echo " Otherwise ':id' matches 'mine' as a UUID" + fi +fi + +# ============================================================================== +# TEST 12: Token Refresh +# ============================================================================== +print_test "12. Token Refresh (Token Rotation)" + +if [ -z "$REFRESH_TOKEN" ]; then + print_error "No refresh token available" + exit 1 +fi + +echo "Request: POST /api/v1/auth/refresh" +echo "Using refresh token: ${REFRESH_TOKEN:0:30}..." +echo "" +echo "This tests:" +echo " - Refresh token validation" +echo " - Token rotation (new access + refresh tokens)" +echo " - Session management" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/refresh" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $REFRESH_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Token refresh successful" + + if [ "$HAS_JQ" = true ]; then + NEW_ACCESS_TOKEN=$(echo "$BODY" | jq -r '.access_token') + NEW_REFRESH_TOKEN=$(echo "$BODY" | jq -r '.refresh_token') + + echo "" + echo "New tokens received:" + echo " Access Token: ${NEW_ACCESS_TOKEN:0:30}..." + echo " Refresh Token: ${NEW_REFRESH_TOKEN:0:30}..." + + # Verify tokens are different (rotation) + if [ "$NEW_ACCESS_TOKEN" != "$ACCESS_TOKEN" ]; then + print_success "Access token rotated (security best practice)" + else + print_warning "Access token not rotated" + fi + + if [ "$NEW_REFRESH_TOKEN" != "$REFRESH_TOKEN" ]; then + print_success "Refresh token rotated (security best practice)" + else + print_warning "Refresh token not rotated" + fi + + # Update tokens for logout test + ACCESS_TOKEN="$NEW_ACCESS_TOKEN" + REFRESH_TOKEN="$NEW_REFRESH_TOKEN" + fi +else + print_error "Token refresh failed (expected 200, got $HTTP_CODE)" +fi + +# ============================================================================== +# TEST 13: Use Old Refresh Token (Should Fail) +# ============================================================================== +print_test "13. Use Old Refresh Token After Rotation (Should Fail)" + +echo "After token rotation, old refresh token should be invalid..." +echo "" + +# We'd need to save the old token before refresh to test this properly +print_warning "Skipping: Requires saving old token before rotation" +echo "Manual test: Try using old refresh token after successful refresh" + +# ============================================================================== +# TEST 14: User Logout +# ============================================================================== +print_test "14. User Logout (Session Revocation)" + +echo "Request: POST /api/v1/auth/logout" +echo "This should:" +echo " - Revoke current refresh token" +echo " - Mark session as revoked" +echo " - Old refresh token should no longer work" +echo "" + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/logout" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "200" ]; then + print_success "Logout successful" +else + print_warning "Logout returned $HTTP_CODE (expected 200)" +fi + +# ============================================================================== +# TEST 15: Use Tokens After Logout (Should Fail) +# ============================================================================== +print_test "15. Use Tokens After Logout (Should Fail)" + +echo "Attempting to refresh token after logout..." +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/refresh" \ + -H "Authorization: Bearer $REFRESH_TOKEN") + +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n-1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response Body:" +echo "$BODY" | jq '.' 2>/dev/null || echo "$BODY" + +if [ "$HTTP_CODE" = "401" ]; then + print_success "Refresh token correctly revoked after logout" +else + print_warning "Expected 401, got $HTTP_CODE (session may not be revoked)" +fi + +# ============================================================================== +# FINAL SUMMARY +# ============================================================================== +echo "" +echo "==========================================" +echo "TEST SUMMARY" +echo "==========================================" +echo "" +print_success "All critical tests completed!" +echo "" +echo "Tested functionality:" +echo " ✅ Health check (public route)" +echo " ✅ User registration with transaction" +echo " ✅ Duplicate email detection" +echo " ✅ Password validation" +echo " ✅ Rate limiting" +echo " ✅ User login" +echo " ✅ Invalid credential handling" +echo " ✅ Authentication middleware" +echo " ✅ Row-Level Security (RLS)" +echo " ✅ Tenant context management" +echo " ✅ Token refresh & rotation" +echo " ✅ Session revocation (logout)" +echo "" +echo "Database verification:" +echo " Check sessions table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, user_id, is_revoked, device_type, created_at FROM sessions;\"" +echo "" +echo " Check users table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, email, role, created_at FROM users;\"" +echo "" +echo " Check tenants table:" +echo " docker exec -it aurganize-postgres psql -U postgres -d aurganize_dev -c \"SELECT id, name, type, created_at FROM tenants;\"" +echo "" +echo "==========================================" +echo "TESTING COMPLETE" +echo "==========================================" \ No newline at end of file diff --git a/database/migrations/000001_initial_schema.down.sql b/database/migrations/000001_initial_schema.down.sql index 13552c2..31fd023 100644 --- a/database/migrations/000001_initial_schema.down.sql +++ b/database/migrations/000001_initial_schema.down.sql @@ -1,33 +1,246 @@ -- ============================================================================= --- ROLLBACK: 000001_initial_schema +-- AURGANIZE V6.2 - INITIAL SCHEMA ROLLBACK +-- ============================================================================= +-- Migration: 000001_initial_schema (DOWN) +-- Description: Safely removes all tables, functions, triggers, and types +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.0 (Marketplace Edition) +-- ============================================================================= +-- This rollback migration removes the entire Aurganize V6.2 schema in the +-- correct order to avoid foreign key constraint violations. +-- +-- CRITICAL SAFETY NOTES: +-- 1. This will DESTROY ALL DATA in the database +-- 2. Always backup before running this migration +-- 3. Cannot be undone - data recovery requires restoring from backup +-- 4. Runs in reverse dependency order (child tables before parent tables) +-- +-- Order of operations: +-- 1. Drop all RLS policies +-- 2. Drop all triggers +-- 3. Drop all tables (child → parent order) +-- 4. Drop all functions +-- 5. Drop all custom types +-- 6. Drop all extensions (optional - usually kept for other schemas) -- ============================================================================= --- Drop tables in reverse order (respecting foreign keys) +-- ============================================================================= +-- SECTION 1: DISABLE ROW-LEVEL SECURITY +-- ============================================================================= +-- Must disable RLS before dropping policies +-- ============================================================================= + +-- Disable RLS on all tables (if they exist) +ALTER TABLE IF EXISTS notifications DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS analytics_events DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS audit_logs DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS attachments DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS comments DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS milestones DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS deliverables DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS contracts DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS users DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS tenants DISABLE ROW LEVEL SECURITY; + +-- ============================================================================= +-- SECTION 2: DROP ROW-LEVEL SECURITY POLICIES +-- ============================================================================= +-- Drop policies before dropping tables +-- Using IF EXISTS to prevent errors if policies don't exist +-- ============================================================================= + +-- Notifications policies +DROP POLICY IF EXISTS notifications_tenant_isolation ON notifications; + +-- Analytics policies +DROP POLICY IF EXISTS analytics_events_access ON analytics_events; + +-- Audit logs policies +DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; + +-- Attachments policies +DROP POLICY IF EXISTS attachments_collaboration_access ON attachments; + +-- Comments policies +DROP POLICY IF EXISTS comments_collaboration_access ON comments; + +-- Milestones policies +DROP POLICY IF EXISTS milestones_collaboration_access ON milestones; + +-- Deliverables policies +DROP POLICY IF EXISTS deliverables_collaboration_access ON deliverables; + +-- Contracts policies +DROP POLICY IF EXISTS contracts_collaboration_access ON contracts; + +-- Users policies +DROP POLICY IF EXISTS users_marketplace_access ON users; + +-- Tenants policies +DROP POLICY IF EXISTS tenants_marketplace_access ON tenants; + +-- ============================================================================= +-- SECTION 3: DROP ALL TRIGGERS +-- ============================================================================= +-- Triggers must be dropped before functions they reference +-- Drop in any order (triggers are independent) +-- ============================================================================= + +-- Updated_at triggers (applied to multiple tables) +DROP TRIGGER IF EXISTS update_tenants_updated_at ON tenants; +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +DROP TRIGGER IF EXISTS update_contracts_updated_at ON contracts; +DROP TRIGGER IF EXISTS update_deliverables_updated_at ON deliverables; +DROP TRIGGER IF EXISTS update_milestones_updated_at ON milestones; +DROP TRIGGER IF EXISTS update_comments_updated_at ON comments; +DROP TRIGGER IF EXISTS update_attachments_updated_at ON attachments; + +-- Full name generation trigger (users table) +DROP TRIGGER IF EXISTS update_user_full_name ON users; + +-- ============================================================================= +-- SECTION 4: DROP ALL TABLES +-- ============================================================================= +-- CRITICAL ORDER: Drop child tables BEFORE parent tables +-- Foreign key constraints prevent dropping parent tables first +-- +-- Dependency tree: +-- notifications → tenants, users +-- analytics_events → tenants, users +-- audit_logs → tenants, users +-- attachments → tenants, users +-- comments → tenants, users +-- milestones → tenants, deliverables +-- deliverables → tenants, contracts +-- contracts → tenants, users +-- users → tenants +-- tenants (root) +-- ============================================================================= + +-- Level 4: Supporting tables (no dependencies) DROP TABLE IF EXISTS notifications CASCADE; DROP TABLE IF EXISTS analytics_events CASCADE; DROP TABLE IF EXISTS audit_logs CASCADE; + +-- Level 3: Polymorphic relationship tables (depend on multiple entities) DROP TABLE IF EXISTS attachments CASCADE; DROP TABLE IF EXISTS comments CASCADE; + +-- Level 2: Milestones → Deliverables DROP TABLE IF EXISTS milestones CASCADE; + +-- Level 1: Deliverables → Contracts DROP TABLE IF EXISTS deliverables CASCADE; + +-- Level 0: Core business tables DROP TABLE IF EXISTS contracts CASCADE; DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS tenants CASCADE; --- Drop functions -DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; -DROP FUNCTION IF EXISTS set_tenant_context(UUID) CASCADE; -DROP FUNCTION IF EXISTS get_current_tenant() CASCADE; +-- ============================================================================= +-- SECTION 5: DROP ALL FUNCTIONS +-- ============================================================================= +-- Functions can be dropped after triggers that use them +-- Order doesn't matter for functions +-- ============================================================================= --- Drop enums +-- Trigger function for updating updated_at timestamps +DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; + +-- Trigger function for generating full_name from first_name + last_name +DROP FUNCTION IF EXISTS generate_full_name() CASCADE; + +-- ============================================================================= +-- SECTION 6: DROP ALL CUSTOM TYPES +-- ============================================================================= +-- Types must be dropped after all tables/functions that use them +-- Order doesn't matter for types (no dependencies between types) +-- ============================================================================= + +-- Drop all ENUM types DROP TYPE IF EXISTS milestone_status CASCADE; DROP TYPE IF EXISTS milestone_type CASCADE; DROP TYPE IF EXISTS deliverable_status CASCADE; DROP TYPE IF EXISTS contract_status CASCADE; -DROP TYPE IF EXISTS tenant_type CASCADE; DROP TYPE IF EXISTS user_role CASCADE; --- Drop extensions (optional - might be used by other databases) --- DROP EXTENSION IF EXISTS "btree_gin"; --- DROP EXTENSION IF EXISTS "pg_trgm"; --- DROP EXTENSION IF EXISTS "uuid-ossp"; \ No newline at end of file +-- ============================================================================= +-- SECTION 7: DROP EXTENSIONS (OPTIONAL) +-- ============================================================================= +-- WARNING: Only drop extensions if you're certain no other schemas use them +-- Usually extensions are shared across the entire database +-- Commented out by default for safety +-- ============================================================================= + +-- Uncomment only if you're certain these extensions are not used elsewhere: +-- DROP EXTENSION IF EXISTS "unaccent" CASCADE; +-- DROP EXTENSION IF EXISTS "btree_gin" CASCADE; +-- DROP EXTENSION IF EXISTS "pg_trgm" CASCADE; +-- DROP EXTENSION IF EXISTS "uuid-ossp" CASCADE; + +-- ============================================================================= +-- SECTION 8: VERIFICATION QUERIES (OPTIONAL) +-- ============================================================================= +-- Run these after rollback to verify complete removal +-- These queries should return 0 rows if rollback was successful +-- ============================================================================= + +-- Verify no tables remain from our schema +DO $$ +DECLARE + remaining_tables INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_tables + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'tenants', 'users', 'contracts', 'deliverables', 'milestones', + 'comments', 'attachments', 'audit_logs', 'analytics_events', 'notifications' + ); + + IF remaining_tables > 0 THEN + RAISE WARNING 'WARNING: % tables still exist after rollback', remaining_tables; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize tables removed'; + END IF; +END $$; + +-- Verify no custom types remain +DO $$ +DECLARE + remaining_types INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_types + FROM pg_type + WHERE typname IN ( + 'user_role', 'contract_status', 'deliverable_status', + 'milestone_type', 'milestone_status' + ); + + IF remaining_types > 0 THEN + RAISE WARNING 'WARNING: % custom types still exist after rollback', remaining_types; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize custom types removed'; + END IF; +END $$; + +-- Verify no functions remain +DO $$ +DECLARE + remaining_functions INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_functions + FROM pg_proc + WHERE proname IN ('update_updated_at_column', 'generate_full_name'); + + IF remaining_functions > 0 THEN + RAISE WARNING 'WARNING: % functions still exist after rollback', remaining_functions; + ELSE + RAISE NOTICE 'SUCCESS: All Aurganize functions removed'; + END IF; +END $$; + +-- ============================================================================= +-- END OF ROLLBACK MIGRATION 000001_initial_schema.down.sql +-- ============================================================================= diff --git a/database/migrations/000001_initial_schema.up.sql b/database/migrations/000001_initial_schema.up.sql index 80c4671..05f570a 100644 --- a/database/migrations/000001_initial_schema.up.sql +++ b/database/migrations/000001_initial_schema.up.sql @@ -1,197 +1,318 @@ -- ============================================================================= --- AURGANIZE V6.2 - INITIAL SCHEMA (CORRECTED) +-- AURGANIZE V6.2 - INITIAL SCHEMA (MARKETPLACE ARCHITECTURE) -- ============================================================================= -- Migration: 000001_initial_schema --- Description: Creates core tables for multi-tenant project management +-- Description: Creates core tables matching Go models exactly -- Author: Aurganize Team --- Date: 2025-11-26 +-- Date: 2025-12-11 +-- Version: 2.0 (Marketplace Edition - Matching Go Models) +-- ============================================================================= +-- This migration establishes the foundational database schema for Aurganize V6.2, +-- a B2B marketplace platform connecting vendors and consumers. +-- +-- Key Design Decisions: +-- 1. Schema matches Go models EXACTLY (no mismatches) +-- 2. Multi-tenancy WITHOUT strict RLS on tenants/users (marketplace discovery) +-- 3. Collaboration-aware RLS on contracts (both vendor & consumer can access) +-- 4. System role with BYPASSRLS for registration flow +-- 5. Full-text search for marketplace discovery -- ============================================================================= -- ============================================================================= --- EXTENSIONS +-- SECTION 1: EXTENSIONS -- ============================================================================= CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -CREATE EXTENSION IF NOT EXISTS "btree_gin"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Fuzzy text search +CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- JSONB indexes +CREATE EXTENSION IF NOT EXISTS "unaccent"; -- For slug generation -- ============================================================================= --- ENUMS +-- SECTION 2: CUSTOM ENUM TYPES -- ============================================================================= -CREATE TYPE user_role AS ENUM ('admin', 'vendor', 'consumer', 'project_manager'); -CREATE TYPE tenant_type AS ENUM ('permanent', 'temporary'); -CREATE TYPE contract_status AS ENUM ('draft', 'active', 'completed', 'cancelled'); -CREATE TYPE deliverable_status AS ENUM ('pending', 'in_progress', 'submitted', 'approved', 'rejected'); -CREATE TYPE milestone_type AS ENUM ('fixed_date', 'duration_from_start', 'duration_from_previous'); -CREATE TYPE milestone_status AS ENUM ('pending', 'in_progress', 'completed'); +-- User roles for marketplace participants +CREATE TYPE user_role AS ENUM ( + 'admin', -- Platform administrator + 'vendor', -- Service provider (interior designers, agencies, etc.) + 'consumer', -- Service buyer (hotels, companies, etc.) + 'project_manager' -- Project coordinator +); + +-- Contract lifecycle states +CREATE TYPE contract_status AS ENUM ( + 'draft', -- Being negotiated + 'active', -- Work in progress + 'completed', -- All deliverables approved + 'cancelled' -- Terminated before completion +); + +-- Deliverable workflow states +CREATE TYPE deliverable_status AS ENUM ( + 'pending', -- Not yet started + 'in_progress', -- Vendor working on it + 'submitted', -- Awaiting consumer approval + 'approved', -- Consumer accepted + 'rejected' -- Needs rework +); + +-- Milestone scheduling types +CREATE TYPE milestone_type AS ENUM ( + 'fixed_date', -- Specific calendar date (e.g., "2025-12-31") + 'duration_from_start', -- Days from contract start (e.g., "30") + 'duration_from_previous' -- Days from previous milestone (e.g., "14") +); + +-- Milestone completion states +CREATE TYPE milestone_status AS ENUM ( + 'pending', -- Not yet eligible + 'in_progress', -- Conditions being met + 'completed' -- Fully satisfied +); -- ============================================================================= --- CORE TABLES +-- SECTION 3: TENANTS TABLE +-- ============================================================================= +-- Organizations in the marketplace (vendors and consumers) +-- ✅ Matches Go model exactly +-- ✅ NO strict RLS - discoverable for marketplace -- ============================================================================= - --- ----------------------------------------------------------------------------- --- Tenants Table --- ----------------------------------------------------------------------------- --- Stores tenant (organization) information --- Supports both permanent tenants (companies) and temporary tenants (projects) CREATE TABLE tenants ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(255) NOT NULL, - type tenant_type NOT NULL DEFAULT 'permanent', - parent_tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - is_active BOOLEAN NOT NULL DEFAULT true, - expires_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_tenant_type CHECK ( - (type = 'permanent' AND parent_tenant_id IS NULL AND expires_at IS NULL) OR - (type = 'temporary' AND parent_tenant_id IS NOT NULL AND expires_at IS NOT NULL) - ) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + + -- Contact information (matches Go model) + email VARCHAR(255), + phone VARCHAR(50), + website TEXT, + + -- Address (matches Go model) + address_line1 VARCHAR(255), + address_line2 VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + country VARCHAR(100), + postal_code VARCHAR(20), + + -- Localization (matches Go model) + timezone VARCHAR(50) NOT NULL DEFAULT 'UTC', + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + locale VARCHAR(10) NOT NULL DEFAULT 'en-US', + + -- Subscription & billing (matches Go model) + subscription_status VARCHAR(50) NOT NULL DEFAULT 'trial', + subscription_plan VARCHAR(50) NOT NULL DEFAULT 'basic', + subscription_expires_at TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + + -- Limits (matches Go model) + max_users INTEGER NOT NULL DEFAULT 10, + max_contracts INTEGER NOT NULL DEFAULT 50, + max_storage_mb INTEGER NOT NULL DEFAULT 5120, + + -- Status (matches Go model - NOT is_active) + status VARCHAR(50) NOT NULL DEFAULT 'active', + + -- Audit fields (matches Go model) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT chk_tenant_subscription_status CHECK ( + subscription_status IN ('trial', 'active', 'cancelled', 'expired', 'suspended') + ), + CONSTRAINT chk_tenant_subscription_plan CHECK ( + subscription_plan IN ('basic', 'professional', 'enterprise') + ), + CONSTRAINT chk_tenant_status CHECK ( + status IN ('active', 'inactive', 'suspended', 'deleted') + ) ); --- Indexes -CREATE INDEX idx_tenants_parent ON tenants(parent_tenant_id) WHERE parent_tenant_id IS NOT NULL; -CREATE INDEX idx_tenants_active ON tenants(is_active) WHERE is_active = true; -CREATE INDEX idx_tenants_expires ON tenants(expires_at) WHERE expires_at IS NOT NULL; +-- Performance indexes +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active'; +CREATE INDEX idx_tenants_subscription ON tenants(subscription_status, subscription_expires_at); +CREATE INDEX idx_tenants_trial ON tenants(trial_ends_at) WHERE trial_ends_at IS NOT NULL; --- Comments -COMMENT ON TABLE tenants IS 'Organizations and project workspaces'; -COMMENT ON COLUMN tenants.type IS 'permanent: Long-lived organization, temporary: Project-specific workspace'; -COMMENT ON COLUMN tenants.parent_tenant_id IS 'For temporary tenants, links to parent permanent tenant'; +-- Full-text search for marketplace directory +CREATE INDEX idx_tenants_search ON tenants + USING GIN(to_tsvector('english', + name || ' ' || + COALESCE(city, '') || ' ' || + COALESCE(country, '') + )); --- ----------------------------------------------------------------------------- --- Users Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE tenants IS 'Organizations in marketplace - discoverable without RLS (matches Go models.Tenant)'; +COMMENT ON COLUMN tenants.slug IS 'URL-friendly unique identifier generated from name'; +COMMENT ON COLUMN tenants.status IS 'active, inactive, suspended, deleted (NOT boolean is_active)'; + +-- ============================================================================= +-- SECTION 4: USERS TABLE +-- ============================================================================= +-- User accounts with complete profile support +-- ✅ Matches Go model exactly (first_name, last_name, full_name, etc.) +-- ✅ NO strict RLS - profiles discoverable in marketplace +-- ============================================================================= CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Authentication - email VARCHAR(255) NOT NULL, - password_hash TEXT NOT NULL, - - -- Profile - name VARCHAR(255) NOT NULL, - avatar_url TEXT, - role user_role NOT NULL DEFAULT 'consumer', - - -- Status - is_active BOOLEAN NOT NULL DEFAULT true, - email_verified_at TIMESTAMPTZ, - last_login_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT unique_email_per_tenant UNIQUE(tenant_id, email), - CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Authentication + email VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, + + -- Profile (matches Go model - first_name, last_name, full_name) + first_name VARCHAR(255), + last_name VARCHAR(255), + full_name VARCHAR(255), -- Will be auto-generated via trigger + avatar_url TEXT, + phone VARCHAR(50), + + -- Role & permissions (matches Go model) + role user_role NOT NULL DEFAULT 'consumer', + + -- Account status (matches Go model - status string, NOT is_active boolean) + status VARCHAR(50) NOT NULL DEFAULT 'active', + email_verified BOOLEAN NOT NULL DEFAULT false, + email_verified_at TIMESTAMPTZ, + is_onboarded BOOLEAN NOT NULL DEFAULT false, + + -- Activity tracking (matches Go model - includes last_login_ip) + last_login_at TIMESTAMPTZ, + last_login_ip INET, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT unique_email_per_tenant UNIQUE(tenant_id, email), + CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), + CONSTRAINT chk_user_status CHECK ( + status IN ('active', 'inactive', 'suspended', 'pending_verification') + ) ); --- Indexes +-- Performance indexes CREATE INDEX idx_users_tenant ON users(tenant_id); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_role ON users(role); -CREATE INDEX idx_users_active ON users(is_active) WHERE is_active = true; +CREATE INDEX idx_users_status ON users(status) WHERE status = 'active'; +CREATE INDEX idx_users_tenant_role ON users(tenant_id, role); --- Comments -COMMENT ON TABLE users IS 'User accounts with multi-tenant support'; -COMMENT ON CONSTRAINT unique_email_per_tenant ON users IS 'Email must be unique within a tenant, but can exist in multiple tenants'; +-- Full-text search for user discovery in marketplace +CREATE INDEX idx_users_search ON users + USING GIN(to_tsvector('english', + COALESCE(full_name, '') || ' ' || + COALESCE(first_name, '') || ' ' || + COALESCE(last_name, '') || ' ' || + email + )); --- ----------------------------------------------------------------------------- --- Contracts Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE users IS 'User accounts - profiles discoverable in marketplace (matches Go models.User)'; +COMMENT ON COLUMN users.full_name IS 'Auto-generated from first_name + last_name via trigger'; +COMMENT ON COLUMN users.status IS 'String status (active, inactive, suspended, pending_verification) NOT boolean'; +COMMENT ON COLUMN users.email_verified IS 'Boolean flag (separate from email_verified_at timestamp)'; + +-- ============================================================================= +-- SECTION 5: CONTRACTS TABLE +-- ============================================================================= +-- Agreements between vendors and consumers +-- ✅ RLS allows BOTH vendor and consumer tenants to access (collaboration-aware) +-- ============================================================================= CREATE TABLE contracts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Parties - vendor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - consumer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - - -- Details - title VARCHAR(500) NOT NULL, - description TEXT, - status contract_status NOT NULL DEFAULT 'draft', - - -- Dates - start_date DATE NOT NULL, - end_date DATE NOT NULL, - - -- Financial - total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, - currency VARCHAR(3) NOT NULL DEFAULT 'USD', - - -- Version control (optimistic locking) - version INTEGER NOT NULL DEFAULT 1, - - -- Metadata - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_dates CHECK (end_date > start_date), - CONSTRAINT valid_amount CHECK (total_amount >= 0), - CONSTRAINT different_parties CHECK (vendor_id != consumer_id) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Parties (vendor provides service, consumer receives it) + vendor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + consumer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + + -- Contract details + title VARCHAR(500) NOT NULL, + description TEXT, + status contract_status NOT NULL DEFAULT 'draft', + + -- Timeline + start_date DATE NOT NULL, + end_date DATE NOT NULL, + + -- Financial terms + total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + + -- Concurrency control + version INTEGER NOT NULL DEFAULT 1, + + -- Audit fields + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_dates CHECK (end_date > start_date), + CONSTRAINT valid_amount CHECK (total_amount >= 0), + CONSTRAINT different_parties CHECK (vendor_id != consumer_id) ); --- Indexes +-- Performance indexes CREATE INDEX idx_contracts_tenant ON contracts(tenant_id); CREATE INDEX idx_contracts_vendor ON contracts(vendor_id); CREATE INDEX idx_contracts_consumer ON contracts(consumer_id); CREATE INDEX idx_contracts_status ON contracts(status); CREATE INDEX idx_contracts_dates ON contracts(start_date, end_date); -CREATE INDEX idx_contracts_search ON contracts USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, ''))); --- Comments -COMMENT ON TABLE contracts IS 'Agreements between vendors and consumers'; -COMMENT ON COLUMN contracts.version IS 'For optimistic locking - increment on each update'; +-- Full-text search +CREATE INDEX idx_contracts_search ON contracts + USING GIN(to_tsvector('english', title || ' ' || COALESCE(description, ''))); --- ----------------------------------------------------------------------------- --- Deliverables Table --- ----------------------------------------------------------------------------- +COMMENT ON TABLE contracts IS 'Vendor-consumer agreements with collaboration-aware RLS (both parties can access)'; +COMMENT ON COLUMN contracts.tenant_id IS 'Primary tenant (usually vendor) but both vendor/consumer tenants have access'; + +-- ============================================================================= +-- SECTION 6: DELIVERABLES TABLE +-- ============================================================================= CREATE TABLE deliverables ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, - - -- Details - title VARCHAR(500) NOT NULL, - description TEXT, - sequence_number INTEGER NOT NULL, - status deliverable_status NOT NULL DEFAULT 'pending', - - -- Dates - deadline DATE NOT NULL, - submitted_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - - -- Submission - submitted_by UUID REFERENCES users(id) ON DELETE SET NULL, - approved_by UUID REFERENCES users(id) ON DELETE SET NULL, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT unique_sequence_per_contract UNIQUE(contract_id, sequence_number), - CONSTRAINT valid_sequence CHECK (sequence_number > 0) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, + + -- Deliverable details + title VARCHAR(500) NOT NULL, + description TEXT, + sequence_number INTEGER NOT NULL, + status deliverable_status NOT NULL DEFAULT 'pending', + + -- Timeline + deadline DATE NOT NULL, + submitted_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + + -- Workflow tracking + submitted_by UUID REFERENCES users(id) ON DELETE SET NULL, + approved_by UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT unique_sequence_per_contract UNIQUE(contract_id, sequence_number), + CONSTRAINT valid_sequence CHECK (sequence_number > 0) ); -- Indexes @@ -200,36 +321,35 @@ CREATE INDEX idx_deliverables_contract ON deliverables(contract_id); CREATE INDEX idx_deliverables_status ON deliverables(status); CREATE INDEX idx_deliverables_deadline ON deliverables(deadline); --- Comments -COMMENT ON TABLE deliverables IS 'Work items to be delivered as part of contracts'; -COMMENT ON COLUMN deliverables.sequence_number IS 'Order of deliverable in contract (1, 2, 3...)'; +COMMENT ON TABLE deliverables IS 'Work items within contracts - inherit collaboration from parent contract'; --- ----------------------------------------------------------------------------- --- Milestones Table --- ----------------------------------------------------------------------------- +-- ============================================================================= +-- SECTION 7: MILESTONES TABLE +-- ============================================================================= CREATE TABLE milestones ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - deliverable_id UUID NOT NULL REFERENCES deliverables(id) ON DELETE CASCADE, - - -- Details - title VARCHAR(500) NOT NULL, - type milestone_type NOT NULL, - condition_value VARCHAR(100) NOT NULL, - amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, - status milestone_status NOT NULL DEFAULT 'pending', - - -- Tracking - completed_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_amount CHECK (amount >= 0) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + deliverable_id UUID NOT NULL REFERENCES deliverables(id) ON DELETE CASCADE, + + -- Milestone configuration + title VARCHAR(500) NOT NULL, + type milestone_type NOT NULL, + condition_value VARCHAR(100) NOT NULL, + amount NUMERIC(12,2) NOT NULL DEFAULT 0.00, + status milestone_status NOT NULL DEFAULT 'pending', + + -- Completion tracking + completed_at TIMESTAMPTZ, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_milestone_amount CHECK (amount >= 0) ); -- Indexes @@ -237,36 +357,40 @@ CREATE INDEX idx_milestones_tenant ON milestones(tenant_id); CREATE INDEX idx_milestones_deliverable ON milestones(deliverable_id); CREATE INDEX idx_milestones_status ON milestones(status); --- Comments -COMMENT ON TABLE milestones IS 'Payment milestones within deliverables'; -COMMENT ON COLUMN milestones.type IS 'Determines how condition_value is interpreted'; -COMMENT ON COLUMN milestones.condition_value IS 'Date or duration depending on type'; +COMMENT ON TABLE milestones IS 'Payment milestones with flexible scheduling logic'; + +-- ============================================================================= +-- SECTION 8: SUPPORTING TABLES +-- ============================================================================= -- ----------------------------------------------------------------------------- --- Comments Table +-- Comments Table (Polymorphic - can comment on any entity) -- ----------------------------------------------------------------------------- CREATE TABLE comments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Polymorphic relation - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- Content - content TEXT NOT NULL, - - -- Author - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone')) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Polymorphic relationship + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- Comment content + content TEXT NOT NULL, + + -- Author + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_comment_entity_type CHECK ( + entity_type IN ('contract', 'deliverable', 'milestone') + ) ); -- Indexes @@ -275,45 +399,47 @@ CREATE INDEX idx_comments_entity ON comments(entity_type, entity_id); CREATE INDEX idx_comments_user ON comments(user_id); CREATE INDEX idx_comments_created ON comments(created_at DESC); --- Comments -COMMENT ON TABLE comments IS 'Discussion comments on various entities'; -COMMENT ON COLUMN comments.entity_type IS 'Type of entity: contract, deliverable, milestone'; -COMMENT ON COLUMN comments.entity_id IS 'ID of the entity (contract, deliverable, or milestone)'; +COMMENT ON TABLE comments IS 'Discussion comments on contracts, deliverables, milestones'; -- ----------------------------------------------------------------------------- --- Attachments Table +-- Attachments Table (File storage metadata) -- ----------------------------------------------------------------------------- CREATE TABLE attachments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Polymorphic relation - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- File details - filename VARCHAR(255) NOT NULL, - content_type VARCHAR(100) NOT NULL, - size BIGINT NOT NULL, - object_name TEXT NOT NULL, - - -- Status - status VARCHAR(20) NOT NULL DEFAULT 'pending', - - -- Tracking - uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - uploaded_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT valid_size CHECK (size > 0), - CONSTRAINT valid_status CHECK (status IN ('pending', 'uploaded', 'processing', 'failed')), - CONSTRAINT valid_entity_type CHECK (entity_type IN ('contract', 'deliverable', 'milestone', 'comment')) + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Polymorphic relationship + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- File details + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + object_name TEXT NOT NULL, -- S3/MinIO object key + + -- Status tracking + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Upload tracking + uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + uploaded_at TIMESTAMPTZ, + + -- Audit fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT valid_attachment_size CHECK (size > 0), + CONSTRAINT valid_attachment_status CHECK ( + status IN ('pending', 'uploaded', 'processing', 'failed') + ), + CONSTRAINT valid_attachment_entity_type CHECK ( + entity_type IN ('contract', 'deliverable', 'milestone', 'comment') + ) ); -- Indexes @@ -322,40 +448,35 @@ CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id); CREATE INDEX idx_attachments_uploaded_by ON attachments(uploaded_by); CREATE INDEX idx_attachments_status ON attachments(status); --- Comments -COMMENT ON TABLE attachments IS 'File attachments for various entities'; -COMMENT ON COLUMN attachments.object_name IS 'Object key in MinIO/S3'; - --- ============================================================================= --- AUDIT TABLES --- ============================================================================= +COMMENT ON TABLE attachments IS 'File attachments stored in MinIO/S3'; -- ----------------------------------------------------------------------------- -- Audit Logs Table -- ----------------------------------------------------------------------------- CREATE TABLE audit_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Action details - action VARCHAR(100) NOT NULL, - entity_type VARCHAR(50) NOT NULL, - entity_id UUID NOT NULL, - - -- Actor - actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - - -- Changes - old_values JSONB, - new_values JSONB, - - -- Context - ip_address INET, - user_agent TEXT, - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Action details + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + + -- Actor information + actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + + -- Change tracking + old_values JSONB, + new_values JSONB, + + -- Security context + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -366,31 +487,30 @@ CREATE INDEX idx_audit_action ON audit_logs(action); CREATE INDEX idx_audit_created ON audit_logs(created_at DESC); CREATE INDEX idx_audit_values ON audit_logs USING GIN(old_values, new_values); --- Comments -COMMENT ON TABLE audit_logs IS 'Audit trail of all important actions'; -COMMENT ON COLUMN audit_logs.action IS 'e.g., contract.created, deliverable.submitted'; +COMMENT ON TABLE audit_logs IS 'Immutable audit trail for compliance'; -- ----------------------------------------------------------------------------- -- Analytics Events Table -- ----------------------------------------------------------------------------- CREATE TABLE analytics_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - - -- Event details - event_type VARCHAR(100) NOT NULL, - event_data JSONB NOT NULL DEFAULT '{}', - - -- User (nullable for anonymous events) - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - - -- Context - ip_address INET, - user_agent TEXT, - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + + -- Event details + event_type VARCHAR(100) NOT NULL, + event_data JSONB NOT NULL DEFAULT '{}', + + -- User (nullable for anonymous events) + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Context + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -400,34 +520,34 @@ CREATE INDEX idx_analytics_user ON analytics_events(user_id); CREATE INDEX idx_analytics_created ON analytics_events(created_at DESC); CREATE INDEX idx_analytics_data ON analytics_events USING GIN(event_data); --- Comments COMMENT ON TABLE analytics_events IS 'User behavior and system events for analytics'; --- ============================================================================= --- NOTIFICATION TABLES --- ============================================================================= +-- ----------------------------------------------------------------------------- +-- Notifications Table +-- ----------------------------------------------------------------------------- CREATE TABLE notifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - - -- Recipient - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Content - type VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - - -- Related entity - entity_type VARCHAR(50), - entity_id UUID, - - -- Status - read_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Recipient + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Notification content + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + + -- Related entity (optional) + entity_type VARCHAR(50), + entity_id UUID, + + -- Read status + read_at TIMESTAMPTZ, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes @@ -437,49 +557,72 @@ CREATE INDEX idx_notifications_unread ON notifications(user_id, read_at) WHERE r CREATE INDEX idx_notifications_entity ON notifications(entity_type, entity_id); CREATE INDEX idx_notifications_created ON notifications(created_at DESC); --- Comments -COMMENT ON TABLE notifications IS 'In-app notifications for users'; +COMMENT ON TABLE notifications IS 'In-app notifications with read/unread tracking'; -- ============================================================================= --- TRIGGERS FOR UPDATED_AT +-- SECTION 9: TRIGGERS FOR AUTOMATIC UPDATES -- ============================================================================= --- Function to update updated_at timestamp +-- ----------------------------------------------------------------------------- +-- Trigger: Auto-update updated_at column +-- ----------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN - NEW.updated_at = NOW(); - RETURN NEW; + NEW.updated_at = NOW(); + RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply to all tables with updated_at CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_contracts_updated_at BEFORE UPDATE ON contracts - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_deliverables_updated_at BEFORE UPDATE ON deliverables - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_milestones_updated_at BEFORE UPDATE ON milestones - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_attachments_updated_at BEFORE UPDATE ON attachments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ----------------------------------------------------------------------------- +-- Trigger: Auto-generate full_name from first_name + last_name +-- ----------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION generate_full_name() +RETURNS TRIGGER AS $$ +BEGIN + NEW.full_name := TRIM( + COALESCE(NEW.first_name, '') || ' ' || COALESCE(NEW.last_name, '') + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_user_full_name + BEFORE INSERT OR UPDATE OF first_name, last_name ON users + FOR EACH ROW + EXECUTE FUNCTION generate_full_name(); + +COMMENT ON FUNCTION generate_full_name() IS 'Auto-generates full_name from first_name + last_name'; -- ============================================================================= --- ROW-LEVEL SECURITY +-- SECTION 10: ROW-LEVEL SECURITY (MARKETPLACE-AWARE) -- ============================================================================= --- Enable RLS on all tenant-scoped tables +-- Enable RLS on all tables ALTER TABLE tenants ENABLE ROW LEVEL SECURITY; ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE contracts ENABLE ROW LEVEL SECURITY; @@ -491,47 +634,264 @@ ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY; ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; --- Create policies -CREATE POLICY tenants_tenant_isolation ON tenants - USING (id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Tenants: Marketplace discovery (allow NULL tenant_id for registration) +-- ----------------------------------------------------------------------------- -CREATE POLICY users_tenant_isolation ON users - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +CREATE POLICY tenants_marketplace_access ON tenants + FOR ALL + USING ( + -- Own tenant + id = current_setting('app.current_tenant_id', true)::UUID + OR + -- Allow during registration (no tenant context yet) + current_setting('app.current_tenant_id', true) IS NULL + OR + -- Public tenants (for marketplace directory - all tenants visible) + status = 'active' + ) + WITH CHECK ( + id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY contracts_tenant_isolation ON contracts - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +COMMENT ON POLICY tenants_marketplace_access ON tenants IS + 'Allows marketplace discovery of active tenants + registration without tenant context'; -CREATE POLICY deliverables_tenant_isolation ON deliverables - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Users: Marketplace discovery (vendor/consumer profiles visible) +-- ----------------------------------------------------------------------------- -CREATE POLICY milestones_tenant_isolation ON milestones - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +CREATE POLICY users_marketplace_access ON users + FOR ALL + USING ( + -- Own tenant users + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + -- Allow during registration (no tenant context yet) + current_setting('app.current_tenant_id', true) IS NULL + OR + -- Active users from other tenants (marketplace discovery) + status = 'active' + ) + WITH CHECK ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY comments_tenant_isolation ON comments - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +COMMENT ON POLICY users_marketplace_access ON users IS + 'Allows discovery of active users across tenants + registration flow'; -CREATE POLICY attachments_tenant_isolation ON attachments - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +-- ----------------------------------------------------------------------------- +-- Contracts: Collaboration-aware (both vendor and consumer can access) +-- ----------------------------------------------------------------------------- + +CREATE POLICY contracts_collaboration_access ON contracts + FOR ALL + USING ( + -- Vendor's tenant + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.vendor_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + -- Consumer's tenant + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.consumer_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + -- Allow during creation + current_setting('app.current_tenant_id', true) IS NULL + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.vendor_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users + WHERE users.id = contracts.consumer_id + AND users.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +COMMENT ON POLICY contracts_collaboration_access ON contracts IS + 'Allows both vendor and consumer tenants to access contract (marketplace collaboration)'; + +-- ----------------------------------------------------------------------------- +-- Deliverables: Inherit collaboration from parent contract +-- ----------------------------------------------------------------------------- + +CREATE POLICY deliverables_collaboration_access ON deliverables + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM contracts c + WHERE c.id = deliverables.contract_id + AND ( + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.vendor_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.consumer_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Milestones: Inherit collaboration via deliverable → contract +-- ----------------------------------------------------------------------------- + +CREATE POLICY milestones_collaboration_access ON milestones + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM deliverables d + JOIN contracts c ON c.id = d.contract_id + WHERE d.id = milestones.deliverable_id + AND ( + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.vendor_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + OR + EXISTS ( + SELECT 1 FROM users u + WHERE u.id = c.consumer_id + AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Comments: Inherit collaboration from parent entity +-- ----------------------------------------------------------------------------- + +CREATE POLICY comments_collaboration_access ON comments + FOR ALL + USING ( + -- Comments on contracts + (entity_type = 'contract' AND + EXISTS ( + SELECT 1 FROM contracts c + WHERE c.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + -- Comments on deliverables + (entity_type = 'deliverable' AND + EXISTS ( + SELECT 1 FROM deliverables d + JOIN contracts c ON c.id = d.contract_id + WHERE d.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + -- Comments on milestones + (entity_type = 'milestone' AND + EXISTS ( + SELECT 1 FROM milestones m + JOIN deliverables d ON d.id = m.deliverable_id + JOIN contracts c ON c.id = d.contract_id + WHERE m.id = comments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Attachments: Similar to comments +-- ----------------------------------------------------------------------------- + +CREATE POLICY attachments_collaboration_access ON attachments + FOR ALL + USING ( + (entity_type = 'contract' AND + EXISTS ( + SELECT 1 FROM contracts c WHERE c.id = attachments.entity_id + AND ( + EXISTS (SELECT 1 FROM users u WHERE u.id = c.vendor_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + OR + EXISTS (SELECT 1 FROM users u WHERE u.id = c.consumer_id AND u.tenant_id = current_setting('app.current_tenant_id', true)::UUID) + ) + ) + ) + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Audit Logs: Strict tenant isolation +-- ----------------------------------------------------------------------------- CREATE POLICY audit_logs_tenant_isolation ON audit_logs - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -CREATE POLICY analytics_events_tenant_isolation ON analytics_events - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID OR tenant_id IS NULL); +-- ----------------------------------------------------------------------------- +-- Analytics: System-wide OR tenant-specific +-- ----------------------------------------------------------------------------- + +CREATE POLICY analytics_events_access ON analytics_events + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + tenant_id IS NULL + OR + current_setting('app.current_tenant_id', true) IS NULL + ); + +-- ----------------------------------------------------------------------------- +-- Notifications: Strict tenant isolation +-- ----------------------------------------------------------------------------- CREATE POLICY notifications_tenant_isolation ON notifications - USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + USING ( + tenant_id = current_setting('app.current_tenant_id', true)::UUID + OR + current_setting('app.current_tenant_id', true) IS NULL + ); -- ============================================================================= --- END OF MIGRATION +-- END OF MIGRATION 000001_initial_schema.up.sql -- ============================================================================= \ No newline at end of file diff --git a/database/migrations/000002_sessions.down.sql b/database/migrations/000002_sessions.down.sql index ca6d02f..0136a7d 100644 --- a/database/migrations/000002_sessions.down.sql +++ b/database/migrations/000002_sessions.down.sql @@ -1,15 +1,98 @@ --- Drop indexes -DROP INDEX IF EXISTS idx_sessions_expired_cleanup; -DROP INDEX IF EXISTS idx_sessions_is_revoked; -DROP INDEX IF EXISTS idx_sessions_expires_at; -DROP INDEX IF EXISTS idx_sessions_refresh_token; +-- ============================================================================= +-- AURGANIZE V6.2 - SESSIONS TABLE ROLLBACK +-- ============================================================================= +-- Migration: 000002_add_sessions (DOWN) +-- Description: Removes sessions table and related objects +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.1 (Aligned to Tenant-less Sessions Model) +-- ============================================================================= +-- This rollback migration removes the sessions table and all associated +-- database objects (indexes, policies, constraints). +-- +-- CRITICAL WARNINGS: +-- 1. This PERMANENTLY DELETES all user sessions +-- 2. All users will be logged out immediately +-- 3. Active refresh tokens become invalid +-- 4. Cannot be undone without database backup +-- 5. Production impact: Users must re-login +-- +-- SAFE ROLLBACK ORDER: +-- 1. Disable RLS +-- 2. Drop policies +-- 3. Drop triggers (if any) +-- 4. Drop constraints (implicit via table drop) +-- 5. Drop indexes +-- 6. Drop table +-- ============================================================================= + + +-- ============================================================================= +-- SECTION 1: DISABLE ROW-LEVEL SECURITY +-- ============================================================================= + +ALTER TABLE IF EXISTS sessions DISABLE ROW LEVEL SECURITY; + + +-- ============================================================================= +-- SECTION 2: DROP RLS POLICIES +-- ============================================================================= +-- Note: Tenant isolation policy removed in Option B. Only user-based policy exists. + +DROP POLICY IF EXISTS sessions_user_isolation ON sessions; + + +-- ============================================================================= +-- SECTION 3: DROP TRIGGERS +-- ============================================================================= +-- The updated model does NOT include updated_at → no update trigger exists. +-- Still kept for safety in case older deployments had it. + +DROP TRIGGER IF EXISTS update_sessions_updated_at ON sessions; + + +-- ============================================================================= +-- SECTION 4: DROP INDEXES +-- ============================================================================= +-- Explicit drops included even though DROP TABLE will remove all dependent indexes. + DROP INDEX IF EXISTS idx_sessions_user_id; - --- Drop table -DROP TABLE IF EXISTS sessions; +DROP INDEX IF EXISTS idx_sessions_active; +DROP INDEX IF EXISTS idx_sessions_expires_at; +DROP INDEX IF EXISTS idx_sessions_ip_address; +DROP INDEX IF EXISTS idx_sessions_last_used; +-- ============================================================================= +-- SECTION 5: DROP TABLE +-- ============================================================================= +-- CASCADE ensures removal of dependencies (FKs, RLS metadata, etc.) + +DROP TABLE IF EXISTS sessions CASCADE; +-- ============================================================================= +-- SECTION 6: VERIFICATION BLOCK +-- ============================================================================= + +DO $$ +DECLARE + table_exists BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'sessions' + ) INTO table_exists; + + IF table_exists THEN + RAISE WARNING 'WARNING: sessions table still exists after rollback!'; + ELSE + RAISE NOTICE 'SUCCESS: sessions table removed completely'; + END IF; +END $$; +-- ============================================================================= +-- END OF ROLLBACK MIGRATION 000002_add_sessions.down.sql +-- ============================================================================= diff --git a/database/migrations/000002_sessions.up.sql b/database/migrations/000002_sessions.up.sql index af85738..51bd15a 100644 --- a/database/migrations/000002_sessions.up.sql +++ b/database/migrations/000002_sessions.up.sql @@ -1,54 +1,172 @@ --- ========================================== --- SESSIONS TABLE --- Purpose: Store refresh tokens for JWT authentication --- ========================================== -CREATE TABLE IF NOT EXISTS sessions ( - -- Primary key +-- ============================================================================= +-- AURGANIZE V6.2 - SESSIONS TABLE (USER-ISOLATED, NO MULTI-TENANCY) +-- ============================================================================= +-- Migration: 000002_add_sessions +-- Description: Creates sessions table for JWT refresh token lifecycle +-- Author: Aurganize Team +-- Date: 2025-12-11 +-- Version: 2.1 (Aligned to Go Model, Tenant-less RLS Edition) +-- ============================================================================= +-- This migration creates the sessions table exactly matching Go models.Session. +-- Multi-tenant isolation is intentionally removed (no tenant_id column). +-- RLS still protects session rows, ensuring users cannot see other users’ data. +-- ============================================================================= + + +-- ============================================================================= +-- SECTION 1: SESSIONS TABLE +-- ============================================================================= + +CREATE TABLE sessions ( + -- ====================================================================== + -- IDENTITY COLUMNS + -- ====================================================================== id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- User reference + -- Purpose: Unique identifier for a session + -- Example: "a3bb189e-8bf9-4558-93c9-62cd9c8b9e5e" + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Token details - refresh_token VARCHAR(500) NOT NULL UNIQUE, - refresh_token_hash VARCHAR(255) NOT NULL, -- bcrypt hash of token - - -- Device/Client information + -- Purpose: Maps each session to a specific user + -- Cascade delete ensures all user sessions are removed when user is deleted + + -- ====================================================================== + -- AUTHENTICATION TOKENS + -- ====================================================================== + refresh_token_hash TEXT NOT NULL, + -- Purpose: Stores bcrypt/SHA256 hash of refresh token (never plaintext) + -- Security: Hash-only approach protects against DB compromise + + -- ====================================================================== + -- DEVICE + CLIENT METADATA + -- ====================================================================== user_agent TEXT, + -- Purpose: Browser/device fingerprinting for security and activity display + ip_address INET, - device_name VARCHAR(255), - device_type VARCHAR(50), -- 'web', 'mobile', 'desktop' - - -- Expiry + -- Purpose: Track login-origin IP for anomaly detection + + device_name TEXT, + -- Purpose: Optional user-friendly device label (e.g., "John's iPhone") + + device_type TEXT NOT NULL DEFAULT 'unknown', + -- Purpose: Categorize device ("mobile", "desktop", "web", "unknown") + + -- ====================================================================== + -- SESSION LIFECYCLE + -- ====================================================================== expires_at TIMESTAMPTZ NOT NULL, - - -- Status - is_revoked BOOLEAN DEFAULT FALSE, - revoked_at TIMESTAMPTZ, - revoked_reason VARCHAR(255), - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - last_used_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - - -- Constraints - CONSTRAINT chk_device_type CHECK (device_type IN ('web', 'mobile', 'desktop', 'unknown')) + -- Purpose: Refresh token expiry timestamp + + is_revoked BOOLEAN NOT NULL DEFAULT FALSE, + -- Purpose: Marks a session as invalidated due to logout/security rules + + revoked_at TIMESTAMPTZ NULL, + -- Purpose: Timestamp of revocation; NULL means active session + + revoked_reason TEXT, + -- Purpose: Optional context ("logout", "password_change", "admin_action") + + -- ====================================================================== + -- AUDIT TIMESTAMPS + -- ====================================================================== + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Purpose: Login timestamp (never updated) + + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Purpose: Updated on every refresh token usage ); --- Indexes for performance -CREATE INDEX idx_sessions_user_id ON sessions(user_id) WHERE NOT is_revoked; -CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token) WHERE NOT is_revoked; -CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); -CREATE INDEX idx_sessions_is_revoked ON sessions(is_revoked); +-- ============================================================================= +-- SECTION 2: INDEXES FOR PERFORMANCE & SECURITY +-- ============================================================================= --- Index for cleanup queries (expired sessions) --- Note: Cannot use NOW() in partial index - it's not IMMUTABLE --- The application will filter expires_at < NOW() at query time -CREATE INDEX idx_sessions_expired_cleanup ON sessions(expires_at, is_revoked) - WHERE NOT is_revoked; +-- Fast retrieval of all sessions for a user +CREATE INDEX idx_sessions_user_id ON sessions(user_id); --- Comments -COMMENT ON TABLE sessions IS 'JWT refresh token storage with device tracking'; -COMMENT ON COLUMN sessions.refresh_token IS 'Plain refresh token (indexed for lookup)'; -COMMENT ON COLUMN sessions.refresh_token_hash IS 'Bcrypt hash of refresh token (for verification)'; -COMMENT ON COLUMN sessions.is_revoked IS 'Manually revoked sessions (logout)'; \ No newline at end of file +-- Lookup active sessions quickly +CREATE INDEX idx_sessions_active + ON sessions(user_id, is_revoked) + WHERE is_revoked = FALSE; + +-- Cleanup expired sessions +CREATE INDEX idx_sessions_expires_at + ON sessions(expires_at) + WHERE is_revoked = FALSE; + +-- IP anomaly investigations +CREATE INDEX idx_sessions_ip_address + ON sessions(ip_address) + WHERE is_revoked = FALSE; + +-- Idle session detection +CREATE INDEX idx_sessions_last_used + ON sessions(last_used_at) + WHERE is_revoked = FALSE; + + +-- ============================================================================= +-- SECTION 3: AUTOMATIC TRIGGERS +-- ============================================================================= +-- Note: updated_at column removed because the Go model does not include it. +-- No trigger required. + + +-- ============================================================================= +-- SECTION 4: ROW LEVEL SECURITY (RLS) +-- ============================================================================= + +-- Enable row-level isolation +ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; + +-- User-based isolation policy +CREATE POLICY sessions_user_isolation ON sessions + FOR ALL + USING ( + user_id = current_setting('app.current_user_id', true)::UUID + ) + WITH CHECK ( + user_id = current_setting('app.current_user_id', true)::UUID + ); + +COMMENT ON POLICY sessions_user_isolation ON sessions IS + 'Restricts all session operations to the authenticated user (no tenant-level RLS).'; + + +-- ============================================================================= +-- SECTION 5: TABLE CONSTRAINTS & VALIDATION +-- ============================================================================= + +-- Expiry must occur after creation +ALTER TABLE sessions ADD CONSTRAINT sessions_valid_expiry + CHECK (expires_at > created_at); + +-- Revoked_at cannot be before created_at +ALTER TABLE sessions ADD CONSTRAINT sessions_valid_revocation + CHECK (revoked_at IS NULL OR revoked_at >= created_at); + + +-- ============================================================================= +-- SECTION 6: COMMENTS FOR DOCUMENTATION +-- ============================================================================= + +COMMENT ON TABLE sessions IS + 'User authentication sessions with refresh token hashes. Exact match to Go models.Session (tenant-less).'; + +COMMENT ON COLUMN sessions.id IS 'Unique session ID (UUID v4).'; +COMMENT ON COLUMN sessions.user_id IS 'User who owns this session.'; +COMMENT ON COLUMN sessions.refresh_token_hash IS 'Hash of refresh token (never store plaintext).'; +COMMENT ON COLUMN sessions.user_agent IS 'Client user-agent string.'; +COMMENT ON COLUMN sessions.ip_address IS 'IP address at session creation.'; +COMMENT ON COLUMN sessions.device_name IS 'Optional user-friendly device name.'; +COMMENT ON COLUMN sessions.device_type IS 'Device category: mobile/desktop/web.'; +COMMENT ON COLUMN sessions.expires_at IS 'Refresh token expiration timestamp.'; +COMMENT ON COLUMN sessions.is_revoked IS 'TRUE when session has been explicitly revoked.'; +COMMENT ON COLUMN sessions.revoked_at IS 'Timestamp of revocation event.'; +COMMENT ON COLUMN sessions.revoked_reason IS 'Reason for revocation.'; +COMMENT ON COLUMN sessions.created_at IS 'Timestamp when session was created.'; +COMMENT ON COLUMN sessions.last_used_at IS 'Timestamp of last refresh token usage.'; + + +-- ============================================================================= +-- END OF MIGRATION 000002_add_sessions.up.sql +-- ============================================================================= diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index 1ac6ecd..632582c 100644 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -94,38 +94,38 @@ services: dockerfile: Dockerfile.dev container_name: aurganize-backend restart: unless-stopped - environment: - # Application config - ENV: development - PORT: 8080 + # environment: + # # Application config + # ENV: development + # PORT: 8080 - # Database connection - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: aurganize - DB_PASSWORD: dev_password_change_in_prod - DB_NAME: aurganize - DB_SSL_MODE: disable + # # Database connection + # DB_HOST: postgres + # DB_PORT: 5432 + # DB_USER: aurganize + # DB_PASSWORD: dev_password_change_in_prod + # DB_NAME: aurganize + # DB_SSL_MODE: disable - # Redis connection - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: "" + # # Redis connection + # REDIS_HOST: redis + # REDIS_PORT: 6379 + # REDIS_PASSWORD: "" - # NATS connection - NATS_URL: nats://nats:4222 + # # NATS connection + # NATS_URL: nats://nats:4222 - # MinIO connection - MINIO_ENDPOINT: minio:9000 - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin - MINIO_USE_SSL: false + # # MinIO connection + # MINIO_ENDPOINT: minio:9000 + # MINIO_ACCESS_KEY: minioadmin + # MINIO_SECRET_KEY: minioadmin + # MINIO_USE_SSL: false - # JWT secrets (development only) - JWT_SECRET: dev-secret-change-in-production + # # JWT secrets (development only) + # JWT_SECRET: dev-secret-change-in-production - # CORS settings - CORS_ORIGINS: http://localhost:3000 + # # CORS settings + # CORS_ORIGINS: http://localhost:3000 ports: - "8080:8080" volumes: diff --git a/infrastructure/docker/init-scripts/06-grant-bypassrls.sql b/infrastructure/docker/init-scripts/06-grant-bypassrls.sql new file mode 100644 index 0000000..1440c6c --- /dev/null +++ b/infrastructure/docker/init-scripts/06-grant-bypassrls.sql @@ -0,0 +1,111 @@ +-- ========================================== +-- 06: GRANT BYPASSRLS TO BACKEND USER +-- ========================================== +-- This script grants Row-Level Security bypass privilege +-- Required for registration flow (tenant + user creation in same transaction) +-- Runs as: postgres (superuser) + +\echo '🔓 Granting BYPASSRLS privilege...' + +-- ========================================== +-- WHY BYPASSRLS IS NECESSARY +-- ========================================== +-- During registration, we create tenant and user in a single transaction: +-- +-- BEGIN; +-- INSERT INTO tenants (...) VALUES (...); -- Creates tenant +-- INSERT INTO users (tenant_id, ...) VALUES (...); -- References tenant +-- COMMIT; +-- +-- PROBLEM WITHOUT BYPASSRLS: +-- - PostgreSQL validates foreign key (users.tenant_id → tenants.id) +-- - Foreign key check runs: SELECT 1 FROM tenants WHERE id = ? +-- - RLS policy blocks this SELECT (no tenant context during registration) +-- - Foreign key check fails: "violates foreign key constraint" +-- - Transaction rolls back +-- +-- SOLUTION WITH BYPASSRLS: +-- - Backend user can see ALL rows during registration +-- - Foreign key check succeeds (tenant visible immediately) +-- - Transaction commits successfully +-- - Regular operations still protected by RLS (when app.current_tenant_id is set) +-- ========================================== + +-- Grant BYPASSRLS to backend API user +ALTER USER aurganize_backend_api WITH BYPASSRLS; + +\echo ' ✅ BYPASSRLS privilege granted to aurganize_backend_api' + +-- ========================================== +-- VERIFY PRIVILEGE GRANTED +-- ========================================== +DO $$ +DECLARE + has_bypassrls BOOLEAN; + user_privileges TEXT; +BEGIN + -- Check if BYPASSRLS was granted + SELECT rolbypassrls INTO has_bypassrls + FROM pg_roles + WHERE rolname = 'aurganize_backend_api'; + + IF has_bypassrls THEN + RAISE NOTICE ' ✅ Verification: BYPASSRLS is active'; + ELSE + RAISE WARNING ' ❌ Verification: BYPASSRLS not active!'; + RAISE EXCEPTION 'Failed to grant BYPASSRLS privilege'; + END IF; + + -- Build privilege summary + SELECT CASE + WHEN rolsuper THEN '🔴 SUPERUSER' + WHEN rolbypassrls THEN '🟡 RLS BYPASS' + ELSE '🟢 STANDARD' + END INTO user_privileges + FROM pg_roles + WHERE rolname = 'aurganize_backend_api'; + + RAISE NOTICE ' 🔐 Privilege Level: %', user_privileges; +END $$; + +-- ========================================== +-- DISPLAY FINAL USER CONFIGURATION +-- ========================================== +\echo '' +\echo '📋 Final user configuration:' + +SELECT + rolname AS "Username", + rolcanlogin AS "Can Login", + rolsuper AS "Superuser", + rolbypassrls AS "Bypass RLS", + rolconnlimit AS "Conn Limit", + CASE + WHEN rolsuper THEN '🔴 Full Access' + WHEN rolbypassrls THEN '🟡 RLS Bypass (for registration)' + ELSE '🟢 Standard (RLS enforced)' + END AS "Access Level" +FROM pg_roles +WHERE rolname = 'aurganize_backend_api'; + +-- ========================================== +-- SECURITY NOTES +-- ========================================== +\echo '' +\echo '==========================================' +\echo '🔒 SECURITY NOTES' +\echo '==========================================' +\echo '' +\echo '✅ SAFE USAGE:' +\echo ' - Backend sets app.current_tenant_id for regular operations' +\echo ' - RLS still protects all normal CRUD operations' +\echo ' - Only registration flow runs without tenant context' +\echo ' - Audit logs capture all operations' +\echo '' +\echo '⚠️ IMPORTANT:' +\echo ' - BYPASSRLS only needed for registration endpoint' +\echo ' - All other operations MUST set tenant context' +\echo ' - Readonly user does NOT have BYPASSRLS' +\echo '' +\echo '✅ BYPASSRLS configuration complete!' +\echo '' \ No newline at end of file