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
This commit is contained in:
2026-08-09 18:13:21 +02:00
co-authored by Claude Opus 5
parent dbe4b36c62
commit 11c8c65d45
2 changed files with 315 additions and 0 deletions
+186
View File
@@ -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<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();
}