Retour au blog
Choix d’Architecture · 10 min de lecture

RLS et base dédiée par projet : le choix d’isolation multi-tenant d’Aurabase

Affane Daylami · Fondateur· 24 août 2026

Cherchez « RLS multi-tenant postgres » et vous tombez presque partout sur le même schéma : une base partagée, une colonne tenant_id, une policy qui filtre les lignes. Ce n’est pas le modèle qu’Aurabase utilise pour isoler ses projets les uns des autres. Chaque projet reçoit sa propre base Postgres 16, jamais partagée avec un autre client — la RLS reste bien là, mais à un autre étage : dans votre base, pour vos propres utilisateurs.

« RLS » et « multi-tenant » se retrouvent accolés dans la quasi-totalité du contenu déjà publié sur le sujet — un choix légitime pour beaucoup d’architectures SaaS, mais qui n’est pas celui d’Aurabase pour séparer ses propres clients entre eux. Ce post explique la différence avec le code de provisioning réel (services/aura-provisioner) et les policies RLS effectivement posées, pas une description marketing simplifiée. Pour une vue d’ensemble de ce que couvre le moteur Postgres managé d’Aurabase au-delà de l’isolation, voir la documentation base de données.

L’essentiel
  • Entre projets, Aurabase isole par base Postgres dédiée, jamais par RLS seule — chaque projet a sa propre base physique, sur un cluster CNPG dédié (palier entreprise) ou sur le cluster CNPG de sa propre organisation (free/pro/team), jamais partagé avec une autre organisation.
  • La RLS (auth.uid(), auth.role(), auth.jwt()) reste active et recommandée à l’intérieur de votre base, pour isoler vos propres utilisateurs — même convention que Supabase.
  • service_role et les rôles d’administration par projet contournent la RLS par conception (BYPASSRLS) : un choix d’architecture assumé pour les opérations serveur, pas une faille.
  • Une régression déjà corrigée dans ce dépôt — des droits PUBLIC accordés par erreur sur d’anciens schémas partagés — illustre concrètement pourquoi une frontière au niveau base résiste mieux qu’une frontière purement applicative.
#
Le modèle par défaut

Le raccourci que prend la plupart des guides RLS multi-tenant

Le pattern le plus documenté pour du multi-tenant Postgres tient en trois lignes : une seule base, une colonne tenant_id sur chaque table, une policy RLS qui compare cette colonne à une valeur extraite du JWT. C’est économique — un pool de connexions, un schéma, une seule instance à faire tourner — et ça marche bien quand les tenants sont nombreux, petits, et de faible enjeu individuel.

Le compromis est réel : la frontière entre deux clients devient une expression SQL, évaluée table par table. Une policy oubliée sur une nouvelle table, une connexion qui tourne avec un rôle superuser, un script de debug lancé en direct — chacun de ces incidents, pourtant banals en exploitation, peut exposer silencieusement les lignes de tous les tenants en même temps. La frontière de sécurité et la frontière technique (la base) sont alors exactement la même chose.

Info
Ce n’est pas un mauvais choix en soi — c’est le bon compromis pour beaucoup de produits. Le point de ce post est ailleurs : ce n’est pas le compromis qu’Aurabase a fait pour séparer ses propres clients (des projets entiers, potentiellement avec des exigences de conformité différentes) les uns des autres.
#
Dans le code

Deux architectures, jamais une base partagée entre projets

Depuis une consolidation récente du provisioner (repérée dans le code comme « Task 12 »), un projet Postgres actif chez Aurabase relève d’exactement deux architectures — les anciens modèles à base réellement partagée entre plusieurs projets ont été retirés du chemin de provisioning.

services/aura-provisioner/src/provisioning.rs
RUST
pub enum ProjectInstanceKind {
/// Cluster CNPG dédié à CE SEUL projet (palier entreprise).
FullyDedicated,
/// Database sur le cluster CNPG de l'ORGANISATION du projet —
/// partagée avec les AUTRES projets de la MÊME organisation,
/// jamais avec une organisation tierce.
SharedClusterDedicated,
}

Le palier du projet décide laquelle des deux s’applique — et c’est le code qui tranche, pas une case cochée dans un dashboard :

services/aura-provisioner/src/provisioning.rs
RUST
fn should_provision_dedicated(engine: &str, db_url_encrypted: &Option<String>, plan: &str) -> bool {
matches!(engine, "postgres" | "postgresql")
&& plan == "enterprise"
&& !non_empty(db_url_encrypted)
}
// should_route_to_fleet() : tout palier non-entreprise (free/pro/team)
// route vers le cluster CNPG de SA PROPRE organisation, jamais celui
// d'une autre — voir migration 073, consolidation à 2 architectures.
DimensionFullyDedicated (entreprise)SharedClusterDedicated (free/pro/team)
Cluster CNPGDédié à ce seul projetPartagé, mais jamais entre deux organisations
Base Postgresapp, seul projet dessusproject_<uuid>, une par projet sur le cluster
Login PostgreSQLUn seul projet sur le cluster : pas de risque d’appartenance croiséeLogin par-projet (F-013), membre de ses seuls rôles tenant_<uuid>

Sur les deux architectures, la base ou le cluster n’héberge jamais deux organisations différentes — la question n’est donc pas « vos données sont-elles isolées » mais « votre projet a-t-il du compute CloudNativePG pour lui seul, ou le partage-t-il avec d’autres projets de la même organisation ».

Cette consolidation à deux architectures est récente : le code portait auparavant deux voies supplémentaires — un « master partagé » où plusieurs projets cohabitaient dans la même base, isolés uniquement par schéma, et une variante postgrest_dedicated_shared_db. Une migration dédiée les a retirées et resserré la contrainte de la table projects aux deux seules valeurs restantes, précisément parce que le modèle à schémas partagés était la source du bug décrit plus bas.

#
Frontière de sécurité

Pourquoi une base dédiée bat une RLS partagée entre clients

Une base Postgres distincte est une frontière au niveau connexion, pas au niveau ligne. Un rôle applicatif connecté à la base du projet A ne peut tout simplement pas interroger les tables du projet B — il n’a pas de session ouverte dessus. Cette propriété tient même si une policy RLS est mal écrite, absente sur une table, ou contournée par un rôle élevé : le pire cas reste confiné à l’intérieur d’une seule base.

Ce dépôt porte d’ailleurs la trace d’un vrai bug qui illustre le risque inverse. Sous l’ancien modèle à schémas partagés (retiré depuis), provision_postgres_schema accordait par erreur des droits GRANT ALL ... TO PUBLIC sur chaque schéma de projet — PUBLIC s’appliquant à tout rôle de la base sans condition d’appartenance, un login isolé par projet pouvait lire et écrire dans le schéma d’un autre. Une migration correctrice (066) a retiré ces droits sur l’existant.

La leçon retenue
Le correctif n’a pas ajouté une policy RLS de plus pour boucher la fuite — il a supprimé la possibilité même que deux projets partagent une base. Sur les deux architectures actuelles, un commentaire de provisioning.rs le documente noir sur blanc : « chaque projet a déjà sa propre base Postgres physique ». Une frontière au niveau base rend une classe entière de bugs de ce type simplement inatteignable, plutôt que de compter sur le fait que chaque policy soit toujours écrite correctement.

Un correctif du 23 août 2026 va dans le même sens : un REVOKE ALL ON SCHEMA public posé sans condition par le provisioner a été rendu conditionnel à la topologie, parce qu’il n’apportait une isolation réelle que sur l’ancien modèle à base partagée — sur les deux architectures actuelles, il bloquait sans bénéfice l’import de dumps SQL qui référencent explicitement public.<table>.

#
RLS en pratique

La RLS reste là — dans votre base, pour vos utilisateurs

Rien de ce qui précède ne rend la RLS inutile — elle change simplement d’étage. Une fois dans votre base de projet, Aurabase expose exactement la convention PostgREST reprise par Supabase : trois fonctions SQL qui lisent les claims du JWT posés par la gateway dans request.jwt.claims.

libs/aura-migrations/golden/auth_global.sql
SQL
create function auth.uid() returns uuid
language sql stable security definer
set search_path to 'pg_catalog', 'pg_temp'
as $$
select nullif(nullif(current_setting('request.jwt.claims', true), '')::json->>'sub', '')::uuid
$$;

Ces helpers sont utilisés dans les policies réelles d’Aurabase elle-même — pas seulement documentés pour les vôtres. Voici la policy qui protège storage_objects, telle qu’elle est posée dans le dépôt (reformatée sur plusieurs lignes pour la lecture) :

libs/aura-migrations/golden/storage.sql
SQL
alter table storage_objects enable row level security;
create policy storage_objects_select on storage_objects
for select using (
(owner_id = auth.uid())
or (
exists (
select 1 from storage_buckets b
where (b.name = storage_objects.bucket_name and b.public)
)
)
);

Dans votre propre schéma project_<uuid>, celui qui porte vos tables applicatives, Aurabase ne pose délibérément aucune policy à votre place — le code le documente comme « modèle Supabase » : la RLS de vos tables reste de votre responsabilité, avec les mêmes fonctions, la même syntaxe.

#
BYPASSRLS assumé

service_role contourne la RLS — par conception, pas par accident

Postgres propose nativement un attribut de rôle, BYPASSRLS, qui ignore toutes les policies. Aurabase l’utilise volontairement sur deux familles de rôles : aura_service_role (le rôle serveur, jamais exposé côté navigateur) et le rôle d’administration propre à chaque projet, utilisé le temps d’opérations DDL comme ALTER SCHEMA ... OWNER TO.

services/aura-provisioner/src/provisioning.rs
SQL
create role aura_service_role nologin bypassrls;
-- Rôle serveur : n'est jamais exposé côté navigateur, jamais membre
-- des rôles d'un autre projet.

Le rôle qui sert vos requêtes anon/authenticatedtenant_<uuid> — n’a lui aucun BYPASSRLS : la RLS s’y applique normalement, sans exception. En prime, les schémas système propres à chaque projet (_auth, _storage, _platform) reçoivent une RLS activée sans policy — donc un refus total par défaut pour tout rôle non-bypass, une défense en profondeur au cas où un chemin applicatif y accéderait un jour par erreur.

