Aller au contenu

Modèle de sécurité

Chaque requête passe par la même gouvernance, quel que soit le langage dans lequel elle est écrite (GraphQL, SQL, Cypher) et la voie par laquelle elle arrive (REST, gRPC, Arrow Flight, JDBC, WebSocket). Il n'existe aucun chemin qui la contourne.

Les contrôles s'appliquent dans l'ordre. Une requête doit passer chacun avant que le suivant ne soit examiné.

Modèle en couches

Couche 0 — Filtrage de l'introspection

Un rôle ne voit que les tables que son domain_access atteint et les colonnes que ses règles visible_to autorisent. Le reste n'existe pas pour ce rôle : impossible de l'interroger, de le compléter automatiquement ou de le deviner. Le schéma GraphQL, le catalogue SQL et le navigateur de schéma de l'éditeur de requêtes montrent tous la même vue restreinte.

Voir Visibilité du schéma.

Couche 1 — Accès public

Une colonne publiée avec la portée public est servie à chaque rôle de l'organisation, même à un rôle dont le domain_access n'atteint pas le domaine de la table. Ce rôle voit la table avec ses colonnes publiques et rien d'autre. Rien n'est public tant que la personne qui a enregistré la table ne l'a pas décidé. Voir Colonnes publiques.

Couche 2 — Accès par domaine

Chaque rôle a une liste domain_access d'identifiants de domaine, et une requête qui touche une table d'un autre domaine est refusée avant de s'exécuter. C'est la frontière grossière de propriété. Un rôle RH n'atteint pas les tables financières, quelle que soit la façon dont le SQL est écrit.

Voir Modèle de droits.

Couche 3 — Sécurité au niveau des lignes

Une fois l'accès par domaine établi, le filtre de lignes du rôle pour chaque table est ajouté à chaque SELECT sous forme de prédicat WHERE, évalué sur les données brutes. Un responsable régional qui lance SELECT * sur la table partagée des commandes ne récupère toujours que les lignes de sa région.

Voir Sécurité au niveau des lignes (RLS).

Couche 4 — Visibilité et masquage des colonnes

Une colonne dont la liste visible_to exclut votre rôle disparaît du résultat. Une colonne masquée voit ses valeurs remplacées avant qu'elles ne quittent le serveur, par une expression régulière, une constante ou une troncature. Cela vaut dans chaque langage de requête et chaque format de sortie.

Voir Modèle de permissions des colonnes et Masquage des colonnes.

Couche 5 — Garde des prédicats

Vous ne pouvez pas filtrer sur une colonne masquée : WHERE et HAVING la refusent dès l'analyse de la requête. Sinon, un appelant pourrait retrouver la vraie valeur par recherche dichotomique, filtre après filtre, alors que chaque résultat qu'il a vu était masqué.

Gouvernance des relations (V002)

Une jointure SQL doit suivre une relation enregistrée et approuvée, et toute autre jointure est refusée. Chaque relation porte une raison et une description qui expliquent aux personnes et aux agents pourquoi ce chemin existe. Voyez-la comme une politique de gouvernance plutôt que comme une frontière de sécurité. Les couches 2 à 5 tiennent quelles que soient les jointures, si bien que contourner la garde n'expose rien que le rôle ne puisse lire avec deux requêtes séparées. Les tentatives de contournement sont journalisées.

Il y a deux façons de passer V002. La première est une capacité : un rôle qui détient ignore_relationships peut joindre des relations que le catalogue ne couvre pas. Parmi les rôles fournis, seul modeler la détient, parce que son travail consiste à établir le modèle ; analyst ne l'a pas.

La seconde exige deux choses à la fois :

  1. Drapeau du rôle : relationship_guard: false dans la définition du rôle (la valeur par défaut est true).
  2. Désactivation par requête : le commentaire --relationship-guard=false dans le SQL.

L'une sans l'autre ne fait rien.

Le mode haute sécurité verrouille la garde. Avec security.mode: high, aucune des deux voies ne fonctionne. ignore_relationships et relationship_guard: false sont tous deux ignorés, et chaque jointure doit figurer dans le catalogue approuvé. La redondance est voulue : un rôle de production qui a reçu la capacité par erreur ne peut toujours pas sortir du modèle.

GraphQL ignore V002. Ses relations viennent du modèle, elles sont donc déjà approuvées.

SQL et Cypher appliquent V002 par défaut, avec la désactivation à deux conditions décrite plus haut.

pgwire applique le même contrôle que SQL. Le commentaire --relationship-guard=false est retiré avant que la requête n'atteigne la base de données.


Les couches s'additionnent. Un rôle avec un accès par domaine, des filtres de lignes et des colonnes masquées a tout cela en vigueur en même temps. Ajouter une source, une colonne ou une relation n'oblige pas à toucher chaque règle, car chaque couche se configure à part et s'applique à toute requête qui atteint ce qu'elle gouverne.


Modèle de droits

Capacités attribuées indépendamment, avec une hiérarchie de rôles facultative via parent_role_id. admin accorde tout.

Capacité Description
source_registration Enregistrer des sources de données
table_registration Enregistrer des tables et des colonnes
create_relationship Définir des relations FK
access_config Configurer la RLS et le masquage
query_development Exécuter des requêtes
write Invoquer les mutations enregistrées (contrôle grossier ; voir Autorisation des mutations)
full_results Contourner les limites d'échantillonnage
ignore_relationships Contourner la gouvernance des relations (V002). Détenue par modeler seul parmi les rôles système, et entièrement ignorée en mode haute sécurité
admin Superutilisateur — accorde tout

Héritage des rôles

Les rôles peuvent hériter d'un rôle parent via parent_role_id, défini dans la configuration ou sur la page Sécurité comme « Hérite de ». La chaîne est repliée au démarrage : un enfant détient l'union des capacités et de l'accès aux domaines de ses ancêtres, chaque autorisation de colonne, de métrique, de fonction et de webhook qui nomme un ancêtre, et les règles RLS des ancêtres par table, l'enfant ayant la priorité. Le rôle le plus proche dans la chaîne qui a une règle pour une table fournit le prédicat de cette table, et une règle enregistrée pour l'enfant remplace celle du parent pour cette table.

roles:
  - id: basic_user
    description: Queries the public domain.
    capabilities: [query_development]
    domain_access: [public]
  - id: analyst
    description: Analyses sales; reads full results.
    capabilities: [full_results]
    domain_access: [sales, analytics]
    parent_role_id: basic_user   # inherits query_development + public domain

Rôles : accordés, dérivés et méta

Trois sortes de rôles fonctionnent ensemble dans le modèle d'accès de Provisa. La page Rôles liste les deux premières ensemble ; un méta-rôle n'est jamais listé.

Rôles accordés

Un rôle accordé est attribué directement à un utilisateur. Il détient des droits : capacités, accès aux domaines, entrées de masquage, filtres de lignes. L'héritage (parent_role_id) replie au démarrage les droits d'un ancêtre dans l'enfant. Les droits ne font que s'ajouter ; il n'y a pas de soustraction. Pour restreindre un lecteur, accordez à un composé qui nomme à la fois le rôle et une condition, ou placez une entrée de masque avant l'entrée permissive.

Attributions qui expirent et inutilisées

Une attribution que Provisa détient peut prendre fin. Une attribution accordée avec une expiration n'est plus détenue à partir de cet instant, pour chaque requête. Un rôle peut indiquer inactivity_days : une attribution de ce rôle dont aucune requête n'a dépendu pendant plus longtemps, compté à partir du plus récent de son dernier usage et de sa création, est supprimée. Les rôles issus des revendications ou des correspondances de rôles d'un fournisseur d'identité sont limités dans le temps par le fournisseur ; fixez-y leur expiration. Les rôles dérivés et les méta-rôles n'expirent jamais.

Une requête a dépendu d'un rôle quand ce rôle l'a servie seul ou, quand la requête agit en tant que plusieurs rôles, quand une table que l'instruction a lue est accordée au rôle. Agir en tant que « Tous » ne compte pas chaque rôle détenu comme utilisé. Le dernier usage d'une attribution est enregistré au plus une fois par jour.

Toutes les 15 minutes, le balayage de chaque organisation supprime les attributions expirées et inutilisées et inscrit chacune au journal d'administration comme role.expired ou role.removed_inactive. Là où le courrier sortant est configuré, y compris dans un déploiement à une seule organisation, le titulaire et les administrateurs de l'organisation reçoivent un e-mail auth.role_grant_notice_days jours (7 par défaut) avant la fin d'une attribution, et les administrateurs en reçoivent un quand l'une est supprimée ; sans courrier configuré, l'entrée du journal en fait foi. Ces avis sont envoyés quelle que soit la préférence de courrier de l'utilisateur.

Rôles dérivés

Un rôle dérivé provient des attributs d'un utilisateur. L'organisation déclare quels attributs deviennent des rôles (derived_roles), chacun avec une description. Pour chaque attribut déclaré a dont la valeur v est fournie par les attributs de l'utilisateur, l'utilisateur détient le rôle parent a et son rôle de valeur a__v : region = eu donne region et region__eu. Sans valeur, il ne détient ni l'un ni l'autre.

La résolution des attributs compte. L'enregistrement propre de l'organisation sur un membre l'emporte sur ce que fournit le fournisseur d'identité : si les deux nomment le même attribut, la valeur de l'organisation est utilisée et celle du fournisseur est marquée comme remplacée.

Les rôles dérivés ne portent aucun droit et ne sont jamais attribués à la main. Une liste d'autorisation, un masque ou une règle de lignes les nomme exactement comme il nomme un rôle accordé ; une requête les nomme dans X-Provisa-Role et agit sous eux.

Sur la page Rôles, chaque attribut déclaré est un rôle parent marqué dérivé, avec ses rôles de valeur listés en dessous à mesure que leurs valeurs apparaissent. + Rôle → Dérivé d'un attribut en déclare un. Son panneau montre l'attribut, sa description, les valeurs vues jusqu'ici et chaque masque, règle de lignes et autorisation qui le nomme, lui ou l'un de ses rôles de valeur. Il n'a pas de contrôles d'autorisation. La description d'un rôle de valeur est générée : « Détenu quand l'attribut region de l'utilisateur vaut eu ».

Déclarez-les dans la configuration sous forme de liste :

derived_roles:
- attribute: region
  description: The subsidiary a member works for; region__eu marks EU staff.

Pensez à un JWT : ses claims sont des faits sur la personne. Les rôles dérivés transforment des faits choisis en rôles, de sorte qu'une autorisation peut les viser sans créer un rôle par combinaison.

Méta-rôles

Lorsqu'une requête nomme plus d'un rôle, Provisa construit un méta-rôle éphémère identifié comme meta:<ids sorted, joined by +>. C'est un enfant unique de chaque rôle de l'ensemble nommé. Tout le pipeline s'exécute alors avec lui comme un rôle unique ordinaire.

Les droits sont l'union de ceux de chaque membre. Les filtres de lignes sont l'OR du filtre résolu de chaque membre ; si un membre qui lit une table n'a pas de filtre, le méta-rôle n'en a pas non plus. Pas de filtre signifie toutes les lignes. Un méta-rôle n'est jamais stocké, listé ni exporté. Il est reconstruit à partir de ses membres lorsque le modèle change.

Les colonnes de chaque ligne sont masquées et affichées selon les rôles dont le filtre de lignes a admis cette ligne : parmi ces rôles, la lecture la moins restrictive de la colonne s'applique, et un rôle qui n'a pas admis la ligne ne contribue en rien à la façon dont elle est lue. Les masques s'appliquent là où la table est lue, avant toute jointure ou agrégation, de sorte qu'une agrégation lit les valeurs masquées. Les écritures sont vérifiées de la même façon : une colonne n'est écrite sur une ligne que là où un rôle qui accorde cette colonne admet la ligne.

L'enregistrement d'audit nomme chaque rôle agissant, pas l'identifiant du méta-rôle. Comme un méta-rôle n'est jamais stocké, il n'a pas de description propre : partout où une description est affichée, c'est celle de ses membres, chaque identifiant avec la sienne.

Descriptions

Chaque rôle et chaque règle de lignes dit à quoi il sert. Une revue d'accès lit qui peut voir quoi et pourquoi. Un rôle ou une règle que personne ne sait expliquer est un rôle que personne ne peut revoir ; la description est donc obligatoire : un rôle, un rôle parent dérivé ou une règle de lignes dont la description est vide est refusé par le chargement de la configuration, l'API d'administration et l'UI, avec la raison. Les listes Rôles et Règles de lignes montrent chaque description, et leurs panneaux de détail la montrent et la modifient.

Un rôle a une seule description, pas une par domaine. Un rôle général qui ne décrit pas ses responsabilités dans un domaine particulier signifie que ce domaine a besoin de son propre rôle, généralement un enfant du rôle général (parent_role_id), qui hérite de ses droits et décrit ce qu'il y fait.

Comment une requête nomme des rôles

Un client envoie un rôle, ou un ensemble séparé par des virgules, dans X-Provisa-Role. Chaque rôle nommé doit être détenu. Un rôle qui n'est pas attribué à l'identité est refusé nommément.

Les droits et les données ne suivent que les rôles sous lesquels agit la requête : détenir un rôle n'est pas agir sous lui. Un rôle est ce rôle ; deux ou plus agissent sous leur méta-rôle. Un rôle agissant qui apporterait avec lui les droits de chaque rôle détenu ne signifierait rien ; une requête obtient donc le moindre privilège, et l'enregistrement d'audit nomme exactement ce qui a agi.

Les rôles dérivés nommés entrent comme qualificatifs. Un qualificatif n'a ni droits, ni accès aux domaines, ni masques, ni filtres de lignes propres, il n'est donc pas membre des droits du méta-rôle : comme membre sans masques, il démasquerait chaque colonne, et comme membre qui ne lit aucune table, il mettrait fin à tout OR de filtres de lignes. Il n'entre que dans les autorisations : une autorisation ou une entrée de masque qui nomme region__eu atteint une requête qui nomme ce qualificatif à côté d'un rôle accordé.

Un agent MCP n'agit jamais au-delà des rôles qui lui ont été donnés. Un agent distant ne nomme que des rôles que détient le principal de son jeton. Un agent local en stdio ne nomme que des rôles de l'ensemble fixé dans mcp.role (PROVISA_MCP_ROLE), et agit comme cet ensemble lorsqu'il n'en nomme aucun ; tout autre rôle est refusé par son nom.

Un agent demande sous quel rôle il agit avec l'outil MCP describe_role. La réponse est ce rôle et ce à quoi il sert (un méta-rôle avec chacun de ses membres), ainsi que les rôles vers lesquels l'agent peut basculer, chacun avec sa description. Elle ne contient jamais de règle de lignes, de masque, d'autorisation ni de condition. Provisa ne dit jamais à un agent pourquoi un résultat a été restreint, et un résultat ne porte aucun indicateur « filtré » : les données d'un rôle sont toute sa vue, et une raison pour ce qui manque inviterait à le sonder.

Ce que signifie +

+ apparaît à deux endroits, avec un sens différent dans chacun.

Dans une entrée d'autorisation. analyst+region__eu dans une liste visible_to ou to signifie que le lecteur les détient tous : analyst ET region__eu à la fois. Une entrée qui n'est pas un composé est un rôle unique. Entre les entrées d'une liste, la règle est n'importe laquelle : un lecteur détient l'autorisation s'il détient une entrée quelconque. ET à l'intérieur, OU entre elles.

Dans un identifiant de méta-rôle. meta:analyst+engineer est l'identifiant du serveur pour le méta-rôle de l'ensemble {analyst, engineer}. Ses droits sont l'union des deux. Un client ne nomme jamais directement l'identifiant du méta-rôle ; il nomme les rôles qu'il détient, et le serveur construit le méta-rôle.

Règles de colonne ABAC en simple RBAC

Une politique fondée sur les attributs demande : « cet utilisateur, avec ces attributs, peut-il voir cette colonne ? » RBAC demande : « quels rôles peuvent voir cette colonne ? » Les autorisations composées et les rôles dérivés en font la même question.

« Démasquer cet e-mail uniquement pour un analyste dans l'UE » est une entrée de masque {to: [analyst+region__eu], mask: null}. Tout lecteur qui détient à la fois le rôle analyst et le rôle dérivé region__eu lit la colonne non masquée. Pas de langage de politique. Pas de rôle par combinaison.

Le compromis est le calcul plutôt que l'énumération. Un petit catalogue de rôles réduit la maintenance, mais « qui peut voir cet e-mail de l'UE ? » devient une réponse calculée : les entrées composées sont évaluées contre les attributions et les claims de chaque utilisateur. Provisa peut répondre dans les deux sens parce que le modèle est une seule définition. La vérification d'accès de l'éditeur de colonnes (POST /admin/masks/check) exécute cette évaluation pour un ensemble de rôles choisi.

Avantages

  • Une règle de colonne ABAC devient une entrée d'autorisation RBAC.
  • Un petit catalogue de rôles au lieu d'un rôle par combinaison.
  • Une seule définition qui coordonne les règles de chaque région.
  • Le moindre privilège et un enregistrement d'audit exact par requête.

Bonnes pratiques

  • Gardez le catalogue de rôles aussi petit que l'organisation le permet.
  • Héritez au sein d'un axe (ancienneté, niveau d'habilitation). Composez entre axes indépendants (rôle + région, rôle + service).
  • Placez en premier les entrées de masque les plus spécifiques. Une entrée antérieure l'emporte même lorsque le lecteur en détient une ultérieure.
  • Vérifiez l'accès effectif avec POST /admin/masks/check avant de déployer une combinaison en production.

Modèle de permissions des colonnes

Chaque colonne a un modèle de permissions qui contrôle l'accès en lecture, en écriture et le masquage par rôle.

Visibilité à trois niveaux

Niveau Condition Résultat
Masquée à la vue Rôle absent de visible_to Colonne absente du schéma et du catalogue
Masquée Rôle dans visible_to ; la première entrée de masque qu'il détient a un mask Colonne visible, valeur remplacée en sortie
Non masquée Rôle dans visible_to ; la première entrée de masque qu'il détient n'a pas de mask, ou il n'en détient aucune Accès complet en lecture

Colonnes publiques

Chaque colonne a un scope, défini dans le fichier du modèle, l'API d'administration ou la page Tables. Les trois portées sont ordonnées selon ceux qu'elles servent : restricted, puis domain, puis public. Un éditeur de table (table_registration dans le domaine de la table) peut restreindre la portée d'une colonne. L'élargir — restricted vers domain ou public, domain vers public — sert la colonne à davantage de rôles et requiert le droit que requiert son octroi : column_grant ou access_config dans le domaine de la table. Une colonne en cours d'enregistrement est comparée à domain, donc l'enregistrer en public est un élargissement. Sur une colonne portant une étiquette sensible, tout changement de portée requiert sensitive_data. Un changement refusé nomme la colonne et le droit.

Portée Servie à
domain (par défaut) Les rôles qui atteignent le domaine de la table, selon visible_to. Un visible_to vide correspond à chacun de ces rôles.
public Les mêmes rôles, et aussi les rôles qui n'atteignent pas le domaine de la table.
restricted Seulement les rôles que nomme visible_to. Un visible_to vide ne correspond à personne.

Publier une colonne retire la condition de domaine pour cette colonne et rien d'autre :

  • Un rôle hors du domaine se voit servir la table avec ses seules colonnes publiques. Les autres colonnes de la table, et les tables qui ne publient rien, restent hors de son schéma, de son catalogue et de son SQL.
  • Le propre visible_to de la colonne décide toujours qui : une colonne publique accordée à un rôle n'est publique que pour ce rôle.
  • Les masques, les filtres de lignes, les limites de lignes et la garde des relations s'appliquent comme à l'intérieur du domaine. Sans filtre de lignes pour le rôle, les colonnes publiées sont lues sans filtre ; un filtre de lignes sur la table ou son domaine s'applique toujours.
  • Une relation est servie lorsque les colonnes de ses deux extrémités le sont.
  • Une colonne de paramètre d'une table d'API (un argument de chemin ou de requête) n'est pas une donnée et ne publie rien.
  • Le domaine ops exige une autorisation explicite pour chaque colonne ; rien en lui n'est servi entre domaines, quelle que soit sa portée.

La règle est la même sur chaque surface, car chacune lit le schéma compilé du rôle : GraphQL, SQL (HTTP, pgwire, Flight, JDBC), Cypher et Bolt, gRPC, REST et JSON:API, les catalogues Flight et MCP et les listes d'administration. Une portée qui n'est pas l'une des trois valeurs est refusée à l'enregistrement d'une colonne.

Autorisations d'écriture

visible_to contrôle la lecture d'une colonne (vide signifie que tous les rôles peuvent lire). L'écriture est contrôlée par table avec write_grants, et non par colonne. Une table n'a aucune autorisation par défaut, donc aucun rôle ne peut y écrire.

