Files
erp/test/scopes.ts
T
arcodangeandClaude Opus 5 11c8c65d45 feat(test): scoped AI users — one user per (environment × scope), provisioned by script
provisionSandbox.ts granted `[12, 122, 262, 32, 111, 251]`: opaque numeric ids,
one fixed scope, one agent per environment. Three failures in the 2026-07/08
sessions came straight from that:

1. Production `ai_agent` — documented as READ-ONLY in AGENTS.md, ADR-0003 and
   every SKILL.md — actually holds 46 rights against 9 declared, including
   create/modify on customer AND supplier invoices, thirdparties, contacts,
   thirdparty payment details, proposals, plus THREE delete rights (proposals,
   events, and submit/delete documents in the GED).
2. Writing a payment, then a product, to production each required granting a
   right by hand and revoking it after. A privilege granted ad hoc under time
   pressure is worse than one nobody holds.
3. A sandbox refresh wiped the agent's proposal rights, because they had been
   granted manually and lived nowhere in code.

- scopes.ts declares three scopes with their purpose and allowed environments:
  `read` (both envs), `sandbox-write` (sandbox only), `prod-write` (production
  only, narrow: invoices + payments, what the gated promote apply actually
  does). resolveScope() refuses a scope on an environment it does not belong to.
  No scope grants DELETE — the ledger is append-only, deletion stays human.
- provisionAiUser.ts creates or aligns one user per (environment × scope), emits
  its API key to a gitignored 600 file, and has an --audit mode that diffs what a
  user HOLDS against what its scope DECLARES. Production writes require the
  guard.ts opt-in.
- The audit reads permission labels LIVE off /user/perms.php rather than trusting
  a catalogue in code: ids are stable per Dolibarr version, not across them, and
  an audit that cannot name what it found is not actionable.

findUserId is implemented locally rather than imported: the trunk's userSetup.ts
has one, but it is uncommitted WIP and a provisioning script must not depend on
someone's working tree.

Tooling only — no production rights were changed. The migration (create the
scoped users, repoint the promote pipeline, then strip the over-grants from
`ai_agent`) rotates credentials used by every read skill and is the operator's
call.

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

130 lines
5.5 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>> = {
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;
}