Multi-Tenant - 04 - Exemplo com RLS

Boa, agora é a parte “gostosa”: deixar o tenant realmente amarrado no Postgres via RLS + SET LOCAL numa camada de infra, sem poluir seu domínio.

Vou te mostrar um padrão bem prático:

  1. RLS no banco usando current_setting('app.tenant_id')

  2. Middleware de transação por request que faz:

    • começa BEGIN
    • executa SET LOCAL app.tenant_id = $1
    • chama o handler
    • COMMIT ou ROLLBACK
  3. Repos adaptados para usar *sqlx.Tx se existir no context, senão usam *sqlx.DB.


1. Lado PostgreSQL – RLS com app.tenant_id

Você já está bem perto disso, a ideia é:

ALTER TABLE public.usuarios ENABLE ROW LEVEL SECURITY;

CREATE POLICY usuarios_tenant_policy
ON public.usuarios
USING (tenant_id = current_settingBIGINT;

Mesma coisa para grupos, usuarios_grupos etc.

Isso faz com que qualquer SELECT/UPDATE/DELETE respeite o tenant_id que estiver definido na sessão/tx.


2. Helper de infra para guardar *sqlx.Tx no context

Cria um pacote tipo pkg/dbctx:

// pkg/dbctx/context.go
package dbctx

import (
	"context"

	"github.com/jmoiron/sqlx"
)

type ctxKey string

const txKey ctxKey = "db_tx"

// Guarda a transação no contexto
func WithTx(ctx context.Context, tx *sqlx.Tx) context.Context {
	return context.WithValue(ctx, txKey, tx)
}

// Recupera a transação do contexto (se existir)
func TxFromContext(ctx context.Context) *sqlx.Tx {
	v := ctx.Value(txKey)
	if v == nil {
		return nil
	}
	tx, _ := v.(*sqlx.Tx)
	return tx
}

3. Interface DBTX e ajuste dos repositórios

No arquivo dos repositórios (internal/identity/repository_sqlx.go), cria uma interface que tanto *sqlx.DB quanto *sqlx.Tx atendem:

// internal/identity/repository_sqlx.go
package identity

import (
	"context"
	"database/sql"

	"fsgo/pkg/dbctx"

	"github.com/jmoiron/sqlx"
)

// Interface comum à DB e Tx
type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	PrepareNamedContext(ctx context.Context, query string) (*sqlx.NamedStmt, error)
	GetContext(ctx context.Context, dest any, query string, args ...any) error
	SelectContext(ctx context.Context, dest any, query string, args ...any) error
}

Agora, em vez de db *sqlx.DB direto, o repo guarda o *sqlx.DB, mas decide em cada método se usa a Tx do contexto ou o DB:

type usuariosRepository struct {
	db *sqlx.DB // conexão principal (pool)
}

func NewUsuariosRepository(db *sqlx.DB) UsuariosRepository {
	return &usuariosRepository{db: db}
}

// helper: escolhe entre Tx do contexto ou DB
func (r *usuariosRepository) runner(ctx context.Context) DBTX {
	if tx := dbctx.TxFromContext(ctx); tx != nil {
		return tx
	}
	return r.db
}

E depois ajusta os métodos para usar runner(ctx):

func (r *usuariosRepository) Create(ctx context.Context, u *Usuario) error {
	db := r.runner(ctx)

	query := `
		INSERT INTO public.usuarios (
			tenant_id, nome, email, senha_hash, ativo
		) VALUES (
			:tenant_id, :nome, :email, :senha_hash, :ativo
		)
		RETURNING id, created_at, updated_at
	`
	stmt, err := db.PrepareNamedContext(ctx, query)
	if err != nil {
		return err
	}
	defer stmt.Close()

	return stmt.QueryRowxContext(ctx, u).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}

func (r *usuariosRepository) GetByID(ctx context.Context, tenantID, id int64) (*Usuario, error) {
	db := r.runner(ctx)

	var u Usuario
	query := `
		SELECT id, tenant_id, nome, email, senha_hash, ativo,
		       ultimo_login_em, created_at, updated_at
		  FROM public.usuarios
		 WHERE tenant_id = $1
		   AND id = $2
	`
	if err := db.GetContext(ctx, &u, query, tenantID, id); err != nil {
		return nil, err
	}
	return &u, nil
}

