Aller au contenu

Écrire et exploiter les politiques

Tout ce que la passerelle refuse ou autorise vient d’un seul artefact signé : le bundle de politiques. Cette page explique comment le rédiger, le modifier sans risque, et le récupérer après la perte d’une machine. Pour les raisons de cette conception, voir Politique & catalogue d’outils.

Deux propriétés gouvernent tout le flux de travail, et les deux sont délibérées :

  • La source de vérité est un checkout git, pas un serveur en fonctionnement. Les politiques sont des fichiers texte revus comme du code, compilés en un bundle signé estampillé du sha du commit. Rien n’est édité sur place sur un hôte de production.
  • La compilation est déterministe octet pour octet. Le même arbre, la même ref, la même clé produisent le même bundle. Une release peut donc être reproduite, comparée par empreinte, et reconstruite de zéro après un sinistre.
my-policies/ ← a git repository
├── policies/
│ ├── 00-base.cedar ← concatenated in lexicographic file order
│ ├── 10-finance.cedar
│ └── obsign.cedarschema ← generated; what your editor type-checks against
├── tools.json ← the signed catalogue
├── fail-mode.json ← what to do when the engine cannot decide
├── identity/ ← optional: who may mint identities
│ ├── provider.json ← issuer + audience
│ └── jwks.json ← the IdP's public keys
└── deployment/ ← optional: enrolled gateway origin keys
├── origin-keys.json
└── attestation.json ← optional: TPM enrollments (v3)

Seuls policies/ et tools.json sont obligatoires. Les préfixes numériques dans les noms de fichiers sont une convention. Comme les fichiers sont concaténés dans l’ordre lexicographique, ils gardent cet ordre lisible et stable. Seuls les fichiers *.cedar sont lus comme des règles, si bien que le schéma généré peut sans dommage se trouver à côté d’eux.

Chaque champ de chacun de ces fichiers (son type, son caractère obligatoire ou non, sa valeur par défaut, les valeurs qu’accepte une énumération, et l’erreur de compilation que produit une faute) est documenté dans L’arbre source des politiques, fichier par fichier. Cette page reste le récit : comment penser les règles et comment les exploiter.

Une règle est un permit/forbid portant sur un principal, une action, une ressource, gardé par une clause when sur le contexte.

Tout ce que décrit cette section existe aussi sous une forme lisible par une machine, comme un schéma Cedar dérivé de votre propre tools.json :

Fenêtre de terminal
obsign-control schema --source ./my-policies # → policies/obsign.cedarschema

Committez-le. obsign-control compile vérifie le typage de chaque règle contre lui et refuse de signer une règle qui lit quelque chose que la passerelle n’expose pas. Pointer votre éditeur sur ce même fichier vous donne ce contrôle au fil de la frappe. Voir Votre éditeur plus bas. Régénérez-le à chaque changement de tools.json ; --check échoue au lieu d’écrire, ce qui est exactement ce que vous voulez en CI.

Les principaux. User::"<subject>", avec Group::"<name>" comme parents, si bien que principal in Group::"dba" fonctionne, groupes imbriqués compris. Le sujet et les groupes viennent du jeton vérifié, mappés par la table de claims du bundle d’identité (les formes Keycloak, Entra et Okta fonctionnent sans aucune configuration). L’entité User ne porte aucun attribut : les permissions s’expriment par appartenance à un groupe ou par scopes, jamais par principal.<something>.

Les actions.

ActionDéclenchée parType de ressource
tool_calltools/callTool::"<name>"
resource_readresources/read, resources/subscribe, resources/unsubscribe, et completion/complete sur un modèle de ressourceResource::"<uri>"
prompt_getprompts/get, et completion/complete sur un promptPrompt::"<name>"
samplingsampling/createMessage initié par le serveurServer::"mcp://wrapped"
elicitationelicitation/create initié par le serveurServer::"mcp://wrapped"
notifynotifications/message initié par le serveurServer::"mcp://wrapped"