Une autorisation d'écriture indique ce qu'un rôle peut écrire dans une table par une opération (insert, update ou delete) :

Champ Rôle
columns Les colonnes qu'un INSERT peut lister ou qu'un UPDATE peut définir
rows Les lignes qu'un UPDATE ou un DELETE peut toucher (un prédicat SQL)
check Ce que doit satisfaire chaque ligne qu'écrit un INSERT ou que laisse un UPDATE (un prédicat SQL)
presets Colonnes que l'autorisation définit elle-même, à partir d'une variable de session, d'un littéral ou de l'instant de l'écriture. L'instruction ne peut pas les fournir.

Si vous omettez rows ou check, l'autorisation prend le filtre de lignes en lecture du rôle sur la table, de sorte qu'un rôle écrit les lignes qu'il lit. "TRUE" signifie toutes les lignes.

Une seule admission décide de chaque écriture sur chaque surface, dans cet ordre : le rôle détient le droit write ; il détient une autorisation pour l'opération sur la table ; chaque colonne qu'écrit l'instruction figure dans l'autorisation ; les presets sont ajoutés ; rows est combiné par AND dans le WHERE ; et check est évalué sur les nouvelles lignes avant que quoi que ce soit soit envoyé à la source. Un refus nomme la table, le rôle et l'opération.

Les instructions plus larges demandent des autorisations plus larges. TRUNCATE demande une autorisation delete sur toutes les lignes. MERGE demande une autorisation insert et une autorisation update sur toutes les lignes. INSERT ... ON CONFLICT DO UPDATE demande une autorisation update sur toutes les lignes, sans presets. COPY demande une autorisation insert sans presets.

GraphQL propose à chaque rôle un champ de mutation pour exactement les opérations pour lesquelles il détient des autorisations, avec les colonnes de l'autorisation. Un rôle enfant hérite des autorisations de son parent, les siennes d'abord. Un méta-rôle détient le repli des autorisations de ses membres : l'union des colonnes, le OR de rows et de check, et seulement les presets que partagent tous les membres. Dans l'interface d'administration, la section Écritures d'une table liste et modifie ses autorisations.

Exemple

tables:
  - source_id: hr-pg
    domain_id: people
    schema: public
    table: employees
    columns:
      - name: id
        visible_to: []                # all can read
      - name: email
        visible_to: [admin, analyst, viewer]
        masks:
          - to: [admin]               # admin sees raw value (no mask)
          - to: ["*"]                 # everyone else sees it masked
            mask: {type: regex, pattern: "(.).*@", replace: "$1***@"}
      - name: salary
        visible_to: [admin, hr]
        masks:
          - to: [admin, hr]           # admin and hr see real value
          - to: ["*"]                 # all other visible roles see 0
            mask: {type: constant, value: 0}
      - name: created_at
        visible_to: []                # all can read
    write_grants:
      - role: admin
        operation: update
        columns: [email]
        rows: "TRUE"
      - role: hr
        operation: update
        columns: [salary]
      - role: hr
        operation: insert
        columns: [email, salary]
        presets:
          - {column: created_at, source: now}

Dans cet exemple :

  • email : admin voit alice@example.com et peut le mettre à jour sur n'importe quelle ligne ; analyst et viewer voient a***@example.com et ne peuvent pas écrire
  • salary : admin et hr voient la valeur réelle ; hr peut la mettre à jour sur les lignes que hr peut lire, car l'autorisation n'indique pas de rows ; tout autre rôle visible voit 0
  • created_at : tout le monde peut le lire, aucun rôle ne le fournit, et un insert de hr le définit à l'instant de l'écriture

Autorisation des mutations

Une mutation enregistrée (GraphQL distant, OpenAPI, gRPC, Hasura) passe deux contrôles. Le rôle doit détenir la capacité globale write, et la commande doit lui être attribuée par visible_to ; une liste vide l'attribue à tous les rôles. Une commande n'a pas de liste writable_by, et la configuration en refuse une en la nommant.

C'est son contrat qui décide si une opération est une écriture, pas ce qu'en dit l'appelant. Un SELECT qui appelle une fonction de type mutation est traité comme une écriture et passe les mêmes deux contrôles : impossible de lancer une mutation en la déguisant en lecture. Marquer une mutation comme sûre en lecture exige la capacité access_config et est consigné comme une décision de gouvernance. Aucune requête ne peut s'en exempter.

Visibilité du schéma

Chaque rôle a son propre schéma GraphQL. Seules les tables des domaines de son domain_access y figurent ("*" les désigne tous), et une colonne absente de visible_to pour ce rôle est absente du SDL aussi. Ce que le rôle ne peut pas lire, il ne le voit pas.

Sécurité au niveau des lignes (RLS)

Un filtre de lignes est un prédicat WHERE pour une table et un rôle, ajouté après la compilation et avant l'exécution.

rls_rules:
  - table_id: orders
    role_id: analyst
    filter: "region = current_setting('provisa.user_region')"
    description: Analysts read only their own region's orders.

Le filtre est ajouté par AND à la clause WHERE de la requête, pour les lectures comme pour UPDATE et DELETE.

Un filtre lit la requête par current_setting('provisa.<name>'). La requête lie user_id à partir de l'identité connectée, role à partir du rôle agissant, et chaque claim scalaire du jeton, en minuscules et avec des tirets bas à la place des tirets. provisa.user_id est toujours l'identité connectée, et aucun claim ne peut la remplacer.

Les variables de session de Hasura gardent leur nom. X-Hasura-User-Id est hasura_user_id, lu dans le claim x-hasura-user-id du jeton (au premier niveau ou dans l'objet de claims de Hasura, par exemple https://hasura.io/jwt/claims) ou dans l'attribut hasura_user_id enregistré pour le membre. Un filtre Hasura importé sur X-Hasura-User-Id lit donc provisa.hasura_user_id.

Sous les valeurs de la requête se trouvent les session_vars configurées du rôle, que la requête remplace. Un rôle qui hérite prend les constantes de ses ancêtres sous les siennes, le rôle le plus proche l'emportant pour chaque nom. user_id, role, region et org viennent de la requête ou du nœud et ne peuvent pas être configurés comme constantes. En mode non sécurisé, un en-tête x-provisa-session-<name> lie <name>. Une variable liée nulle part vaut NULL, et un filtre sur elle ne correspond à aucune ligne.

Une constante de rôle fonctionne à l'inverse d'un rôle dérivé. Un rôle dérivé transforme en rôle une valeur que détient un membre. Une constante de rôle donne une valeur à chaque requête faite sous ce rôle, et une règle de lignes la lit comme elle lit un claim. La constante peut être une référence ${env:NAME} ou ${secret:NAME}, résolue par le même résolveur que toute autre référence quand une requête l'utilise. Le modèle garde la référence, jamais la valeur, et une référence qui ne se résout en rien fait échouer la requête en nommant la constante. Une fois résolue, la valeur fait partie de la requête : les enregistrements propres à Provisa sont couverts par l'expurgation centrale, mais les journaux propres d'une source fédérée échappent à Provisa.

Masquage au niveau des colonnes

Le masquage est défini comme une liste ordonnée sur chaque colonne. La première entrée que détient le lecteur décide de ce qu'il voit.

Type de masque Types de colonne pris en charge Expression SQL
regex Chaîne (varchar, char, text) REGEXP_REPLACE(col, pattern, replace)
constant Tout Valeur littérale (NULL, 0, une chaîne personnalisée)
truncate Date/Horodatage DATE_TRUNC(precision, col)

Chaque entrée a une liste to qui nomme les lecteurs qu'elle vise, et un mask facultatif. Une entrée sans mask signifie non masqué pour ces lecteurs. * dans to correspond à tout lecteur.

L'expression de masque est poussée dans la projection SQL SELECT. La base de données renvoie la valeur masquée ; les données non masquées ne traversent jamais le réseau pour les rôles masqués. Les colonnes masquées sont aussi bloquées dans les clauses WHERE et HAVING (garde des prédicats de la couche 5) pour empêcher de déduire des valeurs par filtrage.

Un masque couvre tous les noms de la colonne, pas une colonne liée. Une colonne lue par son alias ou par son nom physique est masquée de la même façon. Un masque ne cache pas une clé qu'une relation approuvée joint à une colonne que le rôle peut voir : la jointure compare les valeurs réelles, donc la colonne liée montre ce que contient la clé masquée. Masquez aussi la colonne liée, ou n'approuvez pas la relation pour ce rôle.

Aucune entrée détenue signifie non masqué. Un lecteur qui ne détient aucune entrée lit la colonne non masquée. Terminez une liste par une entrée * lorsque chaque lecteur doit recevoir un masque. L'éditeur de colonnes avertit lorsqu'une liste n'a pas d'entrée * (unlisted_unmasked).

Refuser par l'ordre. Une entrée antérieure l'emporte même lorsque le lecteur en détient aussi une ultérieure plus permissive. Pour que les prestataires voient toujours un salaire masqué quels que soient leurs autres rôles, placez l'entrée des prestataires avant l'entrée non masquée. Un rôle accordé ne perd jamais ce que son parent accorde par héritage, mais une entrée de masque placée plus tôt dans la liste s'applique en premier. C'est ainsi que fonctionne la restriction ici.

Méta-rôles. La chaîne d'un méta-rôle comprend les rôles de chaque membre, leurs ancêtres et tout rôle dérivé nommé. Une entrée accordée à n'importe quel membre atteint le méta-rôle. Une entrée placée avant, détenue par un autre membre, s'applique toujours en premier.

Vérifier l'accès. POST /admin/masks/check accepte une liste de masques et un ensemble facultatif de rôles, et renvoie l'entrée qui s'applique pour ces rôles ainsi que les entrées qu'aucun lecteur ne peut atteindre. Protégé par masking_config.

# email column: analyst+region__eu sees raw; everyone else sees masked
masks:
  - to: [analyst+region__eu]          # composite: analyst AND in EU region
  - to: ["*"]                         # all others
    mask: {type: regex, pattern: "(.).*@", replace: "$1***@"}

# hire_date column: hr sees raw; all other visible roles see year only
masks:
  - to: [hr]
  - to: ["*"]
    mask: {type: truncate, precision: year}

Gouvernance des réponses d'actions

Ce que renvoie une fonction suivie ou un webhook est gouverné comme les lignes d'une table. Provisa lie les lignes renvoyées en une relation dont les colonnes sont la sortie déclarée de l'action : output_columns pour une fonction, inline_return_type pour un webhook, ou les propriétés d'objet du return_schema de type tableau d'une fonction. Cette relation passe ensuite par la même étape de gouvernance que toute lecture de table, sur une CTE VALUES contenant les lignes : la sécurité des lignes, le masquage et la visibilité ne sont pas réimplémentés pour les actions.

Trois règles s'appliquent, dans le même ordre que pour une table :

  • Filtre de lignes. La règle RLS du rôle enregistrée pour l'action sous son nom (upsertRlsRule avec actionName), ou la règle du domaine de l'action quand celle-ci n'en a pas. Le prédicat est vérifié par rapport au contrat à l'enregistrement, comme une règle de table.
  • Visibilité des colonnes. Une colonne du contrat qui déclare visible_to disparaît pour tout rôle que la liste n'inclut pas. Une colonne qui n'en déclare pas fait partie de la forme publique de l'action et reste.
  • Masques. Une colonne du contrat porte masks, la même liste ordonnée qu'une colonne de table.

L'héritage des rôles résout la chaîne pour les trois. Si une réponse renvoie une colonne hors de son contrat déclaré, l'appel échoue avec une erreur 502 au lieu de la laisser passer sans gouvernance. Une action qui ne déclare aucune colonne renvoie un scalaire, qui n'a rien à lier, et il est rendu tel quel. L'invocation de test de l'administration indique quels filtres, exclusions et masques se sont appliqués, et l'instruction gouvernée est écrite dans le journal d'audit des requêtes.

Échantillonnage

Chaque rôle reçoit un échantillon des résultats, 100 lignes par défaut, sauf s'il détient la capacité full_results. La taille se règle avec la variable d'environnement PROVISA_SAMPLE_SIZE.

Journalisation d'audit

Chaque requête qui touche un actif d'un domaine est écrite dans query_audit_log. Le journal appartient à l'organisation, pas à la plateforme : le registre propre de la plateforme, avec organisations, utilisateurs et invitations, ne contient aucune entrée d'audit. Le journal de chaque organisation est une table de son schéma, dans le stockage que l'organisation désigne pour ses enregistrements dans chaque région qu'elle choisit. Une organisation sans région le garde dans la base de données du plan de contrôle du déploiement, à côté de son modèle.

Une ligne consigne tenant_id (l'organisation), user_id, role_id, un hachage SHA-256 du texte de la requête, le texte lui-même (chiffré), table_ids et table_names, source, status_code, duration_ms, la route et le nombre de lignes, ce qui a été appliqué à l'instruction, et logged_at. Le texte est chiffré avec la clé de l'organisation si elle en a défini une, sinon avec celle du déploiement. Un déploiement sans fournisseur de chiffrement le stocke en clair. Dans tous les cas, il n'apparaît jamais dans les tables d'exploitation interrogeables et n'est déchiffré que pour les lectures d'administration autorisées.

Le journal n'accepte que des ajouts, au niveau de la base elle-même : des règles PostgreSQL rendent DELETE et UPDATE sur query_audit_log sans effet. Deux index, (tenant_id, logged_at) et (user_id, logged_at), rendent rapides les requêtes de conformité par organisation et par utilisateur sur une période.

Les lignes sont conservées pour toujours. Provisa n'a aucun réglage de rétention et ne purge jamais le journal, et comme DELETE ne fait rien, supprimer d'anciennes lignes revient pour un opérateur à supprimer ou archiver les données de la table en dehors de Provisa.

Expurgation

Ce que Provisa enregistre de son propre travail passe par une liste de règles d'expurgation avant d'être écrit où que ce soit. Le texte qu'une règle reconnaît est remplacé par [REDACTED:<rule>], qui nomme la règle concernée.

La liste a quatre parties :

  • Motifs intégrés. Numéros de carte qui passent le contrôle de Luhn, numéros de sécurité sociale américains, jetons bearer, jetons d'accès Provisa, clés AWS, GCP, Azure, GitHub et Slack, et clés privées. C'est un jeu de départ, pas une garantie.
  • Les règles du déploiement, qui s'appliquent dans toutes les organisations. Les modifier demande le droit platform_settings.
  • Les règles propres à l'organisation. Les modifier demande le droit org_settings.
  • La règle automatique sur les secrets de l'organisation : chaque valeur que le magasin propre de Provisa détient pour l'organisation, chaque ${secret:NAME} que nomment le modèle et les paramètres de l'organisation (sur n'importe quel backend de secrets), et chaque valeur ${secret:…} ou ${user:…} dès sa première résolution. Aucune règle n'a besoin de les nommer.

Une règle est soit une expression régulière nommée, soit une référence ${secret:NAME} / ${env:NAME} nommée, qui reconnaît la valeur à laquelle la référence se résout. Une référence qui ne se résout pas est journalisée en ERROR par son nom et affichée comme non résolue ; les autres règles continuent de s'appliquer. Les valeurs de moins de quatre caractères ne sont pas reconnues.

Provisa n'a pas à connaître tous les formats sensibles. Les motifs intégrés couvrent des formes courantes ; une organisation ajoute des motifs pour ses propres formats, comme les matricules, les identifiants de compte ou les noms d'hôte internes. L'expurgation est faite au mieux : une valeur écrite sous une forme qu'aucune règle ne décrit passe telle quelle.

Les règles se trouvent sur la page Secrets de l'organisation, sous Règles d'expurgation. La page liste les motifs intégrés, les règles du déploiement (modifiables avec platform_settings), les règles de l'organisation, les secrets de la règle automatique par leur nom uniquement, et toute référence qui ne se résout pas. Les règles sont chargées au démarrage du serveur et à la construction de l'environnement d'exécution d'une organisation, et rechargées quand le modèle ou les paramètres changent et quand un secret de l'organisation est enregistré ou supprimé.

La liste s'applique à chaque sortie qu'écrit Provisa :

  • chaque span, sous forme d'une copie expurgée remise à chaque exportateur, et la vue des spans récents ;
  • chaque enregistrement de journal du pipeline de journaux OpenTelemetry de Provisa, et chaque ligne qu'écrit un gestionnaire de journalisation, sortie standard et journaux des conteneurs compris ;
  • le texte de requête du journal d'audit, avant d'être haché et chiffré, de sorte que ni le texte ni son hachage ne révèlent une valeur reconnue ; et le détail de la piste administrative ;
  • les messages d'erreur qu'un client reçoit par HTTP, pgwire, gRPC, Arrow Flight et MCP.

La télémétrie et les erreurs client ne sont rattachées à aucune organisation au moment où elles sont écrites ; elles sont donc expurgées par les règles de toutes les organisations ensemble. Deux erreurs passent sans expurgation : un corps d'erreur HTTP de plus de 1 Mio, et l'erreur d'un flux Arrow Flight qui échoue après avoir commencé à envoyer.

Une source fédérée tient ses propres logs, que Provisa n'écrit pas et ne peut pas atteindre. Les traces qu'une source garde des instructions que Provisa lui envoie relèvent de la protection de cette source.

Statistiques par MCP

Les outils statistiques MCP (describe, correlate, regress, compare, forecast) exécutent le SQL défini par un agent dans le pipeline gouverné sous le rôle de l'agent, donc les règles de lignes, le masquage et l'expurgation s'appliquent à ce qu'ils analysent. Les outils statistiques ne renvoient que des résultats, donc les lignes derrière une analyse n'entrent jamais dans le contexte du modèle : minimisation des données par défaut.

Le contrôle d'accès est ce qui limite ce qu'un agent peut apprendre. Pour garder les identités hors de portée d'un agent, n'accordez à son rôle que les colonnes qui n'identifient personne ; un rôle qui peut lire une colonne peut l'agréger, et des agrégats sur de petits groupes peuvent encore identifier des individus.

Couverture des colonnes sensibles

La vue sensitive_coverage du domaine meta liste chaque colonne sensible de l'organisation avec la valeur fictive et la règle synthétique qu'elle déclare et un statut : covered, no_fake, ou inadequate quand l'une des vérifications d'adéquation de Provisa a échoué. Les vérifications cherchent une valeur fictive qui laisse les valeurs réelles inchangées, une valeur fictive dont le type ne correspond pas à ce que contient la colonne, et une valeur fictive ou une règle qui lit la valeur réelle d'une autre colonne sensible. Un data steward voit les colonnes sans valeur fictive dans le détail de chaque environnement, et un environnement Test (fake) refuse de servir des données tant que chaque colonne sensible n'en a pas une. Les vérifications et la vue sont décrites dans Construire des environnements de test.

Jugements en revue

Les choix de gouvernance reposent sur le jugement, et un jugement peut être faux ou devenir obsolète quand les faits sur lesquels il reposait changent. Chaque jugement de gouvernance peut porter un pourquoi : un tag sur un objet, la valeur fictive et la règle synthétique d'une colonne, une entrée de masque, une règle de lignes et un droit d'écriture. Un pourquoi contient la raison retenue, le but, les bases nommées citées et une date de revue, le tout facultatif. Sur une attribution de tag, la raison est son propre reason ; sur une règle de lignes, le but est sa description obligatoire. Provisa enregistre qui a pris la décision, quand, et sur quelle preuve de profil : la dernière exécution de profil de la table et un résumé de celle-ci. Il les enregistre à chaque sauvegarde qui modifie la décision ou son pourquoi. Une sauvegarde qui omet le pourquoi conserve celui qui est stocké, et un fichier de modèle porte chaque pourquoi dans un bloc why:.

Les bases nommées sont le contexte que les données ne peuvent pas montrer : une réglementation, une politique, une stratégie. Un jugement les cite par id dans une courte liste (/admin/judgments/bases). Un détenteur de access_config déclare une base modifiée avec une note, et chaque jugement qui la cite et a été décidé avant devient aussitôt obsolète.

La revue s'exécute toutes les heures pour chaque organisation, à la fin de chaque exécution de profil pour la table profilée, et aussitôt qu'une base est déclarée modifiée. Elle place ce qu'elle trouve dans une seule file :

Catégorie Constaté quand
Faux une vérification d'adéquation de la valeur fictive d'une colonne sensible échoue, ou la règle synthétique d'une colonne sensible copie des valeurs réelles
Obsolète par les données le profil a dérivé sur la colonne décidée (selon les seuils propres au profileur), son type plausible, sa famille ou son type de données a changé, une colonne a été ajoutée à la table, la colonne décidée a été supprimée ou retypée, ou l'auteur de la décision n'est plus membre
Obsolète par le contexte une base citée a été déclarée modifiée après la décision, la date de revue est arrivée, ou le expires_on d'une attribution de tag est passé
Non décidé une colonne sensible n'a pas de valeur fictive, ou une colonne a été ajoutée à une table qui porte un tag sensible ou une valeur fictive
À examiner un identifiant de source est stocké comme valeur et non comme référence ; la source d'un environnement de test lit la connexion de prod ; aucune authentification n'est configurée ; l'administrateur d'amorçage n'est pas réclamé ; un listener sert sans TLS ; un rôle ou une source atteint tous les domaines ; une attribution de tag expirée est toujours en vigueur

La dernière catégorie est observée, pas imposée : rien dans la revue ne refuse quoi que ce soit. GET /admin/judgments/findings liste les constats ouverts qu'un appelant peut voir ; un constat sur une colonne sensible exige sensitive_data, tout autre view_governance. Réaffirmer (POST /admin/judgments/findings/{id}/reaffirm) enregistre de nouveau le jugement avec la preuve du jour et exige le droit qui modifie la décision. Un constat de posture est écarté avec une note (.../dismiss, access_config). Un constat que la revue ne voit plus est levé. Comme preuve d'audit, la vue judgment_review du domaine ops relie chaque constat au pourquoi de son jugement et s'interroge sous l'accès par rôle et par domaine.

Limitation de débit

Les limites par rôle se règlent dans provisa.yaml : requêtes par seconde, abonnements SSE simultanés et flux Arrow Flight simultanés. Elles sont vérifiées à l'API, avant toute compilation ou exécution, et une requête au-delà de la limite reçoit HTTP 429 avec un en-tête Retry-After.

Les requêtes en langage naturel (POST /query/nl) ont leur propre limite, nl.rate_limit, en requêtes par minute et par rôle. Une requête au-delà est refusée avant tout appel au LLM.

Les compteurs vivent dans Redis (cache.redis_url) sous forme de fenêtres glissantes, sans rien de propre à une instance : une limite tient quel que soit le nombre d'instances Provisa que vous faites tourner.

Limite de complexité des requêtes

Une limite de débit plafonne le nombre de requêtes qu'envoie un rôle. La limite de complexité plafonne ce qu'une instruction peut demander.

Chaque instruction a un score de complexité : les relations qu'elle lit, ses jointures, les colonnes qu'elle sélectionne (un * compte pour les colonnes qu'il représente) et les requêtes imbriquées en elle. Une relation lue depuis une source d'API distante (OpenAPI, GraphQL distant, gRPC distant) compte pour 10, car chaque lecture consomme un budget que le système distant fixe pour tous les utilisateurs de cette source.

La limite est max_query_complexity dans le rate_limit d'un rôle. Le réglage à l'échelle de l'organisation limits.max_query_complexity est un plafond pour chaque rôle : un rôle peut fixer une limite plus basse, et une limite plus haute n'a aucun effet. Aucun des deux n'est défini par défaut.

roles:
  - id: analyst
    rate_limit:
      max_query_complexity: 200

Le score est mesuré après l'analyse d'une requête et avant sa gouvernance ou son exécution, de sorte qu'une même limite tient sur chaque interface par laquelle une instruction peut arriver : GraphQL, SQL sur HTTP, pgwire, Arrow Flight, gRPC, Cypher, JSON:API et MCP. Une instruction au-delà de la limite est refusée avec son score, la limite et ce qu'elle demandait. Les interfaces HTTP répondent 413. Le refus est consigné comme un refus de politique.

Authentification

Fournisseurs d'authentification enfichables :

Fournisseur Identifiant Cas d'usage
none En-tête X-Provisa-Role Développement uniquement
basic Comptes locaux (bcrypt) + JWT de session Déploiements autonomes
firebase Jeton d'ID Firebase Production
keycloak JWT Keycloak Entreprise
oauth / oidc JWT OIDC PingFed, Okta, Azure AD, Auth0
ldap Nom d'utilisateur et mot de passe de l'annuaire (LDAP ou Active Directory) Annuaires d'entreprise
saml Assertion signée SAML 2.0, puis un JWT de session Authentification unique d'entreprise
simple bcrypt + JWT Tests

ldap connecte un utilisateur par recherche puis liaison : Provisa se lie en tant que compte de service, trouve l'unique entrée que désigne le nom d'utilisateur, puis se lie en tant que cette entrée avec le mot de passe présenté, de sorte que l'annuaire le vérifie. Les groupes de l'utilisateur deviennent ses rôles. Le compte et ses groupes sont relus à chaque retour d'un jeton de session, de sorte qu'un utilisateur retiré de l'annuaire, ou d'un groupe, perd cet accès avant l'expiration du jeton. ldaps:// et StartTLS sont tous deux pris en charge.

saml est du SSO Web Browser initié par le SP. La signature XML de la réponse est vérifiée par rapport aux métadonnées du fournisseur d'identité, et les claims ne sont lus que dans l'élément signé. L'émetteur (Issuer), l'audience (Audience), l'URL ACS, l'expiration et InResponseTo sont tous vérifiés. Les réponses non sollicitées (initiées par l'IdP), les assertions chiffrées, les documents avec DOCTYPE, les assertions multiples et les signatures SHA-1 sont refusés. Une session SAML n'est pas revérifiée auprès du fournisseur d'identité : un utilisateur retiré de ce côté conserve l'accès jusqu'à l'expiration du jeton de session.

Correspondance des rôles : claims d'identité → rôle Provisa via des règles configurables. Le champ assignments_source contrôle d'où viennent les attributions de rôles : claims les lit dans les claims du jeton JWT (par défaut), provisa les lit dans le magasin d'attributions interne de Provisa. Un déploiement multi-tenant (multitenancy: true) accorde lui-même les rôles, par organisation, et doit donc utiliser assignments_source: provisa : avec claims, il ne démarre pas, et un changement de réglages qui produirait cette combinaison est refusé. Un déploiement mono-tenant peut utiliser l'un ou l'autre.

Un superutilisateur configuré dans provisa.yaml (nom d'utilisateur plus un mot de passe issu d'un secret d'environnement) reçoit toujours le rôle admin et toutes les capacités, quel que soit le fournisseur configuré, un chemin d'amorçage pour la configuration initiale.

Surfaces et identifiants

Chaque surface authentifie les appelants par le même fournisseur : un identifiant qui fonctionne sur l'une fonctionne sur toutes, partout où le protocole peut le transporter.

Surface Mot de passe Jeton du fournisseur Jeton d'accès personnel Certificat client (mTLS)
HTTP (REST, JSON:API, GraphQL) Authorization: Basic Authorization: Bearer Authorization: Bearer via un proxy de terminaison
pgwire champ de mot de passe (en clair ou SCRAM) champ de mot de passe, déploiements OIDC champ de mot de passe oui
Bolt schéma basic schéma bearer schéma bearer oui
Arrow Flight — token dans la poignée de main ou la charge utile du ticket ; en-tête d'appel authorization: Bearer sur list_flights, get_flight_info et get_schema idem oui
gRPC — métadonnées authorization métadonnées authorization oui
Airport (extension DuckDB airport, sur option) — authorization: Bearer (l'auth_token du secret) idem —
MCP — Authorization: Bearer Authorization: Bearer via un proxy de terminaison

L'extension DuckDB airport ne peut envoyer que l'auth_token bearer de son secret. Un client DuckDB y place un jeton du fournisseur ou un jeton d'accès personnel et agit sous le rôle auquel son identité se résout. Tout client capable de définir des en-têtes d'appel peut demander l'un de ses rôles, ou un ensemble séparé par des virgules, dans x-provisa-role.

Chaque surface vous dit quel genre de refus vous avez reçu. Un identifiant absent, rejeté ou inutilisable donne un refus d'authentification : HTTP 401, gRPC UNAUTHENTICATED, Arrow Flight et Airport Unauthenticated. Un identifiant valide qui demande à agir sous un rôle que son identité ne détient pas donne un refus d'autorisation, qui nomme le rôle : HTTP 403, gRPC PERMISSION_DENIED, Arrow Flight et Airport Unauthorized. Une exception vient de la bibliothèque cliente Arrow, pas du serveur. Quand get_schema de Flight est refusé, un client Python voit une erreur Arrow générique qui porte la raison du serveur mais pas le genre ; list_flights, get_flight_info et do_get transmettent bien le genre.

Un — dans le tableau signifie que le protocole n'a pas de champ de nom d'utilisateur auquel associer un mot de passe ; les formes à jeton le couvrent. pgwire est le cas inverse. Son paquet de démarrage n'a qu'un champ secret et aucun schéma, donc c'est le secret lui-même qui choisit la méthode : un jeton d'accès personnel est reconnu à son préfixe, le secret est lu comme un jeton bearer quand le fournisseur configuré émet des jetons, et tout le reste est un mot de passe. Ce choix se fait une fois, et un identifiant que le validateur choisi refuse n'est pas retenté auprès d'un autre.

Jetons d'accès personnels

Un jeton d'accès personnel est un secret bearer de longue durée que vous créez pour un client incapable de se connecter de façon interactive : un script, un outil de BI, un pilote. Il porte sa propre organisation et son propre rôle, et chaque surface le vérifie avec le même validateur, si bien qu'aucune n'a besoin de savoir ce que c'est.

Sur le réseau, il s'écrit provisa_pat_ suivi de 43 caractères base64 sûrs pour les URL. Le préfixe envoie un secret présenté vers le magasin de jetons plutôt que vers le fournisseur d'identité, et rend un jeton divulgué facile à retrouver dans les journaux et les dépôts.

Seul le SHA-256 du secret est conservé. Vous voyez le secret une fois, à sa création, et il ne peut plus être récupéré ensuite ; la liste de vos jetons montre un préfixe d'affichage et des dates, jamais un identifiant utilisable. Vous créez, listez et révoquez vos propres jetons avec POST /auth/tokens, GET /auth/tokens et DELETE /auth/tokens/{token_hash}, ou dans la section des jetons de votre profil dans l'interface d'administration.

Un jeton valide se résout vers le compte de son propriétaire, avec identifiant, e-mail et nom affiché : une ligne d'audit ou un rapport d'utilisation écrit sous un jeton nomme la personne et non l'identifiant. Lequel de ses jetons a agi est consigné à part, dans raw_claims["token_name"]. Un jeton peut porter une expiration, après laquelle il est refusé, et retirer l'appartenance d'un utilisateur révoque ses jetons avec elle.

SCRAM-SHA-256 sur pgwire

Avec le fournisseur basic, définir auth.scram: true fait annoncer à pgwire SASL (code d'authentification 10) avec le mécanisme SCRAM-SHA-256, de sorte qu'un mot de passe est prouvé plutôt qu'envoyé. Le channel binding (SCRAM-SHA-256-PLUS) n'est pas proposé.

SCRAM a besoin d'un vérificateur RFC 5802, qui ne peut pas être dérivé d'un hachage bcrypt. Un vérificateur est écrit chaque fois qu'un mot de passe passe en clair (inscription, connexion, changement de mot de passe, réinitialisation par un administrateur) de sorte qu'un déploiement qui active SCRAM collecte des vérificateurs à mesure que ses utilisateurs s'authentifient, et la première connexion SCRAM de chaque utilisateur suit sa prochaine saisie de mot de passe. Un utilisateur sans vérificateur reçoit un échange factice impossible à distinguer d'un vrai, afin que le réseau ne révèle pas qui a migré.

TLS mutuel

La vérification du certificat client déplace le premier contrôle dans la poignée de main TLS : un appelant sans certificat signé par la CA du déploiement n'atteint jamais la couche des identifiants. Elle est disponible sur pgwire, Bolt, gRPC et Arrow Flight: les quatre transports qui terminent leur propre TLS.

Variable Signification
PROVISA_MTLS_CLIENT_CA Paquet PEM des CA autorisées à signer les certificats clients
PROVISA_MTLS_MODE required (par défaut dès qu'une CA est définie) ou optional
PROVISA_MTLS_BIND_PRINCIPAL Si vrai, le nom commun du certificat doit égaler le nom d'utilisateur sous lequel la connexion s'authentifie ensuite

Les surcharges par protocole suivent le même nommage que les réglages TLS. Rien n'est déduit : un mode défini sans CA refuse de démarrer, et un mode non reconnu refuse de démarrer plutôt que d'être lu comme le voisin le plus sûr. Un déploiement qui croit exiger des certificats clients et ne le fait pas est en plus mauvaise posture qu'un déploiement qui ne démarre pas.

Versions de TLS

Chaque écouteur que Provisa termine (l'API HTTP et l'UI, pgwire, Bolt, MCP, gRPC et Arrow Flight) accepte TLS 1.2 et TLS 1.3, et refuse TLS 1.0 et 1.1. Provisa ne fixe elle-même ni version de protocole ni liste de chiffrements. Les écouteurs HTTP, pgwire et Bolt utilisent les valeurs par défaut serveur de ssl de Python (minimum TLS 1.2). gRPC et Arrow Flight utilisent les valeurs par défaut de gRPC (minimum TLS 1.2). Les versions ne sont pas configurables.

Limitation des connexions

Deviner des mots de passe ne dépend pas du protocole : le même compte peut être attaqué via HTTP, pgwire et Bolt. Le compteur vit donc dans la couche de validation des identifiants, pas sur une surface particulière, de sorte qu'un verrouillage obtenu n'importe où s'applique partout.

Elle est active par défaut (cinq échecs en cinq minutes verrouillent le sujet pendant quinze minutes) et se règle sous auth.login_throttle. Un sujet verrouillé est refusé avant même que l'identifiant soit examiné, et une authentification réussie efface l'historique de ce sujet.

La clé est le principal que porte le protocole. Une surface uniquement bearer ne porte pas de principal, la clé est donc un condensé de l'identifiant lui-même ; ce que cela empêche, c'est qu'un mauvais jeton soit rejoué sans limite. Le magasin est par processus, donc un déploiement qui exécute plusieurs workers d'API autorise jusqu'à max_attempts par worker. La limitation freine la devinette, ce n'est pas un quota distribué.

Sondage de rôles

Nommer un rôle que l'appelant ne détient pas est traité comme un événement de sécurité détecté de gravité élevée, sur chaque surface : l'API HTTP, MCP, pgwire, Bolt, Arrow Flight, le service Flight airport et gRPC. Un client légitime nomme les rôles qui lui ont été donnés ; celui qui en nomme un autre est soit défaillant, soit en train de sonder.

La requête est refusée. L'appelant n'apprend que « not permitted », rien sur l'existence du rôle, et la connexion ou la session se termine là où il y en a une : la connexion pgwire, la session Bolt, l'appel gRPC, la session MCP. L'exception est l'API HTTP avec tout identifiant autre qu'un jeton d'accès personnel : le navigateur y présente sa session ou le jeton de son fournisseur d'identité, si bien que le refus continue de nommer le rôle.

Le reste dépend de l'identifiant et du transport :

Identifiant Transport Suspendu
Le jeton d'un visiteur d'un bac à sable de démo l'API HTTP rien
Jeton d'accès personnel tout le jeton
Jeton signé par un fournisseur d'identité externe MCP, pgwire, Bolt, Flight, gRPC l'accès par jeton du principal à l'organisation
Jeton signé par un fournisseur d'identité externe l'API HTTP rien
Une session ou un mot de passe que ce déploiement vérifie tout rien
Une session MCP locale (sans bearer) MCP rien ; l'événement va au journal de l'opérateur

L'API HTTP fait exception parce que le navigateur présente le même jeton bearer qu'un client d'API, si bien que le serveur ne peut pas les distinguer, et qu'un onglet qui nomme encore un rôle que l'utilisateur a perdu depuis (après qu'un administrateur a modifié ses rôles, ou un changement d'organisation) est courant. MCP et les autres transports ne sont jamais une session de navigateur. L'événement y est refusé, enregistré et notifié comme tout autre ; seule la suspension est omise. Le jeton d'un visiteur d'un bac à sable de démo est un jeton d'accès personnel, mais c'est le seul identifiant de l'onglet du navigateur du visiteur ; sur l'API HTTP il est donc traité comme une session : refusé et consigné, jamais suspendu.

Un jeton suspendu est refusé comme un jeton révoqué. Un principal dont l'accès par jeton est suspendu est refusé sur MCP, pgwire, Bolt, Flight et gRPC dans cette organisation, et peut toujours se connecter à l'interface. Seul un administrateur lève une suspension ; rien ne la lève au bout d'un délai. Équipe → Accès suspendu liste chaque suspension avec l'événement qui l'a causée et la lève (GET /admin/orgs/{org}/suspensions, POST /admin/orgs/{org}/suspensions/lift).

Chaque événement est écrit dans le journal d'administration de l'organisation (action security.role_probe) avec le principal, l'identifiant de l'identifiant, l'adresse du client, le transport, le point de terminaison ou l'outil, le rôle nommé, les rôles détenus, l'heure et les identifiants des instructions auditées les plus récentes du principal. Leur texte reste dans le journal d'audit des requêtes et se lit avec le droit view_governance.

Les administrateurs de l'organisation reçoivent un e-mail là où le déploiement envoie du courrier. L'événement est aussi envoyé en JSON au webhook de sécurité de l'opérateur lorsque security.webhook_url est défini. Ce qui quitte l'organisation nomme le principal, l'identifiant de l'identifiant, le transport, le point de terminaison, le rôle nommé, l'heure et la gravité, avec un lien vers la vue d'administration ; il ne contient aucune instruction ni aucun rôle détenu. Un avis qui ne peut être livré est journalisé et n'annule jamais le refus ni la suspension.

Nommer l'organisation

En mode multi-tenant, chaque requête nomme l'organisation à laquelle elle est destinée. Appartenir à exactement une organisation ne la nomme pas : une requête qui n'en nomme aucune est refusée, et le refus indique quoi envoyer. Chaque surface nomme une organisation ainsi :

Surface Comment un client nomme l'organisation
HTTP (interface, REST, GraphQL) Le nom d'hôte propre de l'organisation, acme.provisa.dev, ou l'en-tête X-Org-Provisa sur l'hôte du plan de contrôle
pgwire Le nom de la base de données (psql -d acme, le champ de base de données d'un outil BI), ou le nom d'hôte propre de l'organisation composé en TLS
Bolt Le nom de la base de données, <org>.provisa_<role> (ou <org>.provisa_ops_<role>), ou le nom d'hôte propre de l'organisation composé en TLS
Arrow Flight "org" dans le ticket
gRPC L'en-tête de métadonnées x-provisa-org
MCP (transport HTTP) Le nom d'hôte propre de l'organisation, ou l'en-tête X-Org-Provisa
Client Python org= sur ProvisaClient, connect() et adbc_connect() ; ?org= dans une URL SQLAlchemy

Un client pgwire qui compose un nom d'hôte en TLS l'envoie dans le ClientHello TLS, et Provisa y lit l'organisation. Lorsque le nom d'hôte et le nom de la base de données nomment tous deux une organisation et diffèrent, la connexion est refusée ; aucun n'est préféré. Le nom de base de données provisa, la valeur par défaut, ne nomme aucune organisation, et un outil BI connecté à une organisation affiche cette organisation comme sa base de données. Sur Bolt, SHOW DATABASES liste un <org>.provisa_<role> par organisation à laquelle l'utilisateur appartient ; un simple provisa_<role> est refusé en mode multi-tenant, et une session reste dans l'organisation qu'elle a nommée en premier.

Un nom est une demande, pas une autorisation. Il atteint partout le même résolveur, qui refuse une organisation dont le principal authentifié n'est ni membre ni détenteur du droit inter-organisations.

Un jeton d'accès personnel est émis pour une organisation et la nomme lui-même. Il n'ouvre que cette organisation : une requête qui en nomme une autre est refusée, même lorsque le propriétaire du jeton appartient aux deux.

Un déploiement mono-tenant n'a pas d'organisation à nommer. Aucune surface n'y lit un nom d'hôte, un nom de base de données ou un en-tête à cet effet.

Secrets entre organisations

Les secrets d'une organisation ne sont lus que par cette organisation. Dans le magasin propre de Provisa, chaque secret est conservé sous son organisation. Dans un service central (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) d'un déploiement de nombreuses organisations, chaque lecture se fait à l'intérieur du préfixe de l'organisation qui lit, et une référence qui en sortirait est refusée. Les préfixes et les étapes pour déplacer les secrets existants sous eux se trouvent dans Secrets.

Télémétrie entre organisations

Sur un déploiement partagé par de nombreuses organisations, les signaux opérationnels ("ops"."traces" et, sur le moteur Trino, les métriques et journaux à côté) forment un seul magasin où arrive le travail de chaque organisation. Chaque span enregistre l'organisation pour laquelle il a été démarré, conservée comme tenant_id, et chaque lecture des signaux ne renvoie que les lignes de l'organisation qui lit, quel que soit le rôle. Un span démarré hors du travail de toute organisation (le démarrage du serveur, par exemple) n'appartient à aucune et n'est lu par aucune. L'administrateur de la plateforme n'a accès aux données d'aucune organisation et n'en lit aucune via Provisa.

Les signaux opérationnels sont conservés indéfiniment sur les deux moteurs, comme le journal d'audit : la compaction réécrit les fichiers où ils sont stockés et n'en supprime aucune ligne. Sur Trino, ce sont les tables Iceberg du catalogue otel. Sur un moteur natif (DuckDB), le serveur dépose ses propres enregistrements de traces en fichiers Parquet dans son dossier de télémétrie et le moteur les lit sous le même nom, "ops"."traces", avec la vue de lignage des rafraîchissements "ops"."mv_refresh_lineage" par-dessus ; les deux sont lus selon la même règle par organisation.

Les signaux sont conservés en clair dans leur magasin. Une charge de travail qui exige plus qu'une frontière par organisation au sein d'un déploiement tourne sur un déploiement dédié ; une charge très sensible ou réglementée est auto-hébergée.

Travail en arrière-plan entre organisations

Un processus qui sert de nombreuses organisations exécute le travail en arrière-plan de chacune en tant que cette organisation. Le traitement des changements de chaque organisation (la boucle d'événements qui dépose les changements des sources et rafraîchit les vues qui les lisent), ses constructions de réplicas et les déclencheurs de table qu'ils lancent appartiennent à son propre runtime : un changement est traité par la tâche de sa propre organisation, lit sa source avec l'adresse et l'identifiant de cette organisation, et écrit dans le magasin de cette organisation. Chaque environnement d'une organisation détient son propre slot de réplication logique PostgreSQL et sa publication, nommés d'après l'organisation, l'environnement et la source, de sorte que deux organisations qui nomment une source de la même façon sur une même base lisent chacune leurs propres changements.

Résidence des données

Sur un déploiement à régions, une table peut nommer une région comme son domicile, et ses données personnelles y sont stockées et traitées.

  • Seule la région domicile construit et rafraîchit le réplica de la table. Toute autre région lit ce réplica sur place, avec les règles de ligne et de colonne du lecteur appliquées dans la région domicile, de sorte que n'en sort que ce que le lecteur peut voir.
  • Une région domicile dont le réplica n'est pas construit, ou dont les magasins sont injoignables, refuse la lecture (query.home_region_unavailable). La source n'est jamais lue en direct depuis une autre région à la place.
  • Un brouillon n'est prévisualisé que là où il vit. Une table en brouillon qui nomme une région n'est lue pour l'aperçu de son responsable que sur les nœuds de cette région et refusée ailleurs ; la copie que dépose l'aperçu y est conservée pour sa publication. Une copie qu'une région quelconque détient d'une table, brouillon ou publiée, dont le domicile est désormais une autre région est retirée puis supprimée.
  • Définir, changer ou retirer la région d'une table exige le droit data_residency pour les régions concernées, en plus de posséder la table par son domaine. Un déménagement peut passer par le brouillon, de sorte que la table n'est servie nulle part entre les deux.

Les règles de ligne et de colonne écrites sur la région du nœud qui répond (current_setting('provisa.region')) et sur l'attribut region des membres décident de ce qui passe d'une région à l'autre. Régions décrit en détail les régions, l'attribution de domicile et ces règles.

Durcissement du déploiement

Identifiants des magasins et où ils écoutent

Les magasins fournis n'ont aucun identifiant par défaut. Le plan de contrôle PostgreSQL, Redis, MinIO, le compte administrateur de Grafana et le secret partagé du cluster Trino reçoivent chacun une valeur générée pour le déploiement :

  • Les lanceurs (provisa start, les installeurs de bureau et de serveur) les génèrent une fois et les conservent dans ~/.provisa/store-credentials.env (mode 600). Une valeur déjà présente dans l'environnement est utilisée telle quelle, et un nœud secondaire, qui utilise les magasins du primaire, refuse de démarrer sans ceux du primaire.
  • Les modules Terraform les génèrent avec random_password et donnent les mêmes valeurs à chaque nœud. Ils sont conservés dans l'état Terraform, protégez donc l'état comme vous protégeriez les mots de passe.
  • Le chart Helm n'en définit aucun. Il exige un Secret que vous créez, désigné par credentials.existingSecret, contenant pg-password, redis-password, minio-access-key, minio-secret-key, grafana-admin-password et trino-shared-secret.

Redis exige son mot de passe, et le mot de passe du rôle PostgreSQL est redéfini à chaque démarrage pour qu'un volume créé auparavant suive le mot de passe actuel du déploiement. Les magasins, le moteur de fédération et les services de télémétrie publient leurs ports sur une seule adresse, PROVISA_STORE_BIND_IP : 127.0.0.1 sur un nœud isolé, ou l'adresse privée du primaire lorsque des secondaires s'y connectent. Le compte administrateur de Grafana reçoit le mot de passe généré ; la pile compose laisse des visiteurs anonymes consulter les tableaux de bord, et le chart Helm n'admet aucun visiteur anonyme.

Chiffrement du transport

Chaque listener que Provisa termine sert du TLS (voir Versions de TLS). Un nœud serveur utilise le certificat de PROVISA_TLS_CERT/PROVISA_TLS_KEY, ou en génère un auto-signé. Le chart Helm sert du TLS par défaut (tls.enabled: true) à partir du Secret kubernetes.io/tls que désigne tls.secretName.

Le PostgreSQL embarqué

Les niveaux bureau et natif exécutent PostgreSQL en mode embarqué. Sous macOS et Linux, il écoute sur un socket unix dans son répertoire de données (mode 700), que seul le compte propriétaire du répertoire atteint. Par TCP (Windows, où il écoute sur 127.0.0.1), chaque connexion doit fournir un mot de passe généré pour le répertoire de données et conservé dedans (provisa.password, mode 600). Il n'est jamais démarré en root.

Le moteur de fédération (Trino)

  • Entre nœuds. Un coordinateur et ses workers s'authentifient mutuellement avec le secret partagé généré du déploiement (internal-communication.shared-secret). Chiffrer leur trafic est une option de l'opérateur, désactivée par défaut : désactivée, il reste dans le réseau privé ou le cluster du déploiement. Activez-la là où une exigence de conformité ou de zéro confiance demande le chiffrement à l'intérieur du déploiement. Trino documente qu'elle ralentit les requêtes à fort shuffle (jointures distribuées, agrégations, fonctions de fenêtre) de 10 % à plus de 100 %. Sur un déploiement compose, c'est le paramètre engine.trino_internal_tls (Paramètres du déploiement → Moteur), avec le réseau du moteur et l'adresse épinglée du coordinateur à côté, et il s'applique au démarrage suivant. Sous le chart Helm, c'est la valeur trino.internalTls ; le chart génère l'autorité de certification et le certificat qu'utilisent les nœuds, les conserve d'une mise à niveau à l'autre et adresse chaque nœud par le nom DNS de son pod. Les moteurs de la plateforme hébergée sont des coordinateurs à nœud unique, sans trafic entre nœuds, et la refusent.
  • Données déversées. L'exécution tolérante aux pannes chiffre les données d'échange qu'elle déverse avec une clé générée pour chaque requête et supprimée à la fin de celle-ci (fault-tolerant-execution.exchange-encryption-enabled=true, défini explicitement). Le déversement sur disque hérité n'est pas utilisé.
  • Organisations sur un moteur. Les catalogues de chaque organisation lui appartiennent, nommés d'après l'organisation, et les instructions de chaque organisation s'exécutent dans son propre groupe de ressources.
  • Les catalogues, et les identifiants qu'ils contiennent, ne vivent que dans la mémoire du moteur. Provisa crée chaque catalogue Trino à l'exécution, et chacun contient les propriétés de connexion de sa source, mots de passe compris. Chaque moteur que Provisa exécute les garde en mémoire (catalog.store=memory) : aucun catalogue n'est écrit sur le disque du moteur, et aucun n'est monté depuis un fichier. Un coordinateur qui redémarre revient sans catalogues ; chaque processus Provisa le remarque à sa requête suivante, et l'observateur du moteur en moins d'une minute, et recrée les catalogues qu'il sert. Une source Kafka avec des identifiants SASL a besoin d'un fichier : le connecteur Kafka de Trino ne lit ses réglages client que dans un fichier, donc Provisa les y écrit, mot de passe compris, dans kafka-client/<catalog>.properties. Le fichier n'est lisible que par l'utilisateur de Trino (mode 0600 dans un répertoire 0700). Dans la pile de conteneurs, le répertoire est un volume en mémoire partagé par l'API et le moteur. Sur les moteurs de la plateforme hébergée, c'est le propre Secret Kubernetes du moteur, gardé dans la mémoire du nœud et lisible par le seul groupe de Trino (mode 0440). Dans les deux cas, le fichier n'atteint jamais un disque. Sur les moteurs hébergés, une source Kafka doit nommer un registre de schémas : une source dont seul le modèle déclare les colonnes des topics est refusée nommément à l'enregistrement ou à l'application, car ses descriptions de tables exigeraient un répertoire qu'on ne peut pas donner au moteur en cours d'exécution. Le démarrage de la pile supprime les fichiers de catalogue et de client Kafka que des versions antérieures ont laissés sur le disque.

Sources sur un déploiement partagé

Sur un déploiement qui héberge de nombreuses organisations, les sources d'une organisation ne peuvent atteindre que des adresses publiques : jamais le réseau privé de la plateforme, où se trouvent son plan de contrôle, son cache et son magasin d'objets. La vérification s'applique lorsqu'une source est enregistrée, par l'interface, l'API d'administration ou une application de configuration, puis de nouveau à chaque connexion et à chaque redirection HTTP. Les sources d'un bac à sable de démonstration sont en outre tenues à l'adresse à laquelle elles ont été vérifiées. L'organisation propre du déploiement, dont l'opérateur configure les sources, n'y est pas soumise.

Chiffrement au repos et clés propres de l'organisation

Le chiffrement sous une clé propre à l'organisation est disponible, et il reste désactivé tant que l'organisation ne l'active pas. Rien n'est chiffré sous une clé de l'organisation à moins qu'un administrateur de l'organisation n'en crée une.

La clé de chiffrement de l'organisation enveloppe les secrets de l'organisation, ses colonnes chiffrées et le texte des requêtes de son audit. Un administrateur de l'organisation la crée sous Admin → Org Secrets → Org Key, ou avec PUT /admin/org-encryption : sans corps, Provisa génère la clé, qui n'est jamais affichée, et un corps key_b64 fournit 32 octets de votre propre clé. GET /admin/org-encryption ne signale que son empreinte. Sans clé d'organisation, les données de l'organisation sont enveloppées avec la clé du déploiement (encryption.provider dans provisa.yaml). Un déploiement qui ne configure aucun fournisseur de chiffrement stocke les colonnes chiffrées et le texte des requêtes d'audit sans chiffrement ; les secrets ne sont jamais stockés en clair (voir Secrets).

Le dossier de l'organisation contient ses fichiers téléversés et déposés, les fichiers de déversement de son moteur et ses fichiers temporaires. Un administrateur de l'organisation active son chiffrement sous Admin → Org Secrets → File folder encryption, ou avec PUT /admin/org-files ({"encrypted": true, "key_reference": "${secret:NAME}"}). POST /admin/org-files/generate-key répond une seule fois avec une nouvelle clé de 256 bits, à conserver dans le coffre. La clé reste dans le coffre propre de l'organisation (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault ou HashiCorp Vault) : Provisa ne stocke que la référence, et gocryptfs garde chiffrés sur disque les fichiers du dossier sous cette clé. Cela requiert FUSE, donc un hôte ou conteneur Linux avec /dev/fuse ; sous macOS le paramètre est refusé.

Sur un déploiement auto-hébergé, qui exécute typiquement une organisation sur ses propres moteurs, activer les deux chiffre les secrets, fichiers, fichiers de déversement et fichiers temporaires de cette organisation sous ses clés :

  • Le moteur DuckDB qu'exécute une organisation déverse dans le dossier de l'organisation.
  • Les lignes qu'un filtre de lignes récupère et le code téléversé sont préparés dans le dossier de l'organisation.
  • L'exécution tolérante aux pannes de Trino chiffre les données d'échange déversées avec une clé générée pour chaque requête et supprimée à la fin de celle-ci (fault-tolerant-execution.exchange-encryption-enabled=true). Provisa n'active pas le déversement sur disque hérité de Trino.
  • Trino garde les catalogues de l'organisation, avec leurs identifiants, en mémoire et n'en écrit aucun sur le disque (catalog.store=memory) ; les réglages client SASL d'une source Kafka sont dans un fichier que seul l'utilisateur de Trino lit, en mémoire dans la pile de conteneurs (voir Le moteur de fédération).
  • Un moteur de fédération PostgreSQL écrit ses fichiers temporaires là où ce serveur PostgreSQL les conserve, sous le chiffrement de stockage propre au serveur, et non dans le dossier de l'organisation.

Plateformes hébergées partagées et dédiées

La plateforme hébergée partagée isole les organisations de façon logique. Un seul déploiement les sert toutes, et les données de chaque organisation lui restent propres grâce à la gouvernance, à ses propres schémas, catalogues et groupes de ressources sur le moteur, à ses propres clés, à des clés par requête pour les données déversées du moteur, et au refus de ses sources sur les adresses privées de la plateforme. Elle est recommandée pour l'expérimentation et les petites entreprises. Une plateforme hébergée dédiée est séparée physiquement : ses propres machines, réseau et magasins, au service d'un seul client. Pour les charges de travail très sensibles ou réglementées, auto-hébergez Provisa dans votre propre périmètre, où vos propres opérateurs détiennent chaque clé et chaque hôte. Les plateformes hébergées n'ont pas de rapport SOC 2. Si vous avez besoin de SOC 2, nous recommandons l'auto-hébergement.

Python dans le navigateur

Les onglets Python et Notebook d'Explore exécutent Python dans le navigateur du visiteur. Chaque lecture qu'ils font part vers le serveur en tant qu'utilisateur connecté, avec son rôle actif, son organisation et son environnement, et est gouvernée exactement comme la même instruction depuis n'importe quel autre client.

Mode haute sécurité

security.mode: high dans provisa.yaml affirme une garantie : le backend de Provisa ne manipule jamais de données en clair. Chaque colonne qui compte est chiffrée à la source, et seul un client détenant la clé de déchiffrement peut la lire. Cette garantie a des conséquences qu'un déploiement doit prévoir.

Ce que fait le mode :

  • Les endpoints de données exigent une preuve de déchiffrement côté client. Tout ce qui se trouve sous /data/ renvoie 403 sauf si l'appelant présente l'en-tête X-Provisa-KMS-Key, la marque d'un client JDBC ou Python configuré pour déchiffrer localement. Un navigateur ou un consommateur REST en clair ne porte pas une telle clé et est refusé. Le contrôle est un refus par défaut sur toute l'arborescence : une route ajoutée demain est protégée le jour de sa publication, et une exemption doit être justifiée.
  • Les endpoints de métadonnées de schéma restent ouverts. /data/sdl, /data/introspection, /data/schema-version, /data/domains, /data/proto et /data/compile ne renvoient aucune donnée de ligne, et un client doit lire le schéma (y compris les champs @encrypted) avant de pouvoir se connecter.
  • gRPC et Arrow Flight continuent de servir, sous la même preuve. Ce sont les transports qu'utilisent réellement les clients qui chiffrent ; les fermer laisserait un déploiement haute sécurité sans protocole filaire. Un appel de données sur l'un ou l'autre doit porter la même clé KMS en métadonnées d'appel.
  • pgwire, Bolt et MCP ne démarrent pas. Aucun des trois n'a de poignée de main par connexion capable de porter un contexte de déchiffrement : un ensemble de lignes pgwire et un résultat Cypher circulent en clair, et un appel d'outil MCP remet ses résultats à un modèle sous forme de texte. Un port configuré pour l'un d'eux est refusé au démarrage plutôt que servi.
  • La garde des relations ne peut pas être contournée. ignore_relationships et relationship_guard: false sont tous deux ignorés ; voir Gouvernance des relations.

Vérifier qu'un déploiement est dans le mode : le journal de démarrage le nomme, une requête /data/sql sans clé KMS répond 403 avec un message qui nomme le mode haute sécurité, et les ports pgwire, Bolt et MCP n'écoutent pas.

Point d'ancrage d'approbation ABAC

Un point d'ancrage de politique externe facultatif qui se déclenche avant l'exécution de la requête. Lorsqu'il est configuré, Provisa appelle votre moteur de politiques avec l'identité de l'utilisateur, les rôles, les tables, les colonnes et l'opération. La réponse détermine si la requête se poursuit. Une commande enregistrée avec Nécessite une approbation est soumise au même point d'ancrage avant chaque appel ; voir Appels de commandes.

Portée

Le point d'ancrage ne se déclenche que lorsqu'une requête touche une table ou une source incluse ; toute autre requête s'exécute donc sans surcoût.

Configuration Effet
auth.approval_hook.scope: all Chaque requête déclenche le point d'ancrage
sources[].approval_hook: true Toutes les tables de cette source déclenchent le point d'ancrage
tables[].approval_hook: true Cette table déclenche le point d'ancrage

Protocoles

Trois transports sont pris en charge :

Type Cas d'usage Champ de configuration
webhook Tout service de politique compatible HTTP (OPA, personnalisé) url
unix_socket OPA ou sidecar de politique sur la même machine socket_path + url
grpc Service de politique co-localisé à haut débit url (host:port)

Le transport gRPC utilise le contrat provisa.auth.ApprovalService défini dans provisa/auth/approval.proto. Implémentez ce service dans votre moteur de politiques :

service ApprovalService {
  rpc Evaluate (ApprovalRequest) returns (ApprovalResponse);
}

message ApprovalRequest {
  string user = 1;
  repeated string roles = 2;
  repeated string tables = 3;
  repeated string columns = 4;
  string operation = 5;
  map<string, string> session_vars = 6;
  string command = 7;         // a command call's name; empty for a query
  string arguments_json = 8;  // a command call's arguments as a JSON object
}

message ApprovalResponse {
  bool approved = 1;
  string reason = 2;
}

Le canal gRPC est persistant: un canal par instance de Provisa, réutilisé pour tous les appels vers cet endpoint du point d'ancrage.

Requête / réponse

Les trois transports portent la même charge utile :

Champ Type Description
user string Identité de l'utilisateur authentifié
roles string[] Rôles Provisa de l'utilisateur
tables string[] Identifiants des tables référencées dans la requête
columns string[] Colonnes sélectionnées dans la requête
operation string "query" ou "mutation" ; "command" pour un appel de commande
command string Le nom de la commande pour un appel de commande ; vide pour une requête
arguments object Les arguments avec lesquels une commande est appelée ; vide pour une requête

Les transports webhook et socket Unix échangent du JSON, avec command et arguments comme clés. Sur gRPC, les arguments voyagent comme le texte JSON arguments_json. La réponse doit inclure approved (bool) et éventuellement reason (string).

Appels de commandes

Une commande dont requires_approval est activé (l'interrupteur Nécessite une approbation du formulaire de commande) passe par le point d'ancrage avant chaque appel, sur toutes les surfaces. Elle ne s'exécute que si le point d'ancrage l'approuve. La requête porte operation: "command", le nom de la commande et ses arguments ; tables et columns sont vides.

{
  "user": "analyst",
  "roles": ["analyst"],
  "tables": [],
  "columns": [],
  "operation": "command",
  "command": "create_issue",
  "arguments": {"input": {"title": "Crash on save"}}
}

Deux refus, tous deux 403 :

  • functions.approval_unavailable : aucun point d'ancrage n'est configuré. Contrairement à une requête, une commande qui a besoin d'une approbation ne passe jamais sans lui.
  • functions.approval_denied : le point d'ancrage a répondu approved: false. Le motif figure dans le message.

Délai et repli

auth:
  approval_hook:
    type: grpc          # webhook | grpc | unix_socket
    url: "localhost:50051"
    timeout_ms: 500     # default 5000
    fallback: deny      # allow | deny — applied on timeout or error
    scope: ""           # "" = use per-table/per-source flags; "all" = every query

En cas de délai dépassé ou d'erreur de transport, la politique fallback s'applique. Un disjoncteur (par défaut : ouvert après 5 échecs consécutifs, semi-ouvert après 30 s) empêche les défaillances en cascade dues à un endpoint de point d'ancrage lent.

Exemple de configuration

auth:
  approval_hook:
    type: webhook
    url: "http://opa.internal:8181/v1/data/provisa/allow"
    timeout_ms: 300
    fallback: deny

sources:
  - id: analytics_pg
    approval_hook: true   # all tables on this source require hook approval

tables:
  - id: salary_data
    approval_hook: true   # this table always requires hook approval

Secrets

Les identifiants utilisent la syntaxe ${env:VAR_NAME}, résolue à l'exécution. Les mots de passe ne sont jamais stockés dans la base de données de configuration.

Pour le service de secrets complet (coffres, syntaxe des références et fournisseurs) voir Secrets.