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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user