/* 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> = { 11: "Lire les factures clients", 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", 32: "Créer/modifier les comptes bancaires", 111: "Lire les factures fournisseurs", 251: "Lire les utilisateurs", } as const; const READ_ONLY: ReadonlyArray = [11, 111, 121, 126, 241, 261, 31, 251, 16]; 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, 122, 262, 242, 14, 32], }, /** * 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 thirdparty creation, no product creation, no proposals — those are * rehearsed then applied by a human when they are genuinely needed. * * 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"], rights: [...READ_ONLY, 12, 14], }, } as const; export type ScopeName = keyof typeof SCOPES; /** Conventional login for a given scope + environment. One user per pair. */ 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; }