Files
erp/test/scopes.ts
T
arcodangeandClaude Opus 5 1cba032512 fix(security): production read agent is now actually read-only
Migration applied to production, in the only order that does not break the
promote flow:

1. Created `ai_agent_prod_prod_write` (id=5) with the narrow `prod-write` scope —
   invoices, payments, and document submission (needed by builddoc to regenerate
   a modified invoice's PDF). Verified functionally: reads pass, DELETE on an
   invoice returns 403.
2. Repointed the promote pipeline at that user's key. It no longer borrows the
   read skills' credential; if the key is absent it dies with the provisioning
   command rather than silently falling back.
3. Revoked 12 write/delete rights from `ai_agent` (id=3), the credential every
   read skill holds: create/modify on customer AND supplier invoices,
   thirdparties, contacts, thirdparty payment details, proposals, exports,
   accounting links — and delete on proposals, events, and GED documents.

Verified after: invoices, thirdparties, contacts, products, proposals, supplier
invoices and bank accounts all still read; creating an invoice returns
`403 Forbidden: Insuffisant rights`. The documented posture and the real one
finally agree.

scopes.ts corrected against the live instance: 262 is NOT "créer/modifier les
produits" as the first catalogue guessed but the `voir_tous` ACL extension — a
READ right the skills depend on (without it, list endpoints return empty arrays
instead of 403). Revoking it would have silently blinded every read skill. This
is why the audit reads labels off /user/perms.php rather than trusting ids in
code. The READ_ONLY baseline is now the audited read surface (35 rights), not a
guess.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-08-09 19:33:00 +02:00

186 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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. */
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;
}