docs/group-manager-scoped-permissions/05-pr-roadmap.md
Status: active · Task: group-manager-scoped-permissions · Source plan: 04-implementation-plan.md
Primitives per 03 §2 (single-classifier model).
has_permissionreturnsPermissionAuthority— one classifier replacing the old boolean form, since a scoped grant is group-qualified and can't be a flat bool;has_global_permissionis the GLOBAL-only bool helper. GATE 1 =require_permission(..., allow_scope=True)threshold; GATE 2 =assert_within_scope/assert_global. Names below updated to match.
Six PRs, dependency-ordered. Safety invariant: every enforcement PR lands its read-filter + write-side gate
allow_scope switch together — a manager can never reach an endpoint before its GATE 2 exists, so
there is no escalation window at any merge boundary. (Pre-GA branch: existing curators were already collapsed to
STANDARD by the account_type backfill, so no live capability is regressed while enforcement lands incrementally.)Revised by the 2026-06-29 regression review — see 03 §11 for the full case checklist. Net changes to this roadmap: a new PR0 (boot-fix prerequisite); PR4 adds skills (D5) and actions scoping (D4 —
manage:actionsstays in the bundle for GATE 1 reach + create; the agent-mediated GATE 2 was later dropped, see D8); delete stays admin-only across all PRs (D6, narrowed by D9 — a creator may delete their own action/MCP server); PR3/PR5 enumerate the previously-missed write endpoints (cc_pair status/name/property/prune, persona/share, group rename,/agents) and the persona-gate signature fix (§11.5) + cc_pair-reattach fix (§11.6).
| PR | Title | Est. LOC | Depends on | Key deliverable |
|---|---|---|---|---|
| 0 | fix(perms): re-point skill/targeted-reindex off removed curator dep | ~30 | — | Unblocks boot — import onyx.main currently fails (§11.0); independent of §8 |
| 1 | feat(perms): add is_manager + is_group_manager columns and backfill | ~220 | 0 | Schema + role-gated migration + cached-flag recompute |
| 2 | feat(perms): scoped-manager authorization primitives | ~330 | 1 | scoped_permissions.py (bundle w/o actions, gates, scope helpers) + require_permission(allow_scope) — inert |
| 3 | feat(perms): scope connectors & document sets to group managers | ~560 | 2 | Two-gate enforcement on cc_pairs + doc sets incl. status/name/property/prune (delete admin-only) |
| 4 | feat(perms): scope agents, skills & token limits to group managers | ~520 | 2 | Persona (+ /share, /agents, gate-signature fix §11.5) + skills + token-limit enforcement + scoped-PAT tests |
| 5 | feat(perms): group-manager assignment & membership scoping | ~430 | 3,4 | make/revoke + assign endpoint (D3) + membership gates + cc_pair-reattach gate (§11.6) + group rename + /me/permissions.is_manager |
| 6 | feat(perms): group-manager assignment UI & scoped nav | ~370 | 5 | Group-detail Make/Revoke toggle + manager nav visibility + Playwright |
PR0 (boot-fix: curator dep) ─▶ PR1 (schema+migration+recompute)
└─▶ PR2 (auth primitives — inert)
├─▶ PR3 (connectors + doc sets enforcement) ┐
└─▶ PR4 (agents + skills + token limits) ┘ both build on PR2 only; mergeable in either order
▼
PR5 (assignment + membership scoping)
▼
PR6 (frontend)
Walking skeleton = PR1→PR2→PR3: after PR3 the full two-gate model is provably working on the two highest-value resource types, exercised by the escalation integration suite (managers seeded via fixture).
current_curator_or_admin_user was deleted from onyx/auth/users.py by §1–7 but is still
imported by server/features/skill/api.py:16 (deps :173/186/223/259/297/322) and
server/documents/targeted_reindex.py:22, so import onyx.main raises ImportError. Re-point both onto the
correct require_permission(...) dep: targeted_reindex.py:80/163 → MANAGE_CONNECTORS (its connector
peers); skills → FULL_ADMIN_PANEL_ACCESS in PR0 (safe, unbreaks boot), narrowed to the new
MANAGE_SKILLS, allow_scope=True when the skill GATE 2 lands in PR4 (§11.2). DELETE endpoints excluded
(stay admin-only).python -c "import onyx.main" succeeds; skill admin + targeted-reindex endpoints import.is_manager + cached flag + backfillUser__UserGroup.is_manager; User.is_group_manager (cached route-gate flag); migration
c71a18ea7d07 (down_revision c8e316473aaa, role-gated backfill capturing CURATOR + GLOBAL_CURATOR, then
is_group_manager backfill); extend recompute_user_permissions__no_commit to recompute the cached flag.is_curator/role.| File | New/Modified | This PR's slice |
|---|---|---|
backend/onyx/db/models.py | modified | 2 boolean columns (User__UserGroup, User) |
backend/alembic/versions/c71a18ea7d07_*.py | new | add columns + role-gated backfill |
backend/onyx/db/permissions.py | modified | recompute sets is_group_manager |
backend/tests/integration/tests/usergroup/test_group_membership_updates_user_permissions.py | modified | recompute sets is_group_manager (folded into the existing recompute test) |
tests/integration/tests/migrations/test_is_manager_backfill_migration.py | new (DEFERRED) | backfill correctness via real alembic |
is_group_manager set/cleared by recompute on a managed edge (folded
into the existing recompute test). Backfill correctness (CURATOR / zero-is_curator GLOBAL_CURATOR /
fresh-install) is DEFERRED — revisit as a real-alembic migration test before GA.c8e316473aaa is still head; confirm the pre-GA assumption (curators already
collapsed to STANDARD) still holds so migrated is_manager bits are dormant, not a silent re-grant. If §6.1.1
snapshot caveat matters operationally, decide whether the zero-managed-group report ships now or later.backend/onyx/auth/scoped_permissions.py — SCOPED_MANAGER_PERMISSIONS,
scoped_group_ids_subquery, get_scoped_groups, has_permission (reads cached flag),
within_managed_scope_clause, assert_within_scope; extend require_permission(allow_scope=False).| File | New/Modified | This PR's slice |
|---|---|---|
backend/onyx/auth/scoped_permissions.py | new | bundle + scope helpers + both gates |
backend/onyx/auth/permissions.py | modified | require_permission(..., allow_scope) |
backend/tests/external_dependency_unit/.../test_scoped_permissions.py | new | gate logic + clause SQL |
assert_within_scope invariants (⊆ managed,
non-empty, private, fail-closed, admin/global bypass); within_managed_scope_clause selects the right rows.manage:actions (D4 keeps it in the
bundle for reach + create; manage itself is owner-or-admin per D8) and the new manage:skills (D5);
confirm require_permission's token-cap branch is unchanged since 03.connector_credential_pair.py and document_set.py (the latter
built from today's sa_false()) onto within_managed_scope_clause; insert assert_within_scope in
cc_pair create/update + doc-set create/update DB fns (re-reading current groups in-txn); switch the full set
of manager-reachable endpoints (§11.4) to require_permission(<token>, allow_scope=True) — connector create
(mock-cred connector.py:1568 and bare :1538), associate-credential cc_pair.py:716, cc_pair status
:427/name :512/property :542/prune :604, ee sync_cc_pair_groups, doc-set create/patch — but leave
connector/cc_pair and doc-set DELETE on the global admin dep (D6); resolve get_scoped_groups once per
request on bulk paths.| File | New/Modified | This PR's slice |
|---|---|---|
backend/onyx/db/connector_credential_pair.py | modified | filter re-key + create/update gate |
backend/onyx/db/document_set.py | modified | filter rebuild + create/update gate |
backend/onyx/server/documents/{connector,cc_pair}.py | modified | allow_scope=True deps |
backend/onyx/server/features/document_set/api.py | modified | allow_scope=True deps |
backend/tests/integration/.../test_group_manager_resources.py | new | escalation suite (these two) |
03's
guess server/documents/cc_pair.py) — locate the actual group/access setter before coding. Confirm doc sets
still use is_public (not an access_type) for the private check.user: User into update_persona_access (both MIT+EE
twins); persona group-share is MANAGE_AGENTS-gated via GATE 2 (D7, §11.5) — admin/global bypass,
scoped managers ⊆ managed, ADD_AGENTS-only can't group-share (today's route is ADD_AGENTS + editable
fetch, so PR4 adds the MANAGE_AGENTS check on the group-share write); is_public stays owner/admin-gated. Skills (D5): new dedicated MANAGE_SKILLS permission (enum + registry/groups UI +
bundle; no migration), a NEW scoped admin-list path (do NOT touch the runtime visibility filter), GATE 2 on
replace_skill_grants, re-point skill/api.py by verb to MANAGE_SKILLS, allow_scope=True (§11.2);
managed-scope enforcement in EE token_limit.py group write path; credentials.py and feedback.py left
unchanged (documented no-ops); admin skill delete stays admin-only (D6); persona delete is owner-or-admin
(D9); scoped-PAT tests. Actions
(D4 + D8): MANAGE_ACTIONS is in the bundle; switch the tool/MCP admin endpoints to allow_scope=True
so a manager can reach them and create their own. Managing an existing action/server stays
owner-or-admin (can_manage_own_tool / _ensure_mcp_server_owner_or_admin) — the agent-mediated GATE 2
once planned here was built and then dropped (D8); agent-derived scope survives only for viewing an
MCP server connected to a managed group. A creator may delete what they made (D9).| File | New/Modified | This PR's slice |
|---|---|---|
backend/onyx/db/persona.py | modified | filter + update_persona_access MIT twin gate + owner carve-out (§11.5) |
backend/ee/onyx/db/persona.py | modified | update_persona_access EE gate (lockstep signature) |
backend/onyx/db/skill.py | modified | scoped admin-list path + replace_skill_grants GATE 2 + is_public toggle gate (§11.2) |
backend/onyx/server/features/skill/api.py | modified | re-point off curator dep; allow_scope=True by verb (DELETE stays admin-only) |
backend/onyx/server/features/{tool,mcp}/api.py | modified | allow_scope=True (reach + create); manage (edit/toggle/auth) stays owner-or-admin — agent-mediated GATE 2 dropped per D8 |
backend/onyx/db/tools.py | modified | can_manage_own_tool / can_manage_mcp_server owner-or-admin gates (D8); agent-mediated scope kept only for view |
backend/ee/onyx/db/token_limit.py | modified | managed-scope on group token-limit writes |
backend/onyx/server/.../persona api | modified | ADD_AGENTS, allow_scope=True deps |
backend/tests/integration/.../test_group_manager_agents.py | new | agent + skill + action escalation + ADD_AGENTS-owner no-regression + PAT narrowing |
backend/onyx/db/feedback.py | unchanged | NO CHANGE — admin-only, not in bundle (§11.7) |
add:agents
ownership tier still keyed on Persona.user_id; credentials remain owner-only for a manager.manage:agents is the right
scoped token. (The actions-via-agents assumption no longer gates anything — D8 made action/MCP manage
owner-or-admin, so there is nothing to derive from the agent join.)make_group_manager/revoke_group_manager (ee/onyx/db/user_group.py) + recompute trigger
(extend recompute_user_permissions__no_commit(user_ids, db_session) to set is_group_manager — §11.7);
gates in update_user_group/add_users_to_user_group (group_id ∈ managed) plus a per-cc_pair GATE 2 on
update_user_group's cc_pair_ids re-attach (§11.6 — else a manager attaches out-of-scope connectors);
allow_scope=True on group update/add-users and rename (:164) endpoints; scoped group-LIST
(fetch_user_groups manager variant filtered by is_manager, list_user_groups+detail/member reads to
allow_scope=True returning only managed groups — §11.9, or the Groups page/assign-UI breaks); new
PUT …/user-group/{group_id}/manager gated admin ∨ group_id ∈ managed (D3); group create, delete,
and set_group_permissions left admin-only (D2/D6); add is_manager (+managed_group_ids) to
GET /users/me/permissions.| File | New/Modified | This PR's slice |
|---|---|---|
backend/ee/onyx/db/user_group.py | modified | make/revoke + membership gates |
backend/ee/onyx/server/user_group/api.py | modified | allow_scope deps + manager-assign endpoint |
backend/onyx/server/.../permissions api | modified | /me/permissions adds is_manager |
backend/tests/integration/.../test_group_manager_membership.py | new | assign authz + membership scope |
set_group_permissions rejected for
managers; assign endpoint allows admin + manager-of-that-group, rejects others; non-member target → 400./me/permissions response shape the frontend (PR6) will consume.usePermissions/hasPermission consume is_manager for nav visibility; group-detail
per-member "Make/Revoke Manager" toggle calling the PR5 endpoint; Playwright happy-path.| File | New/Modified | This PR's slice |
|---|---|---|
web/src/lib/.../usePermissions.ts | modified | is_manager flag |
web/src/.../hasPermission.ts | modified | manager nav visibility |
web/src/app/ee/admin/groups/[groupId]/... | modified | Make/Revoke Manager toggle |
web/tests/e2e/.../group-manager.spec.ts | new | assign → scoped pages visible |
web/src/app/ee/admin/groups/[groupId]/ and
whether an old "Make Curator" affordance still exists to replace rather than add beside.