Completes the role model on the sandbox side, and closes the third failure of the 2026-07/08 sessions: a refresh wiped hand-granted rights and nothing recorded them, so the sandbox silently lost capabilities nobody had written down. - Provisioned `ai_agent_sandbox_read` (36 rights) and `ai_agent_sandbox_sandbox_write` (44 rights) from test/scopes.ts. Verified functionally on the live sandbox: the reader reads and gets 403 on invoice creation; the writer creates a draft and gets 403 on DELETE. - checkpoint-provision.sh now re-creates both scoped agents after every refresh, so their rights come from code rather than from someone's memory. Failure to provision a scope warns instead of aborting the whole checkpoint. - checkpoint-relink-env.sh points the write skill at the scoped writer key, falling back to the legacy single-user key so an older checkout still works. The write skill now operates as ai_agent_sandbox_sandbox_write (id 6). Smoke-tested end to end after the credential swap: the promote pipeline rehearses on the sandbox under the scoped writer, and `apply` still refuses without a recorded human gate. The redundant `ai_agent_prod_prod_write` login is documented as deliberate: renaming a provisioned production credential means creating a second privileged user and repointing the promote flow — churn for cosmetics. Left behind in the sandbox: draft invoice id=19, a scope probe. It cannot be deleted (no scope grants DELETE, which is the point) and the next refresh reclaims it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
193 lines
8.3 KiB
TypeScript
193 lines
8.3 KiB
TypeScript
/*
|
||
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>> = {
|
||
// --- lecture / export (le socle read) ---
|
||
11: "Lire les factures clients",
|
||
16: "Exporter les factures clients",
|
||
21: "Consulter les propositions commerciales",
|
||
28: "Exporter les propositions commerciales",
|
||
31: "Lire les comptes bancaires",
|
||
41: "Lire les projets et les tâches",
|
||
45: "Exporter les projets",
|
||
91: "Lire les charges fiscales ou sociales et paiement de TVA",
|
||
94: "Exporter les charges fiscales ou sociales",
|
||
111: "Lire les factures fournisseurs",
|
||
121: "Consulter les tiers",
|
||
126: "Exporter les tiers",
|
||
141: "Lire tous les projets et tâches",
|
||
161: "Lire les contrats/abonnements",
|
||
167: "Exporter les contrats",
|
||
251: "Lire les utilisateurs",
|
||
262: "Étendre l'accès à TOUS les tiers et leurs objets (voir_tous)",
|
||
281: "Consulter les contacts",
|
||
358: "Exporter les utilisateurs",
|
||
531: "Consulter les services",
|
||
771: "Lire les notes de frais",
|
||
779: "Exporter les notes de frais",
|
||
1181: "Consulter les fournisseurs",
|
||
1182: "Consulter les commandes fournisseurs",
|
||
1191: "Exporter les commandes fournisseurs",
|
||
1201: "Récupérer le résultat d'un export",
|
||
1231: "Lire les factures (et paiements) fournisseurs",
|
||
1236: "Exporter les factures fournisseur",
|
||
1321: "Exporter les factures clients, attributs et règlements",
|
||
2401: "Lire ses propres actions/événements",
|
||
2411: "Lire les actions/événements des autres",
|
||
2414: "Exporter les événements des autres",
|
||
2501: "Lire/récupérer les documents",
|
||
3201: "Lire les événements archivés et leurs empreintes",
|
||
50411: "Lire les opérations du Grand livre",
|
||
|
||
// --- écriture (jamais dans le scope read) ---
|
||
12: "Créer/modifier les factures clients",
|
||
14: "Émettre des paiements sur les factures clients",
|
||
22: "Créer/modifier les propositions commerciales",
|
||
32: "Créer/modifier les comptes bancaires",
|
||
122: "Créer/modifier les tiers",
|
||
130: "Créer/modifier les informations de paiement des tiers",
|
||
282: "Créer/modifier les contacts",
|
||
1202: "Créer/modifier un export",
|
||
1232: "Créer les factures fournisseur",
|
||
2402: "Créer/modifier des actions/événements",
|
||
2503: "Soumettre ou supprimer des documents",
|
||
50401: "Lier les produits et factures avec des comptes comptables",
|
||
|
||
// --- suppression : JAMAIS accordée par aucun scope ---
|
||
// 15 Supprimer les factures clients
|
||
// 27 Supprimer les propositions commerciales
|
||
// 125 Supprimer les tiers
|
||
// 2403 Supprimer des actions/événements
|
||
} as const;
|
||
|
||
/**
|
||
* Le socle de lecture — établi par audit de l'instance de production, pas
|
||
* deviné : c'est l'ensemble exact des droits de lecture/export dont les skills
|
||
* se servent aujourd'hui (factures clients et fournisseurs, tiers, contacts,
|
||
* documents, contrats, projets, banque, grand livre, TVA).
|
||
*
|
||
* `262` (voir_tous) est indispensable : sans lui, les endpoints de liste
|
||
* renvoient des tableaux vides plutôt qu'un 403 — le piège documenté par la
|
||
* skill `dolibarr`.
|
||
*/
|
||
const READ_ONLY: ReadonlyArray<number> = [
|
||
11, 16, 21, 28, 31, 41, 45, 91, 94, 111, 121, 126, 141, 161, 167, 251, 262,
|
||
281, 358, 531, 771, 779, 1181, 1182, 1191, 1201, 1231, 1236, 1321, 2401,
|
||
2411, 2414, 2501, 3201, 50411,
|
||
];
|
||
|
||
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, 14, 22, 32, 122, 130, 282, 1232, 2503],
|
||
},
|
||
|
||
/**
|
||
* 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"],
|
||
// 2503 est nécessaire à builddoc (régénérer le PDF d'une facture modifiée).
|
||
// Dolibarr le livre en bundle « soumettre OU supprimer » : on ne peut pas
|
||
// avoir l'un sans l'autre. C'est la seule capacité de suppression du modèle,
|
||
// et elle est confinée au writer de production, gated par le promote.
|
||
rights: [...READ_ONLY, 12, 14, 2503],
|
||
},
|
||
} as const;
|
||
|
||
export type ScopeName = keyof typeof SCOPES;
|
||
|
||
/**
|
||
* Conventional login for a given scope + environment. One user per pair.
|
||
*
|
||
* The scope name is kept verbatim, so `prod-write` on production reads
|
||
* `ai_agent_prod_prod_write`. Redundant, deliberately left alone: renaming a
|
||
* provisioned production credential means creating a second privileged user and
|
||
* repointing the promote flow — churn for cosmetics. Don't "fix" it.
|
||
*/
|
||
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;
|
||
}
|