/* Scopes for AI agent users — least privilege, declared once, provisioned by script. Why this exists: `provisionSandbox.ts` granted `[12, 122, 262, 32, 111, 251]` — opaque numeric ids, one fixed scope, one user per environment. Three failures in the 2026-07/08 sessions came straight from that: 1. Production `ai_agent` — documented everywhere as READ-ONLY — actually held create/modify on invoices and thirdparties, and DELETE on proposals. 2. Writing a payment to production required granting a right by hand and revoking it after. Twice. A privilege held by nobody is safer than a privilege granted ad hoc under time pressure. 3. A sandbox refresh wiped the agent's proposal rights because they had been granted manually and lived nowhere in code. The model: one user per (environment × scope), never a single all-powerful agent. A session is handed the credentials matching what it is allowed to do, and nothing else. Rule that admits no exception: **no scope grants DELETE.** The ledger is append-only — a wrong entry is offset by a credit note, never removed. Deletion stays with the human admin. */ /** A scope is a set of Dolibarr permission ids, with the reason it exists. */ export interface Scope { /** What this scope is for, in one line — read by the operator at provisioning. */ purpose: string; /** Which environments may hold a user with this scope. */ environments: ReadonlyArray<"sandbox" | "production">; /** Dolibarr permission ids (see PERMISSION_LABELS for what each one is). */ rights: ReadonlyArray; } /** * Dolibarr permission ids used below, with their fr_FR label — so a reader can * audit a scope without opening the Dolibarr admin UI. * * VERIFY BEFORE TRUSTING: these ids come from the running instance * (`/user/perms.php`) and are stable per Dolibarr version, not across versions. * `provisionAiUser.ts --audit` prints the live labels next to each granted id. */ export const PERMISSION_LABELS: Readonly> = { // --- 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", 40001: "Lire les devises et leurs taux", // --- écriture (jamais dans le scope read) --- 12: "Créer/modifier les factures clients", 14: "Émettre des paiements sur les factures clients", 22: "Créer/modifier les propositions commerciales", 32: "Créer/modifier les comptes bancaires", 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", 162: "Créer/modifier les contrats/abonnements", 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; /** * 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 = [ 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, 40001, 50411, ]; export const SCOPES: Readonly> = { /** * The default. Every agent session that only needs to look at the books gets * this one — audits, reconciliation reads, TVA preparation, reporting. * Valid on production BECAUSE it cannot change anything. */ read: { purpose: "Lecture seule — audits, réconciliation, préparation TVA, reporting", environments: ["sandbox", "production"], rights: READ_ONLY, }, /** * Rehearsals. Broad write on the sandbox, because that is what the sandbox is * for: a disposable copy where a change-set is tried before a human sees it. * Refused on production by the environment list AND by the host guard. */ "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, 14, 22, 32, 122, 130, 162, 282, 1232, 2503], }, /** * The narrow production writer, used ONLY by the gated promote apply, and only * for what that step actually does: create/modify invoices, attach payments. * No product creation, no proposals — those are rehearsed then applied by a * human when they are genuinely needed. * `122` (thirdparties) WAS excluded on that same reasoning, and the reasoning * did not survive contact: a supplier invoice needs a supplier, so the scope * could create the document but not the party it names. The exclusion turned * every new supplier into a manual step in the middle of a gated flow — which * is where discipline erodes. * `162` (contracts) IS included: recording the engagement behind an invoice is * what the piste d'audit fiable asks for, and it recurs (Darnis, then KM). * * Provisioning this user is itself a deliberate act: `--env production` * requires the explicit opt-in in guard.ts. */ "prod-write": { purpose: "Écriture de production, gated — création/modification de factures et rattachement de règlements", environments: ["production"], // 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. // 1232 (factures fournisseur) : oubli initial. Le scope dit « création/ // modification de factures » — les factures fournisseur en sont, et leur // saisie est l'opération la plus courante du back-office. Ajouté après un // 403 en production sur la facture Darnis F1048. // // 122 (tiers) : ajouté sur décision de l'opérateur du 2026-08-15, après un // second 403 — cette fois sur Anthropic PBC, entité américaine distincte // d'Anthropic Ireland, dont la facture ne pouvait être saisie faute de // pouvoir créer le fournisseur qu'elle nomme. // // CE QUE CE DROIT COÛTE, et il faut le dire : Dolibarr le libelle // « Créer/MODIFIER les tiers ». Il n'existe pas de droit de création seule. // Le writer de production peut donc désormais altérer l'identité de // n'importe quel tiers existant — nom, adresse, identifiants fiscaux — et // non seulement en créer. C'est la contrepartie assumée : sans lui, toute // facture d'un fournisseur nouveau s'arrête au milieu du flux gated. // La suppression (125) reste exclue, comme dans tous les scopes. rights: [...READ_ONLY, 12, 14, 122, 162, 1232, 2503], }, } as const; export type ScopeName = keyof typeof SCOPES; /** * Conventional login for a given scope + environment. One user per pair. * * The scope name is kept verbatim, so `prod-write` on production reads * `ai_agent_prod_prod_write`. Redundant, deliberately left alone: renaming a * provisioned production credential means creating a second privileged user and * repointing the promote flow — churn for cosmetics. Don't "fix" it. */ export function loginFor(scope: string, env: "sandbox" | "production"): string { return env === "sandbox" ? `ai_agent_sandbox_${scope.replace(/-/g, "_")}` : `ai_agent_prod_${scope.replace(/-/g, "_")}`; } /** Fail loudly rather than provision something nobody declared. */ export function resolveScope(name: string, env: "sandbox" | "production"): Scope { const s = SCOPES[name]; if (!s) { throw new Error( `unknown scope '${name}'. Declared scopes: ${Object.keys(SCOPES).join(", ")}`, ); } if (!s.environments.includes(env)) { throw new Error( `scope '${name}' is not allowed on '${env}' (allowed: ${s.environments.join(", ")}).\n` + " Scopes are per-environment by design — a sandbox writer has no business holding\n" + " production credentials, and vice versa.", ); } return s; }