feat: multi tenant support
This commit is contained in:
parent
a660c591ff
commit
30e3391adb
17 changed files with 271 additions and 145 deletions
|
|
@ -13,6 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
)
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
auth service.IAuthService
|
||||
jobs service.IJobService
|
||||
questions service.IQuestionService
|
||||
|
|
@ -36,6 +38,7 @@ type Handler struct {
|
|||
}
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
auth service.IAuthService,
|
||||
jobs service.IJobService,
|
||||
questions service.IQuestionService,
|
||||
|
|
@ -47,6 +50,7 @@ func New(
|
|||
frontendURL string,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
auth: auth, jobs: jobs, questions: questions, participants: participants,
|
||||
invitations: invitations, maxUpload: maxUploadMB << 20,
|
||||
users: users, interviews: interviews,
|
||||
|
|
@ -139,6 +143,10 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value))
|
||||
}
|
||||
|
||||
func (h *Handler) Db() *gorm.DB {
|
||||
return h.db
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) {
|
||||
var request webmodel.JobStatusRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
|
|
|
|||
73
backend/internal/web/middleware/TenantSchemaMiddleware.go
Normal file
73
backend/internal/web/middleware/TenantSchemaMiddleware.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const TxKey contextKey = "tenant_db_tx"
|
||||
|
||||
// Alphanumeric or underscores only to prevent SQL Injection in schema names
|
||||
var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
|
||||
|
||||
// TenantSchemaMiddleware isolates database connections per request using PostgreSQL schemas
|
||||
func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.Header.Get("X-Tenant-Id")
|
||||
if tenantID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error": "Missing X-Tenant-Id header"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate schema name to guarantee safety against SQL injection
|
||||
if !safeSchemaRegex.MatchString(tenantID) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error": "Invalid characters in tenant ID"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Start a fresh transaction scoped exclusively to this HTTP request
|
||||
tx := db.Begin()
|
||||
|
||||
// Set the local search_path for the duration of this transaction block
|
||||
if err := tx.Exec(fmt.Sprintf("SET LOCAL search_path TO %s", tenantID)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error": "Failed to resolve tenant storage isolation"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Embed the transaction database instance into the request context
|
||||
ctx := context.WithValue(r.Context(), TxKey, tx)
|
||||
|
||||
// Execute the subsequent request handlers
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
// Commit or Rollback based on the transaction error status
|
||||
if tx.Error != nil {
|
||||
tx.Rollback()
|
||||
} else {
|
||||
tx.Commit()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetDB retrieves the transaction-bound tenant GORM DB instance from context
|
||||
func GetDB(ctx context.Context) *gorm.DB {
|
||||
if tx, ok := ctx.Value(TxKey).(*gorm.DB); ok {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
panic("GetDB was invoked outside of TenantSchemaMiddleware")
|
||||
}
|
||||
|
|
@ -4,18 +4,21 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/security"
|
||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||
mw "github.com/danyel/go-guess/backend/internal/web/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
||||
router.Use(cors(frontendURL))
|
||||
router.Use(mw.TenantSchemaMiddleware(h.Db()))
|
||||
router.Use(middleware.Timeout(2 * time.Minute))
|
||||
router.Get("/api/health", h.Health)
|
||||
router.Post("/api/auth/login", h.Login)
|
||||
router.Get("/api/interviews/{token}", h.GetInterview)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue