Règles de sécurité (RLS)
Vue d’ensemble
Section intitulée « Vue d’ensemble »Les règles de sécurité vous permettent de définir des stratégies de sécurité au niveau des lignes (Row Level Security - RLS) pour vos tables PostgreSQL directement dans vos définitions de collections. Lorsque le schéma Drizzle est généré, Rebase crée les instructions CREATE POLICY correspondantes.
import { defineCollection } from "@rebasepro/cms-types";const postsCollection = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { /* ... */ }, securityRules: [ { operation: "select", access: "public" }, { operations: ["insert", "update", "delete"], ownerField: "authorId" } ]});Fonctionnement
Section intitulée « Fonctionnement »- Vous définissez des
securityRulessur une collection rebase schema generatecrée le schéma Drizzle avec le RLS activérebase db pushourebase db migrateapplique les stratégies à PostgreSQL- Chaque requête est filtrée automatiquement selon le contexte de l’utilisateur actuel
L’identité de l’utilisateur authentifié est accessible en SQL via :
| Fonction | Retourne |
|---|---|
rebase.uid() |
L’ID de l’utilisateur actuel |
rebase.roles() |
Les ID de rôles de l’application séparés par des virgules |
rebase.jwt() |
L’ensemble des revendications (claims) JWT sous forme de JSONB |
Ces valeurs sont définies automatiquement par transaction par le backend Rebase.
Les politiques qu’une collection reçoit sans les écrire
Section intitulée « Les politiques qu’une collection reçoit sans les écrire »Les politiques d’une table sont plus que les securityRules propres à la collection :
defaultSecurityRules, exporté depuisconfig/collections/index.ts. Une collection dontsecurityRulesest absent ou vide les reçoit toutes à sa place, comme si elle les avait écrites. Une collection qui déclare ne serait-ce qu’une règle à elle n’en reçoit aucune : les deux listes ne sont jamais fusionnées. Elles sont appliquées partout où le répertoire est lu :rebase dev,rebase db push, le panneau d’administration et un backend auquel on passecollectionsDir. Depuis 0.25 L’index du scaffold les applique aussi au tableaucollectionsqu’il exporte, de sorte qu’un backend auquel on remet ce tableau les reçoit également. Un tableau assemblé de toute autre façon ne porte que les règles que déclarent ses collections.- La base administrateur. Chaque collection reçoit aussi
<table>_default_admin_readet<table>_default_admin_write_insert,_updateet_delete: des politiques permissives qui laissent le contexte serveur et le rôleadminlire et écrire chaque ligne. Elles s’ajoutent aux règles que la collection finit par avoir, les siennes ou les valeurs par défaut héritées, et les politiques permissives sont combinées par OR : elles élargissent donc les deux. La collection des utilisateurs reçoit aussi une lecture de soi-même et un verrou d’écriture restrictif qui réserverolesaux administrateurs. - Multi-tenance. Une collection limitée à un tenant reçoit une règle restrictive qui confine chaque opération au tenant de l’appelant ; le contexte serveur la franchit.
disableDefaultPolicies: true sur une collection retire la base permissive, et conserve les verrous restrictifs. C’est par collection : defaultSecurityRules ne porte aucun indicateur de ce genre, si bien qu’une collection qui hérite des valeurs par défaut conserve la base à moins de définir elle-même l’indicateur. Le DDL généré marque chaque politique injectée, et rebase db push remet celle qu’on a supprimée à la main.
Raccourcis pratiques
Section intitulée « Raccourcis pratiques »Accès basé sur le propriétaire
Section intitulée « Accès basé sur le propriétaire »Le modèle le plus simple — les utilisateurs ne peuvent accéder qu’aux lignes dont ils sont propriétaires :
securityRules: [ { operation: "all", ownerField: "user_id" }]Cela génère : USING ((user_id)::text = (SELECT rebase.uid())) — voir
Évalué une fois par instruction pour le SELECT.
Accès public
Section intitulée « Accès public »Permettre à quiconque (y compris les utilisateurs non authentifiés) de lire :
securityRules: [ { operation: "select", access: "public" }]Cela génère : USING (true)
Accès authentifié
Section intitulée « Accès authentifié »Autoriser tout utilisateur connecté. Il s’agit d’une condition plutôt que d’un raccourci d’access — access n’a qu’une seule valeur possible, "public" — car “connecté” est un test appliqué à l’appelant, et c’est dans le builder que résident les tests concernant l’appelant :
import { policy } from "@rebasepro/types";
securityRules: [ { operation: "select", condition: policy.authenticated() }]policy.authenticated() est également vrai pour une connexion anonyme, qui crée une véritable ligne d’utilisateur et une véritable session. Utilisez policy.registered() lorsqu’un invité ne doit pas être admissible — rédiger un avis, rejoindre une organisation, effectuer un paiement.
Accès basé sur les rôles
Section intitulée « Accès basé sur les rôles »Restreindre les opérations à des rôles spécifiques :
securityRules: [ { operation: "all", roles: ["admin"] }, { operation: "select", roles: ["editor", "viewer"] }]Accès par appartenance / relationnel
Section intitulée « Accès par appartenance / relationnel »Pour restreindre l’accès en fonction de l’appartenance à une collection associée — par exemple, “uniquement les lignes dont l’appelant fait partie de l’équipe” — utilisez la condition structurée avec policy.existsIn. Elle est compilée en une sous-requête EXISTS corrélée unique (sans recherche par ligne) et constitue l’alternative sécurisée et native à l’écriture manuelle du SQL brut présenté plus bas.
import { policy } from "@rebasepro/types";
// documents visible only to members of the document's team:securityRules: [ { operation: "select", condition: policy.existsIn({ collection: "team_members", // the join / membership collection where: policy.and( // correlate to the row being checked: policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")), // …and to the caller: policy.compare(policy.field("user_id"), "eq", policy.authUid()), ), }), },]À l’intérieur de where, policy.field(...) fait référence à une colonne de la collection jointe (team_members), tandis que policy.outerField(...) fait référence à une colonne de la ligne en cours de vérification (documents). Combinez ceci avec policy.authUid() pour restreindre la portée à l’utilisateur actuel. Comme cette règle est appliquée par la base de données, l’interface d’administration la considère comme faisant autorité côté serveur.
Le générateur policy, au complet
Section intitulée « Le générateur policy, au complet »Importé depuis @rebasepro/types. Les expressions se composent ; les opérandes sont les feuilles.
| Expression | Se compile en |
|---|---|
policy.true() / policy.false() |
true / false |
policy.and(…) / policy.or(…) |
conjonction / disjonction |
policy.not(e) |
négation |
policy.compare(left, op, right) |
une comparaison entre deux opérandes |
policy.rolesOverlap(roles) |
l’appelant possède au moins un de ces rôles applicatifs |
policy.rolesContain(roles) |
l’appelant possède tous ces rôles applicatifs |
policy.authenticated() |
connecté — rebase.uid() est défini et n’est pas une sentinelle anonyme. IS NOT NULL seul serait une tautologie, car une requête anonyme définit une sentinelle au lieu de la laisser indéfinie |
policy.registered() |
connecté avec un compte — authenticated() et non un invité. Voir ci-dessous |
policy.serverContext() |
rebase.uid() IS NULL — voir l’avertissement ci-dessous |
policy.existsIn({ collection, where }) |
une sous-requête EXISTS corrélée |
policy.raw(sql) |
une trappe de sortie, insérée textuellement |
| Opérande | Signification |
|---|---|
policy.field(name) |
une colonne de la collection en cours de vérification — ou, dans existsIn, de la collection jointe |
policy.outerField(name) |
dans existsIn, une colonne de la ligne externe |
policy.literal(value) |
une chaîne, un nombre, un booléen ou null |
policy.authUid() |
rebase.uid() |
policy.authRoles() |
rebase.roles() |
Évalué une fois par instruction
Section intitulée « Évalué une fois par instruction »Toute partie d’une règle qui ne lit pas la ligne — l’id de l’appelant, une vérification de rôles, une
revendication (claim), serverContext() — se compile à l’intérieur d’une sous-requête scalaire. Une règle de
propriétaire devient (user_id)::text = (SELECT rebase.uid()), et la lecture admin par défaut devient
(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])).
Postgres calcule une telle sous-requête une fois par instruction (un initPlan) plutôt qu’une fois par ligne, et planifie comme si la vérification laissait passer la moitié de la table plutôt que 1,5 % de celle-ci. C’est cette estimation qui maintient une page profonde d’une grande table sur sa clé primaire : sans elle, la page 401 d’un million de lignes était un parcours séquentiel (sequential scan) et un tri de 640 Mo. Qui peut lire ou écrire quoi reste inchangé.
Le SQL brut de using / withCheck reçoit le même traitement lorsque Rebase peut en lire la structure
(owner_id = rebase.uid() le peut). Tout le reste est inséré tel quel : enveloppez donc vous-même les
appels : (SELECT rebase.uid()).
authenticated() et registered()
Section intitulée « authenticated() et registered() »Deux situations distinctes sont qualifiées d’anonymes, et il convient d’être précis sur ce qu’une règle cible.
Une requête non authentifiée ne transporte aucune session. On lui attribue un identifiant sentinelle afin que rebase.uid() ne soit jamais NULL sur le parcours utilisateur, et policy.authenticated() l’exclut — c’est ce qui lui donne la signification “connecté” plutôt que “n’importe qui”.
Un invité est l’autre cas : une session sans utilisateur réel derrière elle. POST /auth/anonymous génère une vraie ligne d’utilisateur avec un vrai uid, de sorte qu’un invité passe tous les tests basés sur l’identifiant. C’est l’objectif même de cette fonctionnalité — un panier avant le paiement, un brouillon avant l’inscription — et cela signifie que authenticated() est vrai pour quiconque a cliqué sur Continuer en tant qu’invité, ce qui ne requiert aucun e-mail, aucun mot de passe et aucune acceptation de conditions.
policy.registered() équivaut à authenticated() plus “n’est pas un invité”. Utilisez-le dès qu’une règle concerne une personne pouvant être tenue responsable de quelque chose : rédiger un avis, rejoindre une organisation, dépenser de l’argent. Utilisez authenticated() lorsqu’un invité est véritablement le bienvenu.
// Anyone with a session, guests included — a draft cart.{ operation: "insert", check: policy.authenticated() }
// Someone with an account.{ operation: "insert", check: policy.registered() }En coulisses, l’indicateur invité voyage avec la session — il se trouve dans le jeton d’accès et parvient à la base de données sous la forme rebase.is_anonymous() — ce qui permet à une stratégie de poser la question sans requête supplémentaire. Une base de données gérée par un serveur trop ancien pour le définir interprète chaque session comme un compte, ce qui correspond au comportement que ce déploiement avait déjà.
Expressions SQL brutes
Section intitulée « Expressions SQL brutes »Pour une logique complexe, utilisez using et withCheck :
securityRules: [ { operation: "select", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" }]using— Filtre les lignes existantes qui sont visibles (s’applique à SELECT, UPDATE, DELETE)withCheck— Valide les valeurs des nouvelles lignes (s’applique à INSERT, UPDATE)
Les références de colonnes utilisent la syntaxe {column_name}, qui est résolue en nom de colonne qualifié complet avec la table.
Combiner raccourcis et SQL
Section intitulée « Combiner raccourcis et SQL »Mélangez les raccourcis pratiques avec du SQL brut :
securityRules: [ // Admins can do anything { operation: "all", roles: ["admin"], using: "true" }, // Regular users can only see their own rows { operation: "select", ownerField: "user_id" }, // Users can insert, but only for themselves { operation: "insert", withCheck: "{user_id} = rebase.uid()" }, // Locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false" }]Permissif vs Restrictif
Section intitulée « Permissif vs Restrictif »PostgreSQL propose deux modes de stratégie :
- Permissif (par défaut) — Plusieurs stratégies permissives sont combinées avec un opérateur OU (OR). Si au moins une est validée, l’accès est accordé.
- Restrictif — Les stratégies restrictives sont combinées avec un opérateur ET (AND). Toutes doivent être validées.
securityRules: [ // Permissive: owners can access their rows { operation: "all", ownerField: "user_id" }, // Restrictive: but locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false", withCheck: "{is_locked} = false" }]Opérations
Section intitulée « Opérations »| Opération | Équivalent SQL | Description |
|---|---|---|
"select" |
SELECT |
Lire des lignes |
"insert" |
INSERT |
Créer de nouvelles lignes |
"update" |
UPDATE |
Modifier des lignes existantes |
"delete" |
DELETE |
Supprimer des lignes |
"all" |
Tous les éléments ci-dessus | Raccourci pour toutes les opérations |
Vous pouvez également utiliser operations (au pluriel) pour appliquer une règle à plusieurs opérations :
{ operations: ["insert", "update", "delete"], ownerField: "authorId" }Interface SecurityRule complète
Section intitulée « Interface SecurityRule complète »SecurityRule est une union, pas un objet ouvert unique : une règle choisit exactement une façon d’exprimer son prédicat, et les autres sont typées à never afin que les mélanger produise une erreur de compilation plutôt qu’une stratégie qui ignorerait silencieusement la moitié de ce que vous avez écrit.
// Shared by every variantinterface SecurityRuleBase { name?: string; // Policy name. Omit it and one is derived operation?: SecurityOperation; // "select" | "insert" | "update" | "delete" | "all" operations?: SecurityOperation[]; // …or several at once mode?: "permissive" | "restrictive"; // Default: "permissive" roles?: string[]; // App roles, via rebase.roles() pgRoles?: string[]; // Native Postgres roles — the CREATE POLICY `TO` clause. // NOT the same as `roles`. Default: ["public"]}
// …plus exactly one of:{ ownerField: string } // <column> = rebase.uid(){ access: "public" } // the one shortcut — "no row filter"{ condition: PolicyExpression; // the structured builder — `policy.*` check?: PolicyExpression } // defaults to `condition`, as Postgres does{ using?: string; withCheck?: string } // raw SQLroles et pgRoles sont les deux propriétés qui prêtent à confusion. roles est un rôle applicatif, appliqué à l’intérieur de la clause USING / WITH CHECK via rebase.roles(). pgRoles est un rôle de base de données, et contrôle à quelles connexions la stratégie est rattachée. La quasi-totalité des projets ont besoin de roles.
Exemples
Section intitulée « Exemples »Plateforme de blog
Section intitulée « Plateforme de blog »securityRules: [ // Anyone can read published posts { operation: "select", using: "{status} = 'published'" }, // Authors can see their own drafts { operation: "select", ownerField: "authorId" }, // Authors can create and edit their own posts { operations: ["insert", "update"], ownerField: "authorId" }, // Only admins can delete { operation: "delete", roles: ["admin"] }]SaaS multi-tenant
Section intitulée « SaaS multi-tenant »securityRules: [ { operation: "all", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" }]Accès anonyme (insertions publiques)
Section intitulée « Accès anonyme (insertions publiques) »Un besoin courant consiste à autoriser les utilisateurs non authentifiés à soumettre des données — formulaires de contact, inscriptions à des newsletters, candidatures publiques. Rebase fournit un modèle clair pour cela.
Recommandé : une règle withCheck brute
Section intitulée « Recommandé : une règle withCheck brute »import { defineCollection } from "@rebasepro/cms-types";
const contactMessagesCollection = defineCollection({ slug: "contact_messages", name: "Contact Messages", table: "contact_messages", securityRules: [ // Anyone can submit a contact message { operation: "insert", // A raw rule carries `using` (which rows are visible) and `withCheck` // (what a write must satisfy); an insert only exercises the latter. using: "true", withCheck: "true" }, // Only admins can read, update, or delete messages { operations: ["select", "update", "delete"], roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } }});Le raccourci access: "public" génère une stratégie qui autorise l’opération sans nécessiter d’authentification.
Pour la capture de leads / inscriptions
Section intitulée « Pour la capture de leads / inscriptions »import { defineCollection } from "@rebasepro/cms-types";
const leadSignupsCollection = defineCollection({ slug: "lead_magnet_signups", name: "Lead Magnet Signups", table: "lead_magnet_signups", securityRules: [ // Allow anonymous inserts { operation: "insert", using: "true", withCheck: "true" }, // Admins can view all signups { operation: "select", roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } }});Comment fonctionnent les requêtes anonymes
Section intitulée « Comment fonctionnent les requêtes anonymes »Lorsqu’une requête arrive sans jeton JWT, le backend Rebase définit les variables de session PostgreSQL comme suit :
| Variable | Valeur |
|---|---|
app.user_id |
'anonymous' |
app.user_roles |
'' (vide) |
Cela signifie que :
rebase.uid()retourne'anonymous'rebase.roles()retourne une chaîne vide- Les stratégies
access: "public"passent car elles génèrentUSING (true)/WITH CHECK (true) - Les conditions
policy.authenticated()échouent car elles recherchent un ID d’utilisateur réel - Les stratégies
ownerFieldéchouent car aucune ligne n’aurauser_id = 'anonymous'(sauf si explicitement défini)
Avancé : SQL brut pour les accès anonymes
Section intitulée « Avancé : SQL brut pour les accès anonymes »Si vous avez besoin d’un contrôle plus granulaire, utilisez du SQL brut :
securityRules: [ { operation: "insert", withCheck: "rebase.uid() = 'anonymous' OR rebase.uid() IS NOT NULL" }]Lignes d’un côté, champs de l’autre
Section intitulée « Lignes d’un côté, champs de l’autre »Les règles de sécurité répondent à une seule question : à quelles lignes cet appelant a-t-il accès. Elles sont appliquées par Postgres lui-même, sur chaque instruction, quelle que soit la route — c’est pourquoi elles constituent le modèle d’autorisation et que tout ce qui se trouve au-dessus n’est que commodité.
Elles ne déterminent rien concernant les colonnes d’une ligne à laquelle un appelant a accès. Une stratégie qui permet à un employé de lire les lignes de son équipe lui permet de lire chaque champ de ces lignes, salaire inclus. C’est précisément le rôle de access par propriété :
salary: { type: "number", // Everyone the rules above let read the row; only HR gets this column. access: { read: ["hr"], write: [] }}Les deux se cumulent et ne se contredisent jamais : une règle de champ ne peut pas élargir l’accès aux lignes, et une ligne que vous ne pouvez pas lire ne contient aucun champ accessible. Les rôles sont identiques — rebase.roles() dans une stratégie, user.roles sur la requête — ainsi, rolesOverlap(['hr']) dans une règle et access: { read: ["hr"] } sur une propriété font référence au même rôle hr. Les règles de champ sont appliquées par le serveur plutôt que par Postgres, de sorte qu’elles couvrent la surface de l’API ; une requête exécutée via rebase.sql() voit toutes les colonnes, tout comme elle contourne le RLS.
Étapes suivantes
Section intitulée « Étapes suivantes »- Accès aux champs — Rôles de lecture/écriture par champ
- Relations — Clés étrangères et jointures
- Callbacks d’entité — Hooks de cycle de vie
- Fonctions personnalisées — Points de terminaison d’API personnalisés