Merge pull request 'feat(test): scoped AI users — one user per (environment × scope), provisioned by script' (#80) from arcodange/prod-apply into main

This commit was merged in pull request #80.
This commit is contained in:
2026-08-09 18:13:53 +02:00
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();
}
+129
View File
@@ -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<number>;
}
/**
* 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<Record<number, string>> = {
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<number> = [11, 111, 121, 126, 241, 261, 31, 251, 16];
export const SCOPES: Readonly<Record<string, Scope>> = {
/**
* 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;
}