Server::"mcp://wrapped" est un littéral fixe, et non le nom du serveur du déploiement : ces canaux sont accordés par serveur, et la requête ne nomme aucun objet stable sur lequel s’indexer. Le --server-id de l’opérateur parvient aux règles sous la forme de context.server et entre dans chaque enregistrement, mais aucune ressource n’est indexée dessus. Rien de ce qu’un opérateur tape sur une ligne de commande ne décide un verdict que le bundle signé n’avait pas déjà décidé. Filtrez sur context.server si vous voulez une règle qui ne s’applique qu’à un seul déploiement ; n’attendez pas qu’une entité Server::"mcp://crm.internal" existe.

Les attributs de ressource. Seul Tool porte des attributs, et seulement parce que le catalogue le décrit : resource.destructive (bool), resource.server (chaîne), resource.required_scope (chaîne, vide en l’absence de scope). Resource et Prompt n’en ont aucun ; le serveur forge ces URI à l’exécution, il n’y a donc rien de signé à y rattacher. Arbitrez sur l’identifiant via context.target.

Le contexte, disponible pour chaque règle :

AttributTypeSignification
context.envchaîneenvironnement déclaré à la passerelle (--env : prod, staging, …)
context.serverchaînele serveur encapsulé tel que l’opérateur l’a nommé (--server-id) ; descriptif, jamais une clé de ressource
context.sessionchaîneidentifiant de session, également l’identifiant de la chaîne d’audit
context.scopesensemble de chaînesscopes accordés par la délégation
context.targetchaîneURI de ressource ou nom de prompt (actions de capacité)
context.principal_kindchaînehuman, delegated_human ou machine
context.has_human_delegationboolun humain identifiable se trouve à la racine de la chaîne
context.delegation_depthlongnombre de sauts de délégation (0 sans claim act)
context.actor_chainensemble de chaînesla chaîne RFC 8693 attestée
context.args.<name>selon le cataloguearguments d’appel déclarés — voir ci-dessous

Chaque règle a besoin d’un @id, et la compilation refuse une règle qui n’en a pas. Cet identifiant n’est pas décoratif : c’est lui qui entre dans l’enregistrement d’audit comme motif de l’autorisation ou du refus d’un appel. Une règle anonyme produit une décision inexplicable, ce qui ruine l’objet même du produit.

// Deny wins over permit, always. Start with what must never happen.
@id("forbid_destructive_prod")
forbid (principal, action == Action::"tool_call", resource)
when { resource.destructive && context.env == "prod" };
// Permission by scope, driven by the catalogue: one rule covers every tool
// that declares a required_scope.
@id("allow_scoped")
permit (principal, action == Action::"tool_call", resource)
when {
resource.required_scope != "" &&
context.scopes.contains(resource.required_scope)
};
// Permission by group (RBAC), narrowed by environment.
@id("allow_dba_nonprod")
permit (principal in Group::"dba", action == Action::"tool_call", resource)
when { context.env != "prod" };
// Nothing irreversible without a human behind the agent. The distinction
// comes from the token: a client_credentials token has no human at the root.
@id("forbid_robot_destructive")
forbid (principal, action == Action::"tool_call", resource)
when { resource.destructive && !context.has_human_delegation };
// Resource families, matched on the identifier.
@id("allow_public_docs")
permit (principal, action == Action::"resource_read", resource)
when { context.target like "docs://public/*" };
// Server-initiated channels are default-deny like everything else.
@id("allow_sampling_for_support")
permit (principal in Group::"support", action == Action::"sampling", resource);

Cedar est en refus par défaut : un acte que personne n’autorise est refusé. Vous n’avez jamais besoin d’un forbid fourre-tout, et vous ne devriez pas en écrire un. Il fait paraître conditionnel chaque permit qui suit, alors qu’il ne l’est pas.

Un outil absent du catalogue est refusé avant que Cedar ne s’exécute : la passerelle ne relaie pas ce qu’elle ne sait pas décrire. Le catalogue est aussi ce qui rend possibles les règles génériques, en attachant à chaque outil des métadonnées revues.

[
{
"name": "delete_production_db",
"server": "mcp://db",
"destructive": true,
"required_scope": "db:admin"
},
{
"name": "send_message",
"server": "mcp://chat",
"required_scope": "chat:write",
"policy_args": [
{ "name": "channel", "kind": "string" },
{ "name": "amount_cents", "kind": "long", "default": 0 }
]
}
]

destructive et required_scope sont à vous de définir ; le moteur ne fait que les exposer. Marquer un outil comme destructeur ne coûte rien et permet à une seule règle de protéger d’un coup tous les outils dangereux, y compris ceux ajoutés plus tard.

