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
130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
/*
|
||
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;
|
||
}
|