ADR-076 — Scoped config-share editor (self-service, per-field, per-repo)
Status: proposed · 2026-07-17
Context
Operators want to let non-operators maintain a bot's configuration — the motivating case is the veille (feed-watch) team editing their own RSS feeds[] and editorial prompt — without handing out a GitHub account or studio access. The config lives as a JSON file in a git repo (feed-watch.json in SocialGouv/iterion-veille); the bot reads it at run time and commits its state back.
The blunt options (a GitHub team with write on the repo; a studio team membership) both over-grant: they expose git, the whole file, every category, the sink webhook keys, and — for a studio membership — the team's runs, secrets and boards. We want a grant scoped to exactly the fields we choose, in one file of one repo, and nothing else in iterion, addressable by a URL we can hand out and revoke.
A three-lens adversarial design review surfaced the load-bearing constraint: the editorial an editor writes is injected verbatim into the synthesize agent's LLM system prompt, and that agent (claude_code, bypassPermissions) otherwise has full native Bash/Read/Write. The editor endpoint is only as safe as the bot it edits — so the work is two coupled pieces: hardening the bot (ADR-scope: the feed-watch permission: deny gate + SSRF-safe fetch + deterministic link firewall, shipped in the paired change) and the editor primitive itself (this ADR).
Options considered
- GitHub team with repo write — zero build, but exposes git + the whole repo, needs accounts, and can't scope below "the repo".
- Config in iterion's Mongo store, form CRUD — clean form UX, but loses git history / PR-reviewability / colocation with the bot's state, and duplicates the source of truth.
- Scoped config-share primitive over the git file (chosen) — a per-grant token + a server that projects reads and fail-closed-merges writes onto the real file through the forge contents API.
Within (3), sub-decisions:
- Auth transport: a long-lived per-share token accepted as
Authorization: Beareronly (vs a cookie / a one-shot-code→cookie exchange). - Forge write credential: reuse the team's
forge_tokenPAT (the same one the bot pushes with) vs mint a repo-narrowed github-app installation token. - Genericity: bake feed-watch's fields into the server vs carry the editable/visible paths on the grant record.
Decision
Add pkg/configshare — a generic, bot-agnostic "scoped config-file share" primitive — plus its server surface and a shell-less studio editor.
- A share grant (
configshare.Share) pins, at mint time and never from a request body:bot_id,repo_url,repo_ref,config_path,category, and literal-dottedallowed_paths(writable) +visible_paths(readable). The token is aniws_string stored only as a salted hash. - Synthetic identity. The token authenticates a principal of a new
auth.KindShare.auth.Identitygains aKindfield, and every operator RBAC gate (canViewTeam/canManageTeam/canViewOrg/canManageOrg) rejects a synthetic principal up front — so a share token can never reach a team, run, secret or board endpoint. Backward-compatible (zero value = a real user); the inbound-webhook actor is stampedKindWebhookby the same mechanism. - Read = projection.
GET /configreturns a fresh object built fromvisible_pathsonly — never a filtered pass over the file, so no other category/field/key leaks. - Write = fail-closed allow-list + if-match.
PATCH /configwalks the patch before any merge (any key outsideallowed_paths, any prototype-pollution key, any hard-forbidden segment → reject the whole request, uniform 400, no path echo), deep-merges the allowed leaves onto the server-read file, re-validates (feeds http(s)-only/no-userinfo/no-IP/capped; editorial size/control-char/fence), and writes through a newforge.FileClient(GitHub contents API) withif-matchon the blob SHA. A stale SHA → 409 + fresh projection for a diff, never a silent overwrite. Commit message/branch/author are server-derived. - Transport = Bearer-only (no cookie surface → structurally CSRF-immune). All auth failures collapse to a uniform 401. Every call lands a
Deliveryaudit row. Rotate = immediate cutoff. Default TTL 14 days. - Forge credential = the team
forge_tokenPAT (resolved viaResolveGenericWithBindings, the same secret the bot pushes with). - Studio: a shell-less
/config/:ideditor with its OWN fetch client (credentials:"omit", Bearer-only, no sharedapiRequest//auth/refresh— an eslint boundary enforces the import isolation), token stripped from the URL (history.replaceState) into per-tabsessionStorage, editorial as a plain<textarea>(no markdown/HTML). Operator CRUD on the bot's home page. - Storage: in-memory (local/desktop) + Mongo (cloud).
Consequences
- A grant is minted per
(bot × repo × file × category); a second bot needs no Go change — only itsallowed_paths/visible_paths(the server is generic; only the studio create-dialog knows feed-watch's field names today). - The
Kindfield is a new, small, permanent RBAC concept every future synthetic-identity surface (share, webhook, and any next one) rides; a regression test locks "synthetic never passes an operator gate". - The single unproven-in-CI edge is the real GitHub contents round-trip (the end-to-end tests run against a fake
FileClient); a live dogfood oniterion-veillecloses it.
Accepted follow-ups (not blockers)
- A repo-narrowed github-app installation token for the write (tighter blast radius than a team PAT, which is broad if the server leaks it).
- A one-shot code-per-handout exchange so a pasted URL burns once (today the long-lived token in the URL leaks if pasted; mitigated by replaceState + short TTL + rotate).
- A per-bot
config_share:manifest block + JSON schema so the studio form auto-derives fields (today the paths are supplied by the operator/dialog). - GitLab/Forgejo
FileClient; a preview/test-run button; per-org monthly write quota.
Reference: docs/config-share.md.
