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
This commit is contained in:
2026-08-09 18:13:21 +02:00
co-authored by Claude Opus 5
parent dbe4b36c62
commit 11c8c65d45
2 changed files with 315 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
/*
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;
}