Mesma lógica você aplica nos outros repositórios (gruposRepository, usuariosGruposRepository), adicionando o runner(ctx) e usando db := r.runner(ctx) no começo de cada método.

Ganho: se tiver Tx no contexto → tudo roda nela; se não tiver → ele usa o pool normal.


4. Middleware de transação + SET LOCAL app.tenant_id

A ideia é ter um middleware global que:

  1. Lê o tenantID (da rota ou do header; abaixo vou usar a rota).

  2. Abre uma Tx.

  3. Faz SET LOCAL app.tenant_id = $1.

  4. Coloca a Tx no context com dbctx.WithTx.

  5. Chama o próximo handler.

  6. COMMIT se não teve erro/pânico, ou ROLLBACK se teve.

Exemplo:

// cmd/api/middleware_dbtx.go
package main

import (
	"log"
	"net/http"
	"strconv"

	"fsgo/pkg/dbctx"
	"fsgo/pkg/httpcontext"

	"github.com/go-chi/chi/v5"
	"github.com/jmoiron/sqlx"
)

func DBTxMiddleware(db *sqlx.DB) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

			// Aqui assumo que o tenantID vem da rota /tenants/{tenantID}
			tenantStr := chi.URLParam(r, "tenantID")
			tenantID, err := strconv.ParseInt(tenantStr, 10, 64)
			if err != nil || tenantID <= 0 {
				http.Error(w, "tenant_id inválido", http.StatusBadRequest)
				return
			}

			// Inicia a transação
			tx, err := db.BeginTxx(r.Context(), nil)
			if err != nil {
				log.Println("erro ao iniciar transação:", err)
				http.Error(w, "erro interno", http.StatusInternalServerError)
				return
			}

			// SET LOCAL app.tenant_id para o RLS
			if _, err := tx.ExecContext(r.Context(), "SET LOCAL app.tenant_id = $1", tenantID); err != nil {
				_ = tx.Rollback()
				log.Println("erro ao fazer SET LOCAL app.tenant_id:", err)
				http.Error(w, "erro interno", http.StatusInternalServerError)
				return
			}

			// Injeta tenantID e Tx no contexto
			ctx := httpcontext.WithTenantID(r.Context(), tenantID)
			ctx = dbctx.WithTx(ctx, tx)

			// Execução do handler
			defer func() {
				if rec := recover(); rec != nil {
					_ = tx.Rollback()
					panic(rec) // repassa o panic depois de rollback
				}
			}()

			// Cria um ResponseWriter que sabe se já deu erro ou não, se quiser sofisticar
			// aqui vou no simples: se chegamos até o fim, commit
			next.ServeHTTP(w, r.WithContext(ctx))

			if err := tx.Commit(); err != nil {
				log.Println("erro ao dar commit:", err)
				// aqui já não dá muito pra devolver erro pro cliente, mas você pode logar/monitorar
			}
		})
	}
}

Observação:


5. main.go ligando tudo

No main.go, você injeta esse middleware no router:

// cmd/api/main.go
package main

import (
	"log"
	"net/http"
	"os"

	"fsgo/internal/identity"

	"github.com/go-chi/chi/v5"
	"github.com/jmoiron/sqlx"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		log.Fatal("DATABASE_URL não configurada")
	}

	db, err := sqlx.Open("pgx", dsn)
	if err != nil {
		log.Fatal("erro ao conectar no banco: ", err)
	}
	defer db.Close()

	if err := db.Ping(); err != nil {
		log.Fatal("erro no ping do banco: ", err)
	}

	usuariosRepo := identity.NewUsuariosRepository(db)
	gruposRepo := identity.NewGruposRepository(db)
	usuariosGruposRepo := identity.NewUsuariosGruposRepository(db)
	svc := identity.NewService(usuariosRepo, gruposRepo, usuariosGruposRepo)
	handler := identity.NewHandler(svc)

	r := chi.NewRouter()

	// Middleware de Tx + SET LOCAL app.tenant_id
	r.Use(DBTxMiddleware(db))

	// Rotas do contexto identity
	handler.Routes(r)

	addr := ":8080"
	log.Println("API rodando em", addr)
	if err := http.ListenAndServe(addr, r); err != nil {
		log.Fatal(err)
	}
}

Repara que agora: