diff --git a/test/provisionAiUser.ts b/test/provisionAiUser.ts new file mode 100644 index 0000000..a3977e6 --- /dev/null +++ b/test/provisionAiUser.ts @@ -0,0 +1,186 @@ +/* + Provision an AI agent user with a declared scope — one user per (environment × scope). + + Replaces the single hardcoded grant in provisionSandbox.ts. Scopes live in + scopes.ts so a reader can audit what an agent may do without opening Dolibarr, + and so a sandbox refresh can restore the exact same rights instead of losing + hand-granted ones. + + Usage: + # audit what a user actually holds vs what its scope declares (no writes) + deno run -A test/provisionAiUser.ts --scope read --env sandbox --audit + + # create (or align) the user and emit its API key + DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \ + deno run -A test/provisionAiUser.ts --scope sandbox-write --env sandbox + + # production requires the explicit opt-in from guard.ts, twice over + DOLIBARR_ADDRESS=https://erp.arcodange.lab \ + ARCO_ALLOW_PRODUCTION=erp.arcodange.lab \ + ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \ + deno run -A test/provisionAiUser.ts --scope prod-write --env production + + The API key is written to a gitignored file, never printed. +*/ +import "load_dotenv"; +import { chromium } from "playwright"; +import login from "./scripts/login.ts"; +import userSetup from "./scripts/admin/userSetup.ts"; +import { assertSandbox } from "./scripts/guard.ts"; +import { loginFor, PERMISSION_LABELS, resolveScope } from "./scopes.ts"; + +const argv = Deno.args; +const pick = (flag: string, dflt = "") => + argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : dflt; + +const scopeName = pick("--scope"); +const env = pick("--env", "sandbox") as "sandbox" | "production"; +const auditOnly = argv.includes("--audit"); +const userLogin = pick("--login") || loginFor(scopeName, env); + +if (!scopeName) { + console.error("--scope is required (see test/scopes.ts for the declared scopes)"); + Deno.exit(2); +} +const scope = resolveScope(scopeName, env); + +// guard.ts refuses anything that is not the sandbox unless the production +// opt-in is set explicitly — provisioning a prod writer is a deliberate act. +const dolibarrAddress = assertSandbox(); +if (env === "production" && dolibarrAddress.includes("erp-sandbox")) { + console.error("--env production but the address is the sandbox — refusing."); + Deno.exit(2); +} + +console.log(`scope : ${scopeName} — ${scope.purpose}`); +console.log(`target : ${dolibarrAddress} (${env})`); +console.log(`user : ${userLogin}`); +console.log(`rights : ${scope.rights.length} declared`); +for (const r of scope.rights) { + console.log(` ${String(r).padStart(4)} ${PERMISSION_LABELS[r] ?? "(libellé inconnu — vérifier)"}`); +} + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ locale: "fr-FR" }); +const page = await context.newPage(); +const globalCtx = { + page, + dolibarrAddress, + imgFolderPath: "", // required by UserCtx (forms.fillForm signature); no file input here + adminCredentials: { + username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", + password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined", + }, +}; + +/** + * Find a user id by login, from the user list. Implemented here rather than + * imported: the trunk's userSetup.ts has a findUserId, but it is uncommitted + * work-in-progress — a provisioning script must not depend on someone's WIP. + */ +async function findUserId(userLogin: string): Promise { + await page.goto(`${dolibarrAddress}/user/list.php?mode=&search_user=${encodeURIComponent(userLogin)}`); + const href = await page.locator(`a[href*="/user/card.php?id="]`).evaluateAll( + (as: { textContent: string | null; getAttribute(n: string): string | null }[], want: string) => { + for (const el of as) { + if ((el.textContent ?? "").trim() === want) return el.getAttribute("href"); + } + return null; + }, + userLogin, + ); + const m = href?.match(/id=(\d+)/); + return m ? Number(m[1]) : undefined; +} + +/** + * What the user actually holds right now, read off the perms page — id AND the + * live label. Reading the label matters: a catalogue in code goes stale across + * Dolibarr versions, and an audit that cannot name what it found is not + * actionable. The live page is the authority. + */ +async function readGrantedRights(userId: number): Promise> { + await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`); + const rows = await page.locator('tr:has(a[href*="action=delrights"])').evaluateAll( + (trs: { textContent: string | null; querySelector(s: string): { getAttribute(n: string): string | null } | null }[]) => + trs.map((tr) => ({ + href: tr.querySelector('a[href*="action=delrights"]')?.getAttribute("href") ?? "", + text: (tr.textContent ?? "").replace(/\s+/g, " ").trim(), + })), + ); + const held = new Map(); + for (const r of rows) { + const m = r.href.match(/rights=(\d+)&/); + if (!m) continue; + // strip the trailing action words Dolibarr renders in the same row + const label = r.text.replace(/(Tout \/ Aucun|Retirer|Ajouter)\s*$/i, "").trim(); + held.set(Number(m[1]), label.slice(0, 80) || "(libellé illisible)"); + } + return held; +} + +try { + await login.doAdminLogin(globalCtx); + console.log(`\nconnecté comme ${await login.whoAmI({ page })}`); + + let userId = await findUserId(userLogin); + + if (auditOnly) { + if (!userId) { + console.log(`\nAUDIT — l'utilisateur '${userLogin}' n'existe pas sur ${env}.`); + Deno.exit(0); + } + const held = await readGrantedRights(userId); + const declared = new Set(scope.rights); + const missing = [...declared].filter((r) => !held.has(r)); + const extra = [...held.keys()].filter((r) => !declared.has(r)); + console.log(`\nAUDIT — utilisateur ${userLogin} (id=${userId})`); + console.log(` détenus : ${held.size} · déclarés : ${declared.size}`); + if (missing.length) { + console.log(` MANQUANTS (le scope les déclare, l'utilisateur ne les a pas) :`); + for (const r of missing) console.log(` ${r} ${PERMISSION_LABELS[r] ?? "?"}`); + } + if (extra.length) { + console.log(` EN TROP (détenus hors scope — c'est ce qui rend la doctrine fausse) :`); + for (const r of extra) console.log(` ${String(r).padStart(6)} ${held.get(r)}`); + } + if (!missing.length && !extra.length) console.log(" conforme au scope déclaré."); + Deno.exit(missing.length || extra.length ? 1 : 0); + } + + if (!userId) { + const pwd = crypto.randomUUID(); + userId = await userSetup.createUser(globalCtx, { + login: userLogin, + password: pwd, + lastname: `AI Agent (${env}/${scopeName})`, + admin: false, + }); + console.log(`utilisateur créé : ${userLogin} (id=${userId})`); + } else { + console.log(`utilisateur existant : ${userLogin} (id=${userId})`); + } + + await userSetup.assignRights(globalCtx, userId, [...scope.rights]); + const held = await readGrantedRights(userId); + const missing = scope.rights.filter((r) => !held.has(r)); + const extra = [...held.keys()].filter((r) => !scope.rights.includes(r)); + console.log(`\ndroits après application : ${held.size} détenus`); + if (missing.length) console.log(` ⚠️ non appliqués : ${missing.join(", ")}`); + if (extra.length) { + // Not auto-revoked: removing a right an operator granted on purpose is not + // this script's call. Surface it and let a human decide. + console.log(` ⚠️ hors scope, à examiner :`); + for (const r of extra) console.log(` ${String(r).padStart(6)} ${held.get(r)}`); + console.log(` retrait : deno run -A test/grantAgentRight.ts --user-id ${userId} --match "" --revoke`); + } + + const apiKey = await userSetup.generateApiKey(globalCtx, userId); + const keyFile = `.${userLogin}.key`; + await Deno.writeTextFile(keyFile, apiKey + "\n"); + await Deno.chmod(keyFile, 0o600); + console.log(`\nclé API écrite dans test/${keyFile} (mode 600, gitignoré) — jamais affichée.`); +} finally { + await context.close(); + await browser.close(); +} diff --git a/test/scopes.ts b/test/scopes.ts new file mode 100644 index 0000000..99eab78 --- /dev/null +++ b/test/scopes.ts @@ -0,0 +1,129 @@ +/* + 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; +}