Info
Contourner la RLS avec un rôle serveur élevé n’est pas propre à Aurabase — c’est la même construction que service_role côté Supabase. Le point n’est pas d’éviter BYPASSRLS, c’est de ne jamais l’accorder à un rôle atteignable depuis un client, et de le confiner à un projet unique.

Ce rôle serveur s’inscrit dans une posture plus large — rôles prédéfinis, RBAC custom, audit logs — détaillée sur la page Sécurité & RBAC.

#
Défense en profondeur

Sur un cluster partagé, la base ne fait pas tout le travail seule

Sur le palier SharedClusterDedicated, plusieurs projets d’une même organisation cohabitent sur un cluster CNPG unique. La base physique sépare déjà les projets entre eux, mais les rôles PostgreSQL — eux — sont des objets globaux au cluster, pas à la base. Aurabase ajoute donc une couche : un login PostgreSQL distinct par projet.

libs/aura-core/src/lib.rs
RUST
pub fn project_authenticator_role(project_id: &str) -> String {
format!("project_{}_authenticator", project_id.replace('-', '_'))
}
// Actif par défaut (F013_PER_PROJECT_AUTHENTICATOR), désactivable
// explicitement en échappatoire de secours.

Chaque projet se connecte avec son propre login, membre uniquement de ses propres rôles tenant_<uuid> / tenant_<uuid>_admin — jamais de ceux d’un autre projet du même cluster. La base isole déjà les données ; ce login par projet isole en plus l’identité qui s’y connecte, pour qu’un incident sur un projet ne donne à son login aucune appartenance à hériter vers un autre.

#
Pour votre architecture

RLS seule ou base dédiée : comment trancher pour votre propre SaaS

Le choix d’Aurabase n’est pas une règle universelle — c’est un compromis pour un cas précis : isoler des clients entre eux, potentiellement avec des exigences de conformité différentes, sur une plateforme qu’ils ne contrôlent pas. Si vous construisez votre propre SaaS, la même question se pose à vous, à une échelle différente.

  • RLS avec tenant_id dans une base partagée — pertinent quand vos tenants sont nombreux, individuellement de faible enjeu, et que le coût d’une base par tenant serait disproportionné. Testez chaque policy avec pg_prove, sur chaque table, sans exception.
  • Base ou schéma dédié — pertinent dès qu’un tenant a un enjeu de conformité propre (santé, RH, secteur public), un volume qui justifie l’isolation des performances, ou que le coût d’une fuite entre deux clients spécifiques serait disproportionné face au coût d’infrastructure supplémentaire.

Le palier tarifaire d’Aurabase applique ce même arbitrage à ses propres clients : base partagée par organisation par défaut, cluster dédié quand l’enjeu du projet le justifie. Pour les patterns RLS à l’intérieur de votre propre base — ownership, multi-tenant par organisation, hiérarchie de rôles — le guide RLS en production détaille les trois cas avec des tests pgTAP. Et si l’API auto-générée sur votre schéma vous intéresse au-delà de REST, le comparatif sur pg_graphql face à Hasura et PostGraphile couvre l’autre moitié de la surface Postgres exposée par Aurabase.

#
Questions Fréquentes

FAQ

La RLS seule suffit-elle pour isoler des tenants dans une seule base Postgres ?+
Techniquement oui, si chaque table porte une policy correcte et qu’aucune connexion n’échappe au rôle non-bypass. C’est exactement le risque d’exploitation qu’Aurabase a choisi de ne pas prendre entre des projets différents — chaque erreur de policy resterait confinée à une seule base. À l’intérieur d’un seul projet, pour vos propres tenants, RLS + tenant_id reste un choix légitime et largement utilisé.
Pourquoi les projets gratuits n’ont-ils pas un cluster Postgres dédié comme le palier entreprise ?+
Un cluster CloudNativePG dédié par projet, y compris pour les projets gratuits, multiplierait le compute réservé sans rapport avec l’usage réel de la majorité d’entre eux. Aurabase donne à la place à chaque projet non-entreprise sa propre base Postgres physique sur le cluster CNPG de sa propre organisation — jamais partagé avec une autre organisation — plutôt qu’un schéma dans une base commune à plusieurs clients inconnus les uns des autres.
Comment auth.uid() sait-il qui je suis, techniquement ?+
La gateway Aurabase vérifie votre JWT puis pose ses claims dans le paramètre de session Postgres request.jwt.claims, pour la durée de la transaction. auth.uid() ne fait rien de plus qu’extraire le champ sub de ce JSON et le caster en uuid — sans claims posés (requête anonyme, ou connexion directe hors gateway), current_setting() renvoie NULL et auth.uid() renvoie donc NULL, ce qui ferme l’accès sur une policy en using (owner_id = auth.uid()).
VOIR L’ISOLATION EN PLACE

Créez un projet, inspectez le schéma.

Chaque projet Aurabase démarre avec sa propre base Postgres 16 et les policies RLS système déjà posées.

Créer un projet Guide RLS en production
Aucune carte bancaire requise · 500 MB gratuits · 50 000 MAU