A propos Compétences Expérience Services Blog Contact

Mets la barrière entre tes tenants dans Postgres, pas dans ton code Go Put the wall between tenants in Postgres, not in your Go code

La sécurité multi-tenant ne doit pas dépendre de ton code Go. Un seul if oublié, et un client voit les données d'un autre. Postgres sait poser la barrière à ta place, avec la Row-Level Security. Tu actives une policy par table, tu remplis le tenant à chaque connexion, et la base filtre toute seule. Voici comment je l'ai câblé en Go avec pgx. Plus les deux pièges qui m'ont coûté du temps. Et comment je le prouve avec des tests.

Pour qui : les devs Go qui construisent un SaaS multi-tenant sur Postgres. Ceux qui veulent une isolation qui tient, même quand le code a un bug.

Le contexte

Un tenant, c'est un client de ton application. Dans un SaaS multi-tenant, plusieurs clients partagent la même base de données. Leurs données vivent dans les mêmes tables, séparées par une colonne tenant_id.

Le risque est simple. Si une requête oublie son filtre WHERE tenant_id = ..., un client lit les données d'un autre. En santé, en finance, ou avec des données perso, c'est une fuite grave.

J'ai construit ce pattern sur mon propre produit, un outil de gestion locative avec un conseiller juridique IA. J'avais effleuré la RLS dans mon article sur la sécurité des agents LLM. Ici, c'est le traitement complet.

La RLS en une minute

La Row-Level Security, ou RLS, est une fonction de Postgres. Elle décide, ligne par ligne, ce qu'une requête a le droit de lire et d'écrire. Tu l'actives par table, avec une policy.

