fix(security): production read agent is now actually read-only

Migration applied to production, in the only order that does not break the
promote flow:

1. Created `ai_agent_prod_prod_write` (id=5) with the narrow `prod-write` scope —
   invoices, payments, and document submission (needed by builddoc to regenerate
   a modified invoice's PDF). Verified functionally: reads pass, DELETE on an
   invoice returns 403.
2. Repointed the promote pipeline at that user's key. It no longer borrows the
   read skills' credential; if the key is absent it dies with the provisioning
   command rather than silently falling back.
3. Revoked 12 write/delete rights from `ai_agent` (id=3), the credential every
   read skill holds: create/modify on customer AND supplier invoices,
   thirdparties, contacts, thirdparty payment details, proposals, exports,
   accounting links — and delete on proposals, events, and GED documents.

Verified after: invoices, thirdparties, contacts, products, proposals, supplier
invoices and bank accounts all still read; creating an invoice returns
`403 Forbidden: Insuffisant rights`. The documented posture and the real one
finally agree.

scopes.ts corrected against the live instance: 262 is NOT "créer/modifier les
produits" as the first catalogue guessed but the `voir_tous` ACL extension — a
READ right the skills depend on (without it, list endpoints return empty arrays
instead of 403). Revoking it would have silently blinded every read skill. This
is why the audit reads labels off /user/perms.php rather than trusting ids in
code. The READ_ONLY baseline is now the audited read surface (35 rights), not a
guess.

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 19:33:00 +02:00
co-authored by Claude Opus 5
parent 18682b5db1
commit 1cba032512
2 changed files with 87 additions and 18 deletions
+14 -1
View File
@@ -229,8 +229,21 @@ def stage_apply(args) -> None:
if os.environ.get("ARCO_PROD_CONFIRM") != "I-UNDERSTAND-THIS-WRITES-PROD":
die("set ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD to apply to production")
# The production writer is its OWN user with the narrow `prod-write` scope
# (test/scopes.ts) — not the read-only `ai_agent` whose key every read skill
# holds. A read credential must never be able to write the ledger, and the
# writer must not be lying around in a file a dozen skills load.
cfg = env_from(os.path.join(SKILL, "dolibarr", ".env"))
base, key = cfg["DOLIBARR_URL"], cfg["DOLIBARR_API_KEY"]
base = cfg["DOLIBARR_URL"]
key_file = os.path.join(REPO, "test", ".ai_agent_prod_prod_write.key")
if not os.path.exists(key_file):
die("no production writer key.\n"
f" expected: {key_file}\n"
" provision it: DOLIBARR_ADDRESS=https://erp.arcodange.lab "
"ARCO_ALLOW_PRODUCTION=erp.arcodange.lab "
"ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \\\n"
" deno run -A test/provisionAiUser.ts --scope prod-write --env production")
key = open(key_file).read().strip()
if SANDBOX_HOST in base:
die(f"the prod .env points at the sandbox ({base}) — nothing to promote to")
print(f"*** PRODUCTION: {base} — approved by {gate['by']} at {gate['at']} ***")