fix(security): production read agent is now actually read-only

Migration applied to production, in the only order that does not break the
promote flow:

1. Created `ai_agent_prod_prod_write` (id=5) with the narrow `prod-write` scope —
   invoices, payments, and document submission (needed by builddoc to regenerate
   a modified invoice's PDF). Verified functionally: reads pass, DELETE on an
   invoice returns 403.
2. Repointed the promote pipeline at that user's key. It no longer borrows the
   read skills' credential; if the key is absent it dies with the provisioning
   command rather than silently falling back.
3. Revoked 12 write/delete rights from `ai_agent` (id=3), the credential every
   read skill holds: create/modify on customer AND supplier invoices,
   thirdparties, contacts, thirdparty payment details, proposals, exports,
   accounting links — and delete on proposals, events, and GED documents.

Verified after: invoices, thirdparties, contacts, products, proposals, supplier
invoices and bank accounts all still read; creating an invoice returns
`403 Forbidden: Insuffisant rights`. The documented posture and the real one
finally agree.

scopes.ts corrected against the live instance: 262 is NOT "créer/modifier les
produits" as the first catalogue guessed but the `voir_tous` ACL extension — a
READ right the skills depend on (without it, list endpoints return empty arrays
instead of 403). Revoking it would have silently blinded every read skill. This
is why the audit reads labels off /user/perms.php rather than trusting ids in
code. The READ_ONLY baseline is now the audited read surface (35 rights), not a
guess.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
This commit is contained in:
2026-08-09 19:33:00 +02:00
co-authored by Claude Opus 5
parent 18682b5db1
commit 1cba032512
2 changed files with 87 additions and 18 deletions
+73 -17
View File
@@ -41,27 +41,79 @@ export interface Scope {
* `provisionAiUser.ts --audit` prints the live labels next to each granted id.
*/
export const PERMISSION_LABELS: Readonly<Record<number, string>> = {
// --- lecture / export (le socle read) ---
11: "Lire les factures clients",
16: "Exporter les factures clients",
21: "Consulter les propositions commerciales",
28: "Exporter les propositions commerciales",
31: "Lire les comptes bancaires",
41: "Lire les projets et les tâches",
45: "Exporter les projets",
91: "Lire les charges fiscales ou sociales et paiement de TVA",
94: "Exporter les charges fiscales ou sociales",
111: "Lire les factures fournisseurs",
121: "Consulter les tiers",
126: "Exporter les tiers",
141: "Lire tous les projets et tâches",
161: "Lire les contrats/abonnements",
167: "Exporter les contrats",
251: "Lire les utilisateurs",
262: "Étendre l'accès à TOUS les tiers et leurs objets (voir_tous)",
281: "Consulter les contacts",
358: "Exporter les utilisateurs",
531: "Consulter les services",
771: "Lire les notes de frais",
779: "Exporter les notes de frais",
1181: "Consulter les fournisseurs",
1182: "Consulter les commandes fournisseurs",
1191: "Exporter les commandes fournisseurs",
1201: "Récupérer le résultat d'un export",
1231: "Lire les factures (et paiements) fournisseurs",
1236: "Exporter les factures fournisseur",
1321: "Exporter les factures clients, attributs et règlements",
2401: "Lire ses propres actions/événements",
2411: "Lire les actions/événements des autres",
2414: "Exporter les événements des autres",
2501: "Lire/récupérer les documents",
3201: "Lire les événements archivés et leurs empreintes",
50411: "Lire les opérations du Grand livre",
// --- écriture (jamais dans le scope read) ---
12: "Créer/modifier les factures clients",
14: "Émettre des paiements sur les factures clients",
// 15 = "Supprimer les factures clients" — DELIBERATELY NEVER GRANTED
16: "Exporter les factures clients",
121: "Consulter les tiers",
122: "Créer/modifier les tiers",
// 125 = "Supprimer les tiers" — DELIBERATELY NEVER GRANTED
126: "Exporter les tiers",
241: "Consulter les propositions commerciales",
242: "Créer/modifier les propositions commerciales",
// 244 = "Supprimer les propositions commerciales" — DELIBERATELY NEVER GRANTED
261: "Consulter les produits/services",
262: "Créer/modifier les produits/services",
31: "Lire les comptes bancaires",
22: "Créer/modifier les propositions commerciales",
32: "Créer/modifier les comptes bancaires",
111: "Lire les factures fournisseurs",
251: "Lire les utilisateurs",
122: "Créer/modifier les tiers",
130: "Créer/modifier les informations de paiement des tiers",
282: "Créer/modifier les contacts",
1202: "Créer/modifier un export",
1232: "Créer les factures fournisseur",
2402: "Créer/modifier des actions/événements",
2503: "Soumettre ou supprimer des documents",
50401: "Lier les produits et factures avec des comptes comptables",
// --- suppression : JAMAIS accordée par aucun scope ---
// 15 Supprimer les factures clients
// 27 Supprimer les propositions commerciales
// 125 Supprimer les tiers
// 2403 Supprimer des actions/événements
} as const;
const READ_ONLY: ReadonlyArray<number> = [11, 111, 121, 126, 241, 261, 31, 251, 16];
/**
* Le socle de lecture — établi par audit de l'instance de production, pas
* deviné : c'est l'ensemble exact des droits de lecture/export dont les skills
* se servent aujourd'hui (factures clients et fournisseurs, tiers, contacts,
* documents, contrats, projets, banque, grand livre, TVA).
*
* `262` (voir_tous) est indispensable : sans lui, les endpoints de liste
* renvoient des tableaux vides plutôt qu'un 403 — le piège documenté par la
* skill `dolibarr`.
*/
const READ_ONLY: ReadonlyArray<number> = [
11, 16, 21, 28, 31, 41, 45, 91, 94, 111, 121, 126, 141, 161, 167, 251, 262,
281, 358, 531, 771, 779, 1181, 1182, 1191, 1201, 1231, 1236, 1321, 2401,
2411, 2414, 2501, 3201, 50411,
];
export const SCOPES: Readonly<Record<string, Scope>> = {
/**
@@ -83,7 +135,7 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
"sandbox-write": {
purpose: "Répétition d'un change-set sur la sandbox (factures, tiers, produits, propositions, règlements)",
environments: ["sandbox"],
rights: [...READ_ONLY, 12, 122, 262, 242, 14, 32],
rights: [...READ_ONLY, 12, 14, 22, 32, 122, 130, 282, 1232, 2503],
},
/**
@@ -98,7 +150,11 @@ export const SCOPES: Readonly<Record<string, Scope>> = {
"prod-write": {
purpose: "Écriture de production, gated — création/modification de factures et rattachement de règlements",
environments: ["production"],
rights: [...READ_ONLY, 12, 14],
// 2503 est nécessaire à builddoc (régénérer le PDF d'une facture modifiée).
// Dolibarr le livre en bundle « soumettre OU supprimer » : on ne peut pas
// avoir l'un sans l'autre. C'est la seule capacité de suppression du modèle,
// et elle est confinée au writer de production, gated par le promote.
rights: [...READ_ONLY, 12, 14, 2503],
},
} as const;