Files
erp/test/scopes.ts
T
arcodangeandClaude Opus 5 4d1e3ecb23 fix(email-ingest): extraction testable et pinnée au golden set + adc-008
L'extraction de champs vivait dans un heredoc à l'intérieur d'email-inspect.sh :
impossible à exécuter isolément, donc jamais mesurée, donc fausse sans que
personne puisse le voir. Sur la facture Darnis F1048 elle renvoyait le numéro de
TVA d'Arcodange comme référence de facture, et aucune date.

- extract_fields.py : l'extraction sort du shell et devient un module.
- test_extract.py : régression contre les 16 factures hand-vérifiées de
  fleet/golden/invoice-extract/. Score par champ, et une valeur FAUSSE pèse plus
  qu'une valeur absente — un humain recopie ce qui s'affiche.

Valeurs fausses : 4 → 0. Exactitude ref 62,5 → 75 %, date 62,5 → 75 %,
HT 68,8 → 75 %, TTC 81,2 → 93,8 %.

Cinq bugs réels, dont trois invisibles sans test :
- « Nº » sur les factures françaises est U+00BA (ordinal masculin), pas le signe
  degré. La classe [°o] le rate, le motif principal échoue, et le repli attrape
  le premier jeton ref-shaped du document — très souvent un numéro de TVA.
- Le filtre anti-TVA rejetait « FR73261832 », qui est la vraie référence OVH : un
  numéro FR fait exactement 11 caractères après le préfixe.
- « Montant total (HT) » était lu comme un TTC.
- Une référence coupée par la colonne (« 06-01-26- » / « payment-366753 ») était
  renvoyée amputée : le recollage doit précéder le scan, sinon la queue seule est
  trouvée en premier.
- Un `\b` après `€` ne peut jamais matcher en fin de ligne (€ n'est pas un
  caractère de mot) — la TVA n'était jamais extraite.

adc-008 : une facture fournisseur s'enregistre à SA date, même future, tant que
l'exercice (année civile) ne bascule pas. Le document fait foi ; altérer sa date
ferait diverger l'écriture de sa pièce justificative (CGI art. 289 VII).
Registre validé : 8 règles, 8 ADC, 0 erreur.

scopes.ts : 1232 (factures fournisseur) ajouté à prod-write — oubli initial,
révélé par un 403 en production sur F1048. Le pipeline s'est arrêté sans écrire.

Appliqué en production via le pipeline gated : FAF2026014 (Darnis F1048),
218,50 HT + 43,70 TVA = 262,20 TTC, validée, non réglée.

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

201 lines
8.9 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 thirdparty creation, no product creation, no proposals — those are
* rehearsed then applied by a human when they are genuinely needed.
* `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.
rights: [...READ_ONLY, 12, 14, 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;
}