Une policy a deux parties. USING filtre les lignes lues. WITH CHECK bloque les écritures qui cassent la règle. Voici la table conversations avec son isolation par tenant :

ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations FORCE  ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON conversations
    FOR ALL
    USING      (tenant_id = current_setting('app.current_tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

current_setting('app.current_tenant_id') lit une variable de session. Ton code la remplit avec le tenant de l'utilisateur. Ensuite, Postgres compare chaque ligne à cette valeur. Une requête ne voit que ses lignes. Les autres n'existent pas pour elle.

FORCE ROW LEVEL SECURITY ajoute une garantie. Sans lui, le propriétaire de la table ignore la RLS. Avec lui, même le propriétaire obéit à la policy.

Rien de ce SQL n'est spécifique à Go. Les policies et la variable de session sont les mêmes que ton backend soit en Python, Node, Rails ou Java. Ce qui change d'un langage à l'autre, c'est la façon de remplir le tenant à chaque requête. Et c'est là que le pooling de connexions se complique. Je te montre cette partie en Go, avec pgx.

Deux rôles Postgres, pas un

La RLS ne sert à rien si ton application peut la désactiver. Alors je crée deux rôles Postgres. Un rôle, c'est un utilisateur de la base.

Le premier, app_role, sert à toutes les requêtes des utilisateurs. Il est soumis à la RLS.

Le second, system_role, a l'attribut BYPASSRLS. Il ignore la RLS. Je le garde pour les migrations, les tâches d'admin, et les rares requêtes qui doivent voir plusieurs tenants.

CREATE ROLE app_role    WITH LOGIN PASSWORD '...';
CREATE ROLE system_role WITH LOGIN PASSWORD '...' BYPASSRLS;

La règle est simple. Le code qui répond aux utilisateurs passe toujours par app_role. system_role reste pour le travail interne, jamais pour une requête déclenchée par un utilisateur. Deux pools de connexions séparés, un par rôle.

Remplis le tenant à chaque connexion

La variable app.current_tenant_id doit être posée avant chaque requête utilisateur. La poser à la main dans chaque fonction, c'est reproduire le problème du if oublié.

Alors je le fais une seule fois, dans le pool de connexions. pgx est le driver Postgres pour Go. Son pool sait exécuter une fonction sur chaque connexion avant de te la prêter.

poolCfg.PrepareConn = func(ctx context.Context, conn *pgx.Conn) (bool, error) {
    tenantID := middleware.GetTenantID(ctx)
    if tenantID == "" {
        return true, nil
    }
    _, err := conn.Exec(ctx,
        "SELECT set_config('app.current_tenant_id', $1, false)", tenantID)
    if err != nil {
        return false, err
    }
    return true, nil
}

poolCfg.AfterRelease = func(conn *pgx.Conn) bool {
    _, _ = conn.Exec(context.Background(), "RESET app.current_tenant_id")
    return true
}

PrepareConn s'exécute quand le pool sort une connexion. Il lit le tenant depuis le contexte de la requête, puis remplit la variable. Dans les vieilles versions de pgx, le hook s'appelle BeforeAcquire.

AfterRelease s'exécute quand la connexion revient. Il fait RESET. Ce détail est vital. Une connexion est réutilisée par d'autres requêtes. Sans RESET, elle garde le tenant du dernier utilisateur. La requête suivante en hériterait. Ce serait une fuite.

Le tenant vient du contexte. Moi, je le mets là depuis le JWT de l'utilisateur. Un JWT est un jeton signé qui prouve qui est l'utilisateur. Un middleware vérifie ce jeton, lit le tenant, et le range dans le contexte.

Premier piège : la RLS échoue en silence

La RLS ne lève pas d'erreur quand elle bloque. Elle rend les lignes invisibles. C'est le comportement voulu, mais il surprend.

Un exemple. Le tenant B tente de modifier une ligne du tenant A. La RLS ne refuse pas. L'UPDATE touche zéro ligne. Aucune erreur. Ton code Go croit que la mise à jour a réussi.

Pareil pour un DELETE cross-tenant : zéro ligne, aucune erreur. Et lire la ligne d'un autre tenant renvoie « rien trouvé », pas « interdit ».

Deux conséquences pour ton code. Un, ne prends jamais l'absence d'erreur pour une réussite sur une écriture. Vérifie le nombre de lignes touchées quand l'opération doit changer quelque chose. Deux, traite « zéro ligne » comme un « not found » propre, pas comme un bug.

Second piège : oublier le tenant

Que se passe-t-il si la variable n'est jamais posée ? La policy ne peut pas s'évaluer. La requête échoue.

C'est un bon défaut. Pas de tenant, pas de données. La RLS échoue fermée, jamais ouverte. Une variable manquante bloque tout, au lieu de tout montrer.

Je double cette garantie côté application. Un middleware refuse toute requête sans tenant, avec un code 403. La base est la barrière finale. Le middleware est la première.

La récursion infinie, erreur 42P17

Voici le bug qui m'a coûté le plus de temps. Il arrive quand deux tables se protègent l'une l'autre.

Ma table conversations avait une policy qui regardait conversation_participants. Et conversation_participants avait une policy qui regardait conversations. Chaque policy déclenchait l'autre. Postgres bouclait, puis rendait l'erreur SQLSTATE 42P17 : récursion infinie dans une policy.

La solution tient en deux mots : SECURITY DEFINER. Je sors la logique d'accès dans une fonction. Cette fonction vérifie l'accès une seule fois, et s'exécute avec les droits de son créateur. Elle ignore donc la RLS à l'intérieur. Plus de boucle.

CREATE FUNCTION user_can_access_conversation(conv_id uuid, uid uuid)
RETURNS boolean AS $$
    SELECT EXISTS (
        SELECT 1 FROM conversations
        WHERE id = conv_id AND owner_id = uid
    ) OR EXISTS (
        SELECT 1 FROM conversation_participants
        WHERE conversation_id = conv_id AND user_id = uid
    );
$$ LANGUAGE sql SECURITY DEFINER STABLE;

CREATE POLICY conversations_access ON conversations
    USING (user_can_access_conversation(id, current_setting('app.current_tenant_id')::uuid));

Une règle est née de ce bug. Une policy ne doit pas interroger une autre table dont la policy pointe, elle aussi, vers la première. Si tu as besoin de cette logique croisée, passe par une fonction SECURITY DEFINER.

Prouve l'isolation avec des tests

La RLS est une règle de sécurité. Une règle de sécurité non testée n'existe pas. Alors j'écris des tests d'intégration. Ils lancent un vrai Postgres dans un conteneur, avec les vraies policies.

Le test pose le tenant A, puis tente de lire les données du tenant B. Il doit recevoir zéro ligne. Il tente une écriture cross-tenant. Elle doit être bloquée.

// Le tenant A ne voit que ses propres lignes.
require.NoError(t, db.SetTenant(ctx, tenantA))
repo := NewConversationRepo(db.TenantPool)

got, err := repo.List(ctx)
require.NoError(t, err)
require.Len(t, got, 1)            // seulement les lignes du tenant A

// Le tenant A ne peut pas lire une conversation du tenant B.
_, err = repo.Get(ctx, tenantBConversationID)
require.Error(t, err)            // not found

// Une écriture avec le mauvais tenant est bloquée par WITH CHECK.
_, err = repo.Create(ctx, &Conversation{TenantID: tenantB})
require.Error(t, err)

Je teste les quatre opérations : lecture, création, mise à jour, suppression. Et je vérifie au niveau SQL brut, avec un count(*), que chaque tenant ne voit que ses lignes. Ces tests tournent à chaque commit, dans la CI.

La checklist avant la prod

Avant de mettre un SaaS multi-tenant en production sur Postgres, coche toutes ces cases.

  • Chaque table multi-tenant a ENABLE et FORCE ROW LEVEL SECURITY
  • Chaque policy a un USING pour la lecture et un WITH CHECK pour l'écriture
  • Deux rôles : un rôle applicatif sous RLS, un rôle système en BYPASSRLS
  • Le code qui répond aux utilisateurs passe toujours par le rôle applicatif
  • Le tenant est posé dans le hook du pool, et remis à zéro au retour de la connexion
  • Le tenant vient du JWT, jamais d'un paramètre de la requête
  • Un middleware refuse toute requête sans tenant, avec un 403
  • Ton code traite « zéro ligne » comme un « not found », pas comme un succès d'écriture
  • Aucune policy n'interroge une autre table dont la policy pointe vers elle
  • La logique d'accès croisée passe par une fonction SECURITY DEFINER
  • Des tests d'intégration prouvent l'isolation en lecture et en écriture

Ce qu'il faut retenir

Le filtrage par tenant dans le code Go marche, jusqu'au jour où un if manque. La RLS déplace la barrière dans la base. Là, aucun bug de code ne peut la contourner.

Le coût, c'est deux rôles, un hook de pool, et quelques pièges à connaître : l'échec silencieux, la variable oubliée, la récursion 42P17. Une fois ces pièges passés, tu as une isolation qui tient toute seule.

Tu construis un SaaS multi-tenant en Go et tu veux un second regard sur ton isolation ? C'est exactement ce que je fais. Écris-moi.

Multi-tenant security should not depend on your Go code. One missing if, and a client sees another client's data. Postgres can hold the wall for you, with Row-Level Security. You enable one policy per table, set the tenant on every connection, and the database filters on its own. Here is how I wired it in Go with pgx, the two traps that cost me time, and how I prove it with tests.

Who this is for: Go developers building a multi-tenant SaaS on Postgres who want isolation that holds, even when the code has a bug.

The setup

A tenant is a customer of your application. In a multi-tenant SaaS, many customers share one database. Their data lives in the same tables, split by a tenant_id column.

The risk is simple. If one query forgets its WHERE tenant_id = ... filter, a customer reads another customer's rows. In healthcare, finance, or with personal data, that is a serious leak.

I built this pattern on my own product, a rental-management tool with an AI legal advisor. I touched on RLS in my article on securing LLM agents. This is the full version.

RLS in one minute

Row-Level Security, or RLS, is a Postgres feature. It decides, row by row, what a query may read and write. You turn it on per table, with a policy.

A policy has two parts. USING filters the rows you read. WITH CHECK blocks writes that break the rule. Here is the conversations table with tenant isolation:

ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations FORCE  ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON conversations
    FOR ALL
    USING      (tenant_id = current_setting('app.current_tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);

current_setting('app.current_tenant_id') reads a session variable. Your code fills it with the user's tenant. Then Postgres compares every row to that value. A query sees only its rows. The others do not exist for it.

FORCE ROW LEVEL SECURITY adds one guarantee. Without it, the table owner skips RLS. With it, even the owner obeys the policy.

None of this SQL is specific to Go. The policies and the session variable are the same whether your backend is Python, Node, Rails, or Java. What changes from one language to the next is how you set the tenant on each request. And that is where connection pooling gets tricky. I show that part in Go, with pgx.

Two Postgres roles, not one

RLS is useless if your application can turn it off. So I create two Postgres roles. A role is a database user.

The first, app_role, runs every user query. It is subject to RLS.

The second, system_role, has the BYPASSRLS attribute. It ignores RLS. I keep it for migrations, admin tasks, and the rare queries that must see several tenants.

CREATE ROLE app_role    WITH LOGIN PASSWORD '...';
CREATE ROLE system_role WITH LOGIN PASSWORD '...' BYPASSRLS;

The rule is simple. Code that answers users always goes through app_role. system_role stays for internal work, never for a user-triggered query. Two separate connection pools, one per role.

Set the tenant on every connection

The app.current_tenant_id variable must be set before every user query. Setting it by hand in each function repeats the missing-if problem.

So I do it once, in the connection pool. pgx is the Postgres driver for Go. Its pool can run a function on each connection before it hands it to you.

poolCfg.PrepareConn = func(ctx context.Context, conn *pgx.Conn) (bool, error) {
    tenantID := middleware.GetTenantID(ctx)
    if tenantID == "" {
        return true, nil
    }
    _, err := conn.Exec(ctx,
        "SELECT set_config('app.current_tenant_id', $1, false)", tenantID)
    if err != nil {
        return false, err
    }
    return true, nil
}

poolCfg.AfterRelease = func(conn *pgx.Conn) bool {
    _, _ = conn.Exec(context.Background(), "RESET app.current_tenant_id")
    return true
}

PrepareConn runs when the pool takes a connection out. It reads the tenant from the request context, then fills the variable. In older pgx versions, the hook is named BeforeAcquire.

AfterRelease runs when the connection comes back. It calls RESET. This detail is vital. A connection is reused by other requests. Without RESET, it keeps the last user's tenant. The next request would inherit it. That would be a leak.

The tenant comes from the context. I put it there from the user's JWT. A JWT is a signed token that proves who the user is. A middleware checks the token, reads the tenant, and stores it in the context.

First trap: RLS fails silently

RLS raises no error when it blocks. It makes rows invisible. That is the intended behavior, but it surprises people.

An example. Tenant B tries to update a tenant A row. RLS does not refuse. The UPDATE touches zero rows. No error. Your Go code thinks the update worked.

Same for a cross-tenant DELETE: zero rows, no error. And reading another tenant's row returns "nothing found", not "forbidden".

Two consequences for your code. One, never read a missing error as success on a write. Check the affected-row count when an operation must change something. Two, treat "zero rows" as a clean "not found", not as a bug.

Second trap: forgetting the tenant

What happens if the variable is never set? The policy cannot evaluate. The query fails.

That is a good default. No tenant, no data. RLS fails closed, never open. A missing variable blocks everything instead of showing everything.

I double this guarantee in the application. A middleware rejects any request without a tenant, with a 403. The database is the last wall. The middleware is the first.

Infinite recursion, error 42P17

Here is the bug that cost me the most time. It happens when two tables protect each other.

My conversations table had a policy that looked at conversation_participants. And conversation_participants had a policy that looked at conversations. Each policy triggered the other. Postgres looped, then returned SQLSTATE 42P17: infinite recursion in a policy.

The fix is two words: SECURITY DEFINER. I move the access logic into a function. The function checks access once, and runs with its creator's rights. So it ignores RLS inside. No more loop.

CREATE FUNCTION user_can_access_conversation(conv_id uuid, uid uuid)
RETURNS boolean AS $$
    SELECT EXISTS (
        SELECT 1 FROM conversations
        WHERE id = conv_id AND owner_id = uid
    ) OR EXISTS (
        SELECT 1 FROM conversation_participants
        WHERE conversation_id = conv_id AND user_id = uid
    );
$$ LANGUAGE sql SECURITY DEFINER STABLE;

CREATE POLICY conversations_access ON conversations
    USING (user_can_access_conversation(id, current_setting('app.current_tenant_id')::uuid));

A rule was born from that bug. A policy must not query another table whose own policy points back to the first. If you need that cross-logic, route it through a SECURITY DEFINER function.

Prove isolation with tests

RLS is a security rule. An untested security rule does not exist. So I write integration tests. They start a real Postgres in a container, with the real policies.

The test sets tenant A, then tries to read tenant B's data. It must get zero rows. It tries a cross-tenant write. It must be blocked.

// Tenant A sees only its own rows.
require.NoError(t, db.SetTenant(ctx, tenantA))
repo := NewConversationRepo(db.TenantPool)

got, err := repo.List(ctx)
require.NoError(t, err)
require.Len(t, got, 1)            // only tenant A rows

// Tenant A cannot read a tenant B conversation.
_, err = repo.Get(ctx, tenantBConversationID)
require.Error(t, err)            // not found

// A write with the wrong tenant is blocked by WITH CHECK.
_, err = repo.Create(ctx, &Conversation{TenantID: tenantB})
require.Error(t, err)

I test all four operations: read, create, update, delete. And I check at the raw SQL level, with a count(*), that each tenant sees only its rows. These tests run on every commit, in CI.

The checklist before production

Before you ship a multi-tenant SaaS on Postgres, check every box.

  • Every multi-tenant table has ENABLE and FORCE ROW LEVEL SECURITY
  • Every policy has a USING for reads and a WITH CHECK for writes
  • Two roles: an app role under RLS, a system role with BYPASSRLS
  • User-facing code always goes through the app role
  • The tenant is set in the pool hook, and reset when the connection returns
  • The tenant comes from the JWT, never from a request parameter
  • A middleware rejects any request with no tenant, with a 403
  • Your code treats "zero rows" as a not-found, not as a successful write
  • No policy queries another table whose policy points back to it
  • Cross-table access logic goes through a SECURITY DEFINER function
  • Integration tests prove isolation for reads and writes

What to remember

Filtering by tenant in Go code works, until the day an if is missing. RLS moves the wall into the database. There, no code bug can get around it.

The cost is two roles, one pool hook, and a few traps to know: the silent failure, the forgotten variable, the 42P17 recursion. Once past them, you have isolation that holds on its own.

Building a multi-tenant SaaS in Go and want a second pair of eyes on your isolation? That is exactly what I do. Write to me.