feat(test): scoped AI users — one user per (environment × scope), provisioned by script #80
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user