/* 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(); }