policy_args déclare quels arguments d’appel la politique peut lire. Cette liste d’autorisation est une frontière de confidentialité : ce qui n’est pas déclaré n’atteint jamais le moteur, et le journal conserve args_hash, jamais les valeurs.

@id("support_channel_only")
forbid (principal, action == Action::"tool_call", resource == Tool::"send_message")
when { context.args.channel != "#support" };
ChampSignification
namele nom sous context.args
kindstring, long (entiers uniquement — les flottants sont refusés, jamais arrondis), bool, string_set
atpointeur JSON dans les arguments de l’appel ; vaut /<name> par défaut
defaultinjecté lorsque l’appel omet l’argument

context.args est un espace de noms unique pour tout le catalogue, chaque appel d’outil étant la même action Cedar, si bien que deux outils ne peuvent pas donner deux types à un même nom. Déclarer amount en long sur un outil et en string sur un autre est une erreur de compilation qui nomme les deux ; renommez-en un, et servez-vous de at si le nom sur le fil doit rester tel quel.

Un argument déclaré sans valeur par défaut est obligatoire : un appel qui l’omet est refusé avant que Cedar ne s’exécute. C’est le sens sûr. Une règle qui lit un champ absent échouerait de toute façon en mode fermé, mais avec un message d’erreur bien pire.

Ce qui se passe quand le moteur ne peut pas décider (un bundle illisible, ou une règle qui lève une erreur d’évaluation) :

{ "default": "closed", "tools": { "search_docs": "open" } }

La valeur par défaut est closed, et un client qui veut autre chose le déclare explicitement, de sorte que cela apparaisse dans une pull request. Par outil, parce qu’il n’existe pas de bonne réponse universelle : bloquer une recherche en lecture seule casse la production pour rien, laisser passer une suppression est indéfendable.

Une dégradation n’est jamais silencieuse : un appel autorisé sous une règle en défaillance ouverte est enregistré comme AllowFailOpen, jamais comme un Allow franc.

Cedar est un langage qui a déjà un éditeur. AWS publie l’extension Cedar pour VS Code (cedar-policy.vscode-cedar), bâtie sur le même moteur Cedar 4.x auquel Obsign se lie : coloration syntaxique, formatage, plan de vos règles, aller à la définition sur les types d’entités et les noms d’actions ; et, dès qu’elle voit un schéma, pour l’essentiel la validation qu’exécute obsign-control compile, en direct, au fil de la frappe. (Pour l’essentiel, pas exactement : l’extension est stricte sur deux formes d’expression que compile accepte avec un avertissement ; voir Tester avant de déployer. Elle penche vers le refus, jamais vers l’acceptation, si bien qu’une règle qui lui plaît est toujours une règle signable.)

C’est ce dernier point qui justifie que le schéma soit un fichier committé. Générez-le, puis pointez l’extension dessus, dans .vscode/settings.json, à l’intérieur du dépôt de politiques :

{
"cedar.schemaFile": "policies/obsign.cedarschema",
"cedar.autodetectSchemaFile": true
}

Committez-le aussi. Un collègue qui clone le dépôt obtient une configuration qui fonctionne sans rien à régler, et c’est tout l’intérêt.

Concrètement, ce que vous y gagnez : context.enviroment et principal.department sont soulignés en rouge plutôt que découverts en production sous la forme d’un événement de mode de défaillance ; context.args. complète avec les arguments que votre catalogue déclare réellement, et leurs types ; Server::"mcp://crm.internal" est refusé, parce que la seule entité de ce type est le littéral fixe.

Deux choses à savoir :

  • L’éditeur ne fait pas autorité. Il lit le schéma sur le disque, qui est un fichier généré : s’il est périmé, l’extension se trompe avec aplomb. obsign-control schema --source . --check en CI est le garde-fou, et le chemin de signature (compile) régénère lui-même le modèle depuis tools.json et ne fait jamais confiance au fichier.
  • Sites air-gap : emportez le .vsix. Le Marketplace n’est pas joignable depuis un réseau cloisonné. Téléchargez une fois le paquet de l’extension, transportez-le avec le reste, et installez-le avec code --install-extension cedar-*.vsix. Elle ne fait aucun appel réseau de son côté ; la validation est locale.

Ni l’extension ni le schéma ne sont nécessaires pour rédiger des politiques : compile attrape les mêmes erreurs, et c’est lui qui décide. L’éditeur ne fait que ramener la découverte de quelques minutes à quelques secondes.

Fenêtre de terminal
# Regenerate the schema after any change to tools.json, and commit it.
obsign-control schema --source ./my-policies
# Compile only — signed artifacts in ./out, nothing published.
obsign-control compile --source ./my-policies \
--key ./ops-key.hex --key-id ops-2026 --out ./out
# Compile and publish an immutable release the gateways read.
obsign-control publish --source ./my-policies \
--key ./ops-key.hex --key-id ops-2026 --dist /srv/obsign/dist

schema n’a besoin d’aucune clé de signature : il dérive le modèle depuis tools.json et écrit un fichier, rien de plus. En CI, lancez-le avec --check ; il n’écrit rien et sort avec un code non nul si le schéma committé ne correspond plus au catalogue, ce qui est le seul moyen d’empêcher un schéma périmé d’induire un éditeur en erreur en silence.

--key-id vaut ops-key par défaut. Nommez-le tout de même : l’identifiant est lié pour de bon à ce matériel de clé, c’est donc lui qu’une rotation doit changer. Une publication qui garde l’identifiant alors que le fichier de clé a changé est refusée.

Le libellé de version vaut par défaut le sha court de HEAD, et la compilation refuse d’estampiller ce sha sur un arbre de travail modifié : une citation policies@<sha> dans un enregistrement d’audit doit désigner les octets que ce commit contient. Utilisez --label pour un arbre qui n’est pas dans git.

policies/obsign.cedarschema est exempté de ce refus, et lui seul : le schéma est dérivé, ses octets n’entrent jamais dans le bundle signé, et compile reconstruit le modèle depuis tools.json plutôt que de lire le fichier. Les deux commandes ci-dessus s’enchaînent donc (régénérer, puis compiler) sans commit entre les deux. Une règle .cedar modifiée reste, elle, un arbre de travail modifié.

Le répertoire de distribution :

dist/
├── policy-bundle.json ← current, atomically replaced
├── identity-bundle.json ← current
├── deployment-bundle.json ← current (when the tree has deployment/)
├── manifest.json ← current, signed
├── trusted-keys.json ← accumulated ops public keys
└── releases/<version>/ ← immutable history, one directory per version

Publier deux fois le même arbre est idempotent. Un répertoire de version est écrit une seule fois et jamais réécrit ; le rollback consiste à republier un sha plus ancien. Un identifiant de clé ne peut pas être relié à un autre matériel de clé : une rotation signifie un nouvel identifiant.

Un changement de politique exige un redémarrage de la passerelle

Section intitulée « Un changement de politique exige un redémarrage de la passerelle »

La passerelle lit le bundle de politiques une seule fois, au démarrage, et en vérifie la signature contre --trusted-keys avant de le charger. Publier un nouveau bundle ne change rien au comportement d’une passerelle en cours d’exécution : redémarrez-la (ou faites tourner vos conteneurs) pour que le changement prenne effet.

C’est l’inverse du bundle d’identité, qui, lui, est relu à l’exécution : une rotation de clé de l’IdP ne doit pas exiger de redémarrage, et chaque rechargement, appliqué ou rejeté, entre dans le journal sous la forme d’un enregistrement config_reload.

Planifiez vos changements en conséquence : une mise en production de politique est un déploiement.

L’ordre compte la première fois que vous déclarez des arguments

Section intitulée « L’ordre compte la première fois que vous déclarez des arguments »

Le plan de contrôle émet le format de bundle /2 dès qu’un outil déclare policy_args, et une passerelle non mise à niveau refuse un bundle /2 au démarrage plutôt que d’appliquer en silence moins que ce que le bundle prescrit. Donc : mettez d’abord à niveau toutes les images de passerelle, publiez ensuite le bundle qui déclare des arguments. Une flotte qui ne déclare jamais d’arguments continue de recevoir /1 et n’a besoin de rien.

Compilez d’abord. La plupart des erreurs sont des erreurs de compilation, par conception : une règle sans @id, un outil en double, une surcharge de fail-mode nommant un outil inexistant, un JWKS inutilisable, et toute règle qui lit un attribut que le modèle ne porte pas.

Cette dernière catégorie était autrefois invisible jusqu’en production. Une règle qui dit when { principal.department == "eng" } s’analyse, compile, part en production, puis lève une erreur d’évaluation à chaque appel qu’elle garde, ce qui bascule sur le mode de défaillance. Sous "default": "open", cela donne un forbid qui n’interdit jamais rien, et le journal enregistre AllowFailOpen pour une règle que son auteur croit appliquée. Le contrôle de typage refuse de la signer, quel que soit le mode de défaillance : celui-ci répond à « le moteur n’a pas pu décider », il ne répond pas à « cette règle ne veut rien dire ».

Ce refus signifie toujours que la règle ne fait rien aujourd’hui : elle lève une erreur à chaque appel qu’elle garde, si bien que la supprimer ou la corriger ne change aucune application en vigueur. Cela compte quand vous tombez dessus en plein incident : le dépôt n’est pas bloqué, la règle était déjà inerte.

Ce contrôle est la validation stricte propre à Cedar, avec deux constats délibérément rétrogradés en avertissements, parce que le mode strict contraint aussi la forme d’une expression pour que les politiques restent analysables automatiquement, et une règle peut échouer là-dessus tout en s’évaluant parfaitement :

// Accepted, and it works. Strict Cedar wants a literal in `ip()`.
@id("private_ranges_only")
forbid (principal, action == Action::"tool_call", resource == Tool::"connect")
when { !ip(context.args.src).isInRange(ip("10.0.0.0/8")) };

Il en va de même pour un littéral d’ensemble vide []. Les deux compilent, les deux sont affichés sous la forme [control] warning: …, et aucun des deux n’est silencieux. L’alternative aurait été de supprimer une capacité qui fonctionne : like "10.*" n’est pas équivalent à un CIDR, il ne sait pas exprimer une frontière /12 ou /24.

Une conséquence pour l’éditeur : l’extension Cedar valide strictement et soulignera ces deux formes là où obsign-control compile les accepte. Le désaccord ne va jamais que dans ce sens (l’éditeur est plus strict que le signataire, jamais l’inverse), si bien qu’une règle que votre éditeur accepte est toujours une règle signable.

Exercez ensuite les vrais binaires contre un WAL jetable, ce que fait le démarrage rapide. Provoquez les appels qui vous intéressent, et lisez les décisions dans le journal :

Fenêtre de terminal
obsign-ledger export --wal /tmp/t/wal --chain-id test \
--store /tmp/t/ledger --out /tmp/t/evidence.json
python3 - <<'EOF'
import json
for r in json.load(open('/tmp/t/evidence.json'))['records']:
p = r['payload']
if p.get('kind') == 'decision':
print(p['outcome'], p.get('policy_id'), p.get('reason'))
EOF

La colonne policy_id est tout l’intérêt : elle dit quelle règle a décidé, si bien qu’un appel autorisé par une règle à laquelle vous ne pensiez pas se voit immédiatement.

La question à laquelle il faut répondre n’est pas « puis-je redémarrer la passerelle » mais « puis-je encore montrer, dans deux ans, ce que contenait policies@a3f19c2 ? » Chaque enregistrement de décision cite la version du bundle qui l’a produite. Perdez l’arbre qui a produit cette version, et la piste d’audit pointe vers quelque chose que plus personne ne peut relire.

ArtefactReproductible ?Comment le protéger
Arbre source des politiques— c’est la sourceUn remote git. Poussez-le. C’est ça, la sauvegarde.
Clé de signature opsNonLe seul secret irremplaçable — voir ci-dessous
Fichiers courants de dist/Oui, depuis la source + la cléRecompilez ; déterministe, identique à l’octet près
dist/releases/<sha>/Oui, si vous avez gardé l’arbre et la cléSauvegardez-le quand même : c’est le relevé de ce qui a réellement été publié, et c’est petit
trusted-keys.jsonAccumulé au fil du tempsSauvegardez-le avec votre configuration ; une passerelle en a besoin pour faire confiance aux bundles

La perdre n’invalide rien de ce qui est déjà signé : la vérification utilise la moitié publique, qui vit dans trusted-keys.json et à l’intérieur de chaque pack. Ce que vous perdez, c’est la capacité à signer de nouveaux bundles.

La reprise est une rotation : générez une nouvelle clé sous un nouvel identifiant de clé (relier un ancien identifiant est refusé), republiez, et distribuez le trusted-keys.json mis à jour à toute la flotte. Prévoyez que ce soit un changement de configuration à l’échelle de la flotte : c’est exactement pourquoi la clé a sa place dans un KMS/HSM en production, et pourquoi la forme « graine dans un fichier » est documentée comme réservée au développement.

  1. L’arbre source vit dans un dépôt git doté d’au moins un remote. Pas « une copie sur la VM » : un remote, sur une infrastructure qui tombe en panne indépendamment. Sur un site air-gap, cela veut dire une deuxième machine et une étape de miroir documentée.
  2. La clé ops ne vit jamais uniquement sur la machine qui l’utilise. HSM en production. Si une graine dans un fichier est inévitable pendant un pilote, gardez-en une copie hors ligne sous scellé, et traitez sa perte comme un exercice de rotation plutôt que comme une catastrophe.
  3. dist/ est sauvegardé avec votre configuration — petit, peu changeant, et il permet de répondre à « qu’est-ce qui était publié à cette date ? » sans reconstruction.
  4. Le WAL, le store du ledger et les packs de preuves ont leur propre procédure : Sauvegarde, restauration & rétention. Ceux-là protègent la preuve ; cette page protège la capacité à l’expliquer.
Fenêtre de terminal
git clone <remote> my-policies && cd my-policies
git checkout <the sha an audit record cites> # e.g. a3f19c2
obsign-control compile --source . \
--key ./ops-key.hex --key-id ops-2026 --out ./rebuilt
sha256sum rebuilt/policy-bundle.json

L’identifiant de clé doit être celui qui a signé la release d’origine, et non celui du jour : il fait partie de ce qui a été signé, donc une reconstruction sous un autre identifiant produit un autre artefact et la comparaison d’empreinte ci-dessous ne prouve rien.

Parce que la compilation est déterministe, cette empreinte correspond à l’artefact publié à l’origine, ce qui transforme « nous pensons que la règle disait ceci » en une affirmation vérifiable. Faites-le une fois en exercice, avant d’en avoir besoin : cela prouve que votre remote, la garde de votre clé et vos libellés de version fonctionnent bien ensemble.

SymptômeCauseQue faire
tool "x" absent from signed cataloguel’outil n’est pas dans tools.jsonajoutez-le et republiez — le refus, c’est la fonctionnalité
compilation : attribute … not found / attribute … on entity type … not foundune règle lit quelque chose que le modèle n’expose pas (p. ex. principal.permissions)utilisez context.scopes ou un groupe ; voir le tableau du modèle plus haut
warning: … extension constructors may not be called with non-literal expressions (accepted: …)ip(context.args.x) — compile et fonctionne ; votre éditeur le signalera quand mêmerien à faire ; c’est une remarque sur la forme stricte de Cedar
compilation : … is not declared as a valid eidune règle indexée sur Server::"<your server>"la clé de ressource est le littéral fixe ; filtrez plutôt sur context.server
obsign.cedarschema is out of date with tools.jsonle catalogue a changé, le schéma nonobsign-control schema --source . puis committez
evaluation failed, fail-closed: …une règle qui passe le typage mais lève une erreur sur certaines entrées (dépassement i64)resserrez l’expression ; un outil aux arguments déclarés refuse plutôt que de basculer en défaillance ouverte
compilation : règle sans @idune règle anonymenommez-la — l’identifiant est le motif d’audit
la compilation refuse le shades changements non commitéscommittez, ou passez --label
key id "…" already recorded with different key materialle fichier de clé a été remplacé alors que --key-id restait le mêmeune rotation prend un nouvel identifiant ; l’ancienne clé publique reste dans trusted-keys.json, donc les releases qu’elle a signées se vérifient toujours
la passerelle refuse de démarrer sur un bundle /2passerelle plus ancienne que le format de bundlemettez d’abord les passerelles à niveau, puis publiez
un changement reste sans effetla politique est lue au démarrageredémarrez la passerelle
AllowFailOpen dans le journalle moteur n’a pas pu décider et le mode de défaillance disait opencorrigez la règle ; la dégradation est visible à dessein