Files
erp/test/scopes.ts
T

219 lines
10 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",
40001: "Lire les devises et leurs taux",
// --- é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",
162: "Créer/modifier les contrats/abonnements",
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, 40001, 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, 162, 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 product creation, no proposals — those are rehearsed then applied by a
* human when they are genuinely needed.
* `122` (thirdparties) WAS excluded on that same reasoning, and the reasoning
* did not survive contact: a supplier invoice needs a supplier, so the scope
* could create the document but not the party it names. The exclusion turned
* every new supplier into a manual step in the middle of a gated flow — which
* is where discipline erodes.
* `162` (contracts) IS included: recording the engagement behind an invoice is
* what the piste d'audit fiable asks for, and it recurs (Darnis, then KM).
*
* 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.
// 1232 (factures fournisseur) : oubli initial. Le scope dit « création/
// modification de factures » — les factures fournisseur en sont, et leur
// saisie est l'opération la plus courante du back-office. Ajouté après un
// 403 en production sur la facture Darnis F1048.
//
// 122 (tiers) : ajouté sur décision de l'opérateur du 2026-08-15, après un
// second 403 — cette fois sur Anthropic PBC, entité américaine distincte
// d'Anthropic Ireland, dont la facture ne pouvait être saisie faute de
// pouvoir créer le fournisseur qu'elle nomme.
//
// CE QUE CE DROIT COÛTE, et il faut le dire : Dolibarr le libelle
// « Créer/MODIFIER les tiers ». Il n'existe pas de droit de création seule.
// Le writer de production peut donc désormais altérer l'identité de
// n'importe quel tiers existant — nom, adresse, identifiants fiscaux — et
// non seulement en créer. C'est la contrepartie assumée : sans lui, toute
// facture d'un fournisseur nouveau s'arrête au milieu du flux gated.
// La suppression (125) reste exclue, comme dans tous les scopes.
rights: [...READ_ONLY, 12, 14, 122, 162, 1232, 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;
}