ADR-078 — Config-editor role: SSO-team-scoped access to config-share
Status: accepted · 2026-07-18
Extends ADR-076 (scoped config-share editor) and ADR-077. ADR-076 shipped the editor as a bearer capability URL (iws_ token, synthetic KindShare principal). This ADR adds a second, complementary access path: a real user, authenticated via org SSO, holding a least-privilege config_editor capability on a team, who reaches only the config-share editor views for that team's bots — nothing else in the studio.
Context
The capability-URL model (ADR-076) is right for a truly external editor (no account, no SSO): you hand them a link, zero onboarding. But for the actual first user — internal design/a11y colleagues who curate the veille config — it has real weaknesses the operator felt in practice:
- it is a bearer secret (leaks if the URL is pasted/forwarded/synced);
- no identity or attribution — the delivery audit records IP/UA, not who;
- no revoke-by-person — you rotate the token (breaking everyone's link) or delete the share;
- keeping a standing link alive needed the "never expires" opt-out (ADR-077 follow-up) — a permanent credential, the classic smell.
For colleagues who are (or can be) in the org's SSO, the better model is: access is a property of team membership + a scoped role. In the team with the config-editor capability → you can edit that team's bots' config; removed from the team → access is gone. No token, no expiry, real identity.
The two models are complementary, not competing: capability-URL for the account-less external case, SSO-team-scoped for the internal case.
Decision
Add a config_editor capability and an SSO-authenticated editor surface, built so it does not weaken the ADR-076 token path.
config_editoris an ORTHOGONAL capability, not a rank on the role ladder. The team role ladder is strictly linear (viewer < member < admin < owner, compared byAtLeast), and — critically —canViewTeamadmits anyRole.Valid()principal, notAtLeast(RoleViewer). So a naive "below viewer" linear role would silently gain broad team read. Instead,config_editoris a distinct role value that the standard gates (canViewTeam/canManageTeam/canViewOrg) explicitly reject, and that only a newcanEditConfigShares(team)gate accepts. It grants exactly one thing: edit this team's config-shares. Nothing else.A SEPARATE authenticated endpoint — the token path stays cookie-less. The public editor routes (
/api/config-share/{id}/…) keep their Bearer-only,credentials:"omit", structurally-CSRF-immune contract (ADR-076). The real-user path is a new group under the normal session auth:GET /api/teams/{id}/config-shares(already exists, now also readable by a config-editor),GET|PATCH /api/teams/{id}/config-shares/{sid}/configbehindrequireAuth + canEditConfigShares, reusing the SAMEconfigShareSvc.ProjectedRead/ApplyEditthe public handlers call. The real user isKindUser(not synthetic), so it passes theIsSynthetic()guard cleanly — ADR-076's synthetic rejection is untouched and is not a blocker here.A role-keyed limited studio shell.
RestrictedShelltoday triggers on "member of zero teams" and shows the marketplace. A config-editor is a team member, so a new branch inAuthGate— keyed on the active role beingconfig_editor— renders a sibling limited shell that mounts ONLY the config-share editors for the team's bots, reached through the normal cookie session client (@/api/client), not theiws_-hardwiredconfigShare.ts/ConfigShareView.No org over-grant. A team membership normally mirrors up to an
OrgRoleMember, which passescanViewOrg(org roster/usage/settings read). Aconfig_editormust not mirror to org membership — it stays purely team-and-config-scoped.Attribution. The
configshare.Deliveryaudit row gains anactorfield, so a real-user edit is attributable to the user (the token path keeps itsshare:<id>actor).SSO assignment.
config_editoris assignable through the existing SSO role plumbing — a GitHub "config editors" team →GitHubTeamGrant{Role: config_editor}, or an OIDC providerDefaultRole: config_editor— once the role value isValid(). The operator UI addsconfig_editorto the team members role picker (invite + change-role), which validates onlyrole.Valid(), so no member-management handler changes.
Scope (MVP) + follow-ups
- MVP: team-scoped. A
config_editorcan edit all of their team's config-shares. There is no member→bot binding today; per-bot / per-share narrowing (a config-editor limited tofeed-watch/a11yonly) is a follow-up (natural home: a scoped field onMembershipor a join consulted bycanEditConfigShares). - Follow-ups: per-bot/per-share member binding; an org-level "config editor only" concept if org context is ever needed; email-invite copy tailored to the config-editor role.
Consequences
- The operator gets a no-token, revoke-by-membership way to delegate config editing to colleagues, with real identity + attribution — the durable-access need that previously forced a never-expiring token.
- The capability-URL path is unchanged and still the right tool for account-less external editors.
never_expiresremains, but is no longer the only way to grant standing access. - One new low-privilege capability enters the auth model. It is deliberately out of the linear ladder so it can never be mistaken for "viewer+" by a gate that checks
Valid()— the single biggest correctness hazard, closed by a golden test assertingconfig_editoris rejected by every standard gate and admitted bycanEditConfigSharesalone.
Reference: docs/config-share.md.
