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:
-
RLS no banco usando
current_setting('app.tenant_id') -
Middleware de transação por request que faz:
- começa
BEGIN - executa
SET LOCAL app.tenant_id = $1 - chama o handler
COMMITouROLLBACK
- começa
-
Repos adaptados para usar
*sqlx.Txse existir nocontext, 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:
-
Lê o
tenantID(da rota ou do header; abaixo vou usar a rota). -
Abre uma
Tx. -
Faz
SET LOCAL app.tenant_id = $1. -
Coloca a Tx no
contextcomdbctx.WithTx. -
Chama o próximo handler.
-
Dá
COMMITse não teve erro/pânico, ouROLLBACKse 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:
Se você quiser algo mais refinado (commit só se não houve erro 5xx, por exemplo), pode fazer um
ResponseWritercustomizado que marca o status.Mas só esse padrão já resolve
SET LOCAL+ Tx por request na maioria dos casos.
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:
-
Toda requisição para
/tenants/{tenantID}/...:-
roda dentro de uma transação;
-
tem
app.tenant_idsetado na sessão da transação; -
tem
tenant_ide*sqlx.Txdisponíveis nocontext.
-
-
Os repositórios:
- não sabem de Tx diretamente; só usam
runner(ctx)que pega Tx se existir.
- não sabem de Tx diretamente; só usam
-
O Postgres, com RLS:
- bloqueia qualquer acesso a dados de outro tenant, mesmo se alguém esquecer o
WHERE tenant_idna query.
- bloqueia qualquer acesso a dados de outro tenant, mesmo se alguém esquecer o