feat(tenant): Add full tenant & user onboarding pipeline with routing + integration
This update adds complete tenant management to the backend: - Added tenant models, repositories, services, and handlers - Added user registration flow that creates tenant + user in a transaction - Added tenant retrieval endpoints (GetTenant, GetMyTenant) - Configured new routes and integrated handlers into SetUpRoutes - Wired repositories, services, and handlers into main.go - Implemented slug generation, validation, and transactional creation logic Includes updates across: - models (tenants) - repositories (tenant_repository) - services (tenant_service) - handlers (tenant_handler, user_handler) - routes and main.go for full system integration
This commit is contained in:
parent
060cf8c78b
commit
9559607fe0
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <token>"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,16 +477,41 @@ 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
|
||||
}
|
||||
|
||||
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.
|
||||
// This is a security feature called "logout everywhere" or "logout all devices".
|
||||
//
|
||||
|
|
@ -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,15 +559,34 @@ 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
|
||||
)
|
||||
|
||||
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.
|
||||
// This is a cleanup/maintenance operation typically run as a scheduled job.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,14 +619,37 @@ 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)
|
||||
ipStr, // $2 - IP address
|
||||
)
|
||||
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.
|
||||
// This is used for:
|
||||
|
|
@ -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,15 +718,41 @@ 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
|
||||
)
|
||||
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.
|
||||
// This is the core of password-based authentication.
|
||||
//
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 <<EOF
|
||||
{
|
||||
"email": "$TEST_EMAIL",
|
||||
"password": "$TEST_PASSWORD",
|
||||
"first_name": "$TEST_FIRST_NAME",
|
||||
"last_name": "$TEST_LAST_NAME",
|
||||
"tenant_name": "$TEST_TENANT_NAME"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Request: POST /api/v1/auth/register"
|
||||
echo "Payload:"
|
||||
echo "$REGISTER_PAYLOAD" | jq '.' 2>/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 <<EOF
|
||||
{
|
||||
"email": "weak-$(date +%s)@example.com",
|
||||
"password": "weak",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"tenant_name": "Test Tenant"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Request: POST /api/v1/auth/register"
|
||||
echo "Password: 'weak' (should fail validation)"
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$WEAK_PASSWORD_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" = "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 <<EOF
|
||||
{
|
||||
"email": "$UNIQUE_EMAIL",
|
||||
"password": "$TEST_PASSWORD",
|
||||
"first_name": "Rate",
|
||||
"last_name": "Test$i",
|
||||
"tenant_name": "Rate Test $i"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RATE_LIMIT_PAYLOAD")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
|
||||
echo "Request $i: HTTP $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "429" ]; then
|
||||
print_success "Rate limit triggered on request $i"
|
||||
RATE_LIMITED=true
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [ "$RATE_LIMITED" = false ]; then
|
||||
print_warning "Rate limit not triggered (check rate limiter middleware)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Waiting 60 seconds for rate limit to reset..."
|
||||
sleep 5 # Shortened for testing, normally would be 60
|
||||
|
||||
# ==============================================================================
|
||||
# TEST 6: User Login
|
||||
# ==============================================================================
|
||||
print_test "6. User Login"
|
||||
|
||||
LOGIN_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"email": "$TEST_EMAIL",
|
||||
"password": "$TEST_PASSWORD"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Request: POST /api/v1/auth/login"
|
||||
echo "Credentials: $TEST_EMAIL / $TEST_PASSWORD"
|
||||
echo ""
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$LOGIN_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" = "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 <<EOF
|
||||
{
|
||||
"email": "$TEST_EMAIL",
|
||||
"password": "WrongPassword123!"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Request: POST /api/v1/auth/login"
|
||||
echo "Using wrong password..."
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$WRONG_PASSWORD_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" = "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 "=========================================="
|
||||
|
|
@ -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";
|
||||
-- =============================================================================
|
||||
-- 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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,76 +1,166 @@
|
|||
-- =============================================================================
|
||||
-- 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 (
|
||||
-- Identity
|
||||
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,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
|
||||
-- Metadata
|
||||
-- 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 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)
|
||||
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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -78,65 +168,93 @@ CREATE TABLE users (
|
|||
email VARCHAR(255) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
|
||||
-- Profile
|
||||
name VARCHAR(255) 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',
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
-- 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,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
is_onboarded BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Metadata
|
||||
-- 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 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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Parties
|
||||
-- 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,
|
||||
|
||||
-- Details
|
||||
-- Contract details
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status contract_status NOT NULL DEFAULT 'draft',
|
||||
|
||||
-- Dates
|
||||
-- Timeline
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
|
||||
-- Financial
|
||||
-- Financial terms
|
||||
total_amount NUMERIC(12,2) NOT NULL DEFAULT 0.00,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
||||
|
||||
-- Version control (optimistic locking)
|
||||
-- Concurrency control
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Metadata
|
||||
-- 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(),
|
||||
|
|
@ -148,43 +266,46 @@ CREATE TABLE contracts (
|
|||
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 (
|
||||
-- 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,
|
||||
|
||||
-- Details
|
||||
-- Deliverable details
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
sequence_number INTEGER NOT NULL,
|
||||
status deliverable_status NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Dates
|
||||
-- Timeline
|
||||
deadline DATE NOT NULL,
|
||||
submitted_at TIMESTAMPTZ,
|
||||
approved_at TIMESTAMPTZ,
|
||||
|
||||
-- Submission
|
||||
-- Workflow tracking
|
||||
submitted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
|
@ -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 (
|
||||
-- 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,
|
||||
|
||||
-- Details
|
||||
-- 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',
|
||||
|
||||
-- Tracking
|
||||
-- Completion tracking
|
||||
completed_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_amount CHECK (amount >= 0)
|
||||
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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Polymorphic relation
|
||||
-- Polymorphic relationship
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
-- Content
|
||||
-- Comment content
|
||||
content TEXT NOT NULL,
|
||||
|
||||
-- Author
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
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'))
|
||||
CONSTRAINT valid_comment_entity_type CHECK (
|
||||
entity_type IN ('contract', 'deliverable', 'milestone')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
|
|
@ -275,20 +399,18 @@ 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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Polymorphic relation
|
||||
-- Polymorphic relationship
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
|
|
@ -296,24 +418,28 @@ CREATE TABLE attachments (
|
|||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100) NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
object_name TEXT NOT NULL,
|
||||
object_name TEXT NOT NULL, -- S3/MinIO object key
|
||||
|
||||
-- Status
|
||||
-- Status tracking
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- Tracking
|
||||
-- Upload tracking
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
uploaded_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Audit fields
|
||||
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'))
|
||||
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,19 +448,14 @@ 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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -343,14 +464,14 @@ CREATE TABLE audit_logs (
|
|||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
|
||||
-- Actor
|
||||
-- Actor information
|
||||
actor_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Changes
|
||||
-- Change tracking
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
|
||||
-- Context
|
||||
-- Security context
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
|
||||
|
|
@ -366,15 +487,14 @@ 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 (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
|
|
@ -400,33 +520,33 @@ 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 (
|
||||
-- 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,
|
||||
|
||||
-- Content
|
||||
-- Notification content
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
|
||||
-- Related entity
|
||||
-- Related entity (optional)
|
||||
entity_type VARCHAR(50),
|
||||
entity_id UUID,
|
||||
|
||||
-- Status
|
||||
-- Read status
|
||||
read_at TIMESTAMPTZ,
|
||||
|
||||
-- Metadata
|
||||
-- Timestamp
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
|
@ -437,14 +557,16 @@ 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
|
||||
|
|
@ -475,11 +597,32 @@ CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments
|
|||
CREATE TRIGGER update_attachments_updated_at BEFORE UPDATE ON attachments
|
||||
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
|
||||
-- =============================================================================
|
||||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
-- Purpose: Unique identifier for a session
|
||||
-- Example: "a3bb189e-8bf9-4558-93c9-62cd9c8b9e5e"
|
||||
|
||||
-- User reference
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Purpose: Maps each session to a specific user
|
||||
-- Cascade delete ensures all user sessions are removed when user is deleted
|
||||
|
||||
-- Token details
|
||||
refresh_token VARCHAR(500) NOT NULL UNIQUE,
|
||||
refresh_token_hash VARCHAR(255) NOT NULL, -- bcrypt hash of token
|
||||
-- ======================================================================
|
||||
-- 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 information
|
||||
-- ======================================================================
|
||||
-- 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'
|
||||
-- Purpose: Track login-origin IP for anomaly detection
|
||||
|
||||
-- Expiry
|
||||
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,
|
||||
-- Purpose: Refresh token expiry timestamp
|
||||
|
||||
-- Status
|
||||
is_revoked BOOLEAN DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_reason VARCHAR(255),
|
||||
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Purpose: Marks a session as invalidated due to logout/security rules
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
last_used_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NULL,
|
||||
-- Purpose: Timestamp of revocation; NULL means active session
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT chk_device_type CHECK (device_type IN ('web', 'mobile', 'desktop', 'unknown'))
|
||||
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)';
|
||||
-- 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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
Loading…
Reference in New Issue