Files
erp/test/provisionAiUser.ts
T
arcodangeandClaude Opus 5 11c8c65d45 feat(test): scoped AI users — one user per (environment × scope), provisioned by script
provisionSandbox.ts granted `[12, 122, 262, 32, 111, 251]`: opaque numeric ids,
one fixed scope, one agent per environment. Three failures in the 2026-07/08
sessions came straight from that:

1. Production `ai_agent` — documented as READ-ONLY in AGENTS.md, ADR-0003 and
   every SKILL.md — actually holds 46 rights against 9 declared, including
   create/modify on customer AND supplier invoices, thirdparties, contacts,
   thirdparty payment details, proposals, plus THREE delete rights (proposals,
   events, and submit/delete documents in the GED).
2. Writing a payment, then a product, to production each required granting a
   right by hand and revoking it after. A privilege granted ad hoc under time
   pressure is worse than one nobody holds.
3. A sandbox refresh wiped the agent's proposal rights, because they had been
   granted manually and lived nowhere in code.

- scopes.ts declares three scopes with their purpose and allowed environments:
  `read` (both envs), `sandbox-write` (sandbox only), `prod-write` (production
  only, narrow: invoices + payments, what the gated promote apply actually
  does). resolveScope() refuses a scope on an environment it does not belong to.
  No scope grants DELETE — the ledger is append-only, deletion stays human.
- provisionAiUser.ts creates or aligns one user per (environment × scope), emits
  its API key to a gitignored 600 file, and has an --audit mode that diffs what a
  user HOLDS against what its scope DECLARES. Production writes require the
  guard.ts opt-in.
- The audit reads permission labels LIVE off /user/perms.php rather than trusting
  a catalogue in code: ids are stable per Dolibarr version, not across them, and
  an audit that cannot name what it found is not actionable.

findUserId is implemented locally rather than imported: the trunk's userSetup.ts
has one, but it is uncommitted WIP and a provisioning script must not depend on
someone's working tree.

Tooling only — no production rights were changed. The migration (create the
scoped users, repoint the promote pipeline, then strip the over-grants from
`ai_agent`) rotates credentials used by every read skill and is the operator's
call.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-08-09 18:13:21 +02:00

187 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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<number | undefined> {
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<Map<number, string>> {
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<number, string>();
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 "<libellé>" --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();
}