docs/group-manager-scoped-permissions/02-high-level-design.md
Status: active · Task: group-manager-scoped-permissions
Primitives superseded by 03 §2 (single-classifier model).
has_permissionnow returnsPermissionAuthority(GLOBAL/SCOPED/NONE) — it is the one classifier; the separatehas_permission_or_scopeis gone. GATE 1 isrequire_permission(..., allow_scope=True)(a threshold onhas_permission); GATE 2 isassert_within_scope/assert_global. Names below are updated to match; see 03 §2 for signatures.
Revised by the 2026-06-29 regression review. Bundle/coverage decisions here are updated by D4 (
manage:actionsstays in the bundle — GATE 1 reach + create; its agent-mediated GATE 2 was later dropped, see D8), D5 (skills = a 7th scoped resource under a newmanage:skillstoken), D6 (managers do everything except delete a resource that merely sits in a managed group — deleting something they created is ownership, see D9) and D7 (attaching an agent to a group is controlled bymanage:agents). The authoritative, complete case list lives in 03 §11. Also a hard prerequisite: the brokencurrent_curator_or_admin_userimport (03 §11.0) must be fixed or the API server won't boot.
The base system (§1–7) grants permission tokens to a whole group — every member gets them, everywhere.
A Group Manager is the controlled exception: one user given admin-like control over a single group's
resources and members, and nothing outside it. We add this with one boolean (is_manager on the
membership row), one code-defined permission bundle (SCOPED_MANAGER_PERMISSIONS), live scope
resolution (never cached, never stale), and a two-gate enforcement model whose authoritative check runs
inside the database write, not at the route. No new tables, no new rows, no second auth round-trip.
user__user_group (the membership EDGE)
┌───────┐ ┌───────────────────────────────────┐ ┌────────────┐
│ Alice │──▶│ user_id=Alice group_id=Engineering│──▶│ Engineering│
└───────┘ │ is_manager = TRUE ◀── the only new │ │ (group) │
│ bit of state │ └────────────┘
└───────────────────────────────────┘
│
is_manager=TRUE ⇒ apply SCOPED_MANAGER_PERMISSIONS (in code)
│ {manage:connectors, manage:document_sets,
▼ manage:agents, add:agents, manage:user_groups,
but ONLY to manage:skills, manage:actions}
Engineering's
resources — resolved LIVE
EXCEPT manage:actions — a custom action or MCP server belongs to no group, so there is
nothing to scope it by. The bundle grants GATE 1 reach + create only; managing an
existing one is plain owner-or-admin (D8). Agent-derived scope survives solely for
*viewing* an MCP server connected to a managed group. See 03 §11.1.
Two things never live in the database as data:
SCOPED_MANAGER_PERMISSIONS), not rows in
permission_grant. permission_grant stays global-only. (A role binding ≠ a permission — the same split
k8s, AWS IAM, and Zanzibar make.)is_manager on every
request that needs it (get_scoped_groups). One indexed read. Because a manager is always a member, and
membership is the source, the scope can never go stale after a group is renamed, moved, or deleted.Every manager action passes two independent checks. The first lets them reach the code; the second is the authorization of record.
Manager calls PUT /document-set {id: 7, groups:[Engineering]}
│
▼
┌─────────────────────────── GATE 1: ROUTE GATE ───────────────────────────┐
│ require_permission(...) → has_permission(MANAGE_DOCUMENT_SETS) │
│ passes if: holds the token GLOBALLY OR manages ANY group. │
│ COARSE. Can only reject. Does NOT authorize the action. │
└──────────────────────────────────────────────────────────────────────────┘
│ (reached the handler)
▼
┌──────────────────── GATE 2: PER-RESOURCE WRITE-SIDE GATE ─────────────────┐
│ assert_within_scope(user, resource, new_groups, access_type) │
│ runs INSIDE the DB write, in the same transaction. Re-reads the │
│ resource's CURRENT groups from the DB (not the client's list). Allows │
│ only if the resource ends up: │
│ • in ≥1 managed group, │
│ • with NO group outside the managed set (current ∪ new ⊆ managed), │
│ • non-PUBLIC (PRIVATE or SYNC; never PUBLIC). │
│ Admin override (admin token) skips this entirely. │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
write commits (or OnyxError FORBIDDEN)
Why two gates and not one? The route gate is cheap and coarse — it answers "could this user ever do this?"
without knowing the specific resource. It exists so a manager isn't 403'd before the handler can even load the
resource. But it is not safe on its own: a manager holds manage:document_sets "scopedly," so a global-style
route check would let them edit any document set. The real decision needs the resource's actual group
membership, which only the handler/DB layer has — so the authoritative check lives there, re-reading current
state in-transaction. The route gate can only ever reject; it must never be the thing that authorizes.
The listing filters (_add_user_filters) only hide out-of-scope resources from the UI. A direct API call by
resource ID bypasses them entirely. And a group-reassign request carries only the new groups. So without
re-reading current state, this works:
Finance doc set #7 currently belongs to {Finance}. Alice manages {Engineering} only.
Alice calls: PUT /document-set {id: 7, groups:[Engineering]}
Naive check (new groups ⊆ managed): [Engineering] ⊆ {Engineering} ✓ → Alice captures Finance's doc set.
Correct check (CURRENT ∪ new ⊆ managed): {Finance, Engineering} ⊄ {Engineering} ✗ → rejected.
The gate therefore loads the resource's current groups in the same transaction and requires
current_groups ∪ requested_groups ⊆ managed_groups. Capture-by-reassignment is closed.
current_user loads Alice with her cached effective_permissions (global tokens only)._add_user_filters(stmt, user, get_editable=True).admin → no filter; else if she holds the token globally → all; else if she manages any
group (get_scoped_groups non-empty) → resources whose group set ⊆ her managed groups; else → empty
(get_editable) / public+member (viewing). Fail-closed: empty managed set ⇒ empty result, never "no
filter."has_permission(MANAGE_CONNECTORS) → passes (she manages a group).assert_within_scope re-reads the cc_pair's current groups, checks
current ∪ requested ⊆ get_scoped_groups(Alice), checks access_type != PUBLIC (PRIVATE or SYNC).
Pass → write; fail →
OnyxError(INSUFFICIENT_PERMISSIONS), transaction rolls back.has_permission(MANAGE_USER_GROUPS) → passes.add_users_to_user_group(... group_id=Engineering ...).Engineering ∈ get_scoped_groups(Alice)? Yes → add. (If Alice targeted Marketing → reject.)
set_group_permissions is untouched — it stays FULL_ADMIN_PANEL_ACCESS-only, so a manager can never
change what tokens a group grants (no privilege manufacturing). ┌────────────────────────── auth/permissions.py ──────────────────────────┐
│ SCOPED_MANAGER_PERMISSIONS (code-defined bundle) │
│ get_scoped_groups(user, perm) → live indexed read of is_manager │
│ has_permission(user, perm) → GATE 1 (route) │
│ assert_within_scope(...) → GATE 2 (write-side) │
└───────▲───────────────▲────────────────────────────▲─────────────────────┘
│ │ │
route deps │ 6× _add_user_filters group + resource DB write fns
(require_perm) │ (connector, persona, (add_users_to_user_group,
│ document_set, credentials, update_user_group,
EE/CE │ feedback, token_limit) add_credential_to_connector,
API routers │ re-keyed onto update_document_set,
│ get_scoped_groups create_update_persona, …)
each calls GATE 2 before commit
┌──────────────── db/models.py ────────────────┐ ┌─── alembic/versions ───┐
│ User__UserGroup.is_manager : bool (NEW) │ │ add is_manager + backfill│
└───────────────────────────────────────────────┘ └──────────────────────────┘
┌──────── make_group_manager / revoke_group_manager ────────┐
│ one-row flip · used by migration + Group-Manager UI │
└────────────────────────────────────────────────────────────┘
Admin makes Alice a manager of Engineering (
make_group_manager(Alice, Engineering)→ one row flip,is_manager=true).
{Engineering} ⊆ {Engineering} ✓, PRIVATE ✓ →
created.PUT /connector/<Finance id> {groups:[Engineering]} → GATE 2 re-reads current {Finance},
{Finance,Engineering} ⊄ {Engineering} ✗ → 403.set_group_permissions stays admin-only).manage:connectors → it works only on Engineering connectors (token caps the
permission set; is_manager still bounds the groups, live). She cannot mint a PAT that widens her to
Marketing — the group bound comes from her live is_manager, which the token cannot touch.is_manager at the route gate → CACHE THE BOOLEAN (D1). A dedicated cached user.is_group_manager
(recomputed on membership change and on a manager flip; loaded with the user at auth) lets GATE 1 decide
reachability with zero queries, per the wiki §8.1 intent. It is a sibling field to
effective_permissions (which stays global-tokens-only) — not a sentinel inside the token list. Crucially,
only the boolean is cached; the managed-group list is still resolved live, so the scope set itself
can never go stale.
GATE 2 lives inside each group/resource DB-write function (re-reading current groups in-txn), exposed as
one shared helper assert_within_scope. Not a second FastAPI dependency (a dependency can't see
the resource's current groups or run in the write transaction). The route dependency has_permission
is only GATE 1.
PAT composition — a PAT stays a flat permission cap (request.state.token_scopes, already implemented). A
manager's group scope is not encoded in the token — it always comes from live is_manager. So a PAT
can only ever narrow the manager's permission set and never widen group reach. No new PAT schema; §8.5
"intersect" = (manager bundle ∩ token scopes) for permissions, AND (live managed groups) for scope, both
enforced independently.
Group create → admins only (D2). Managers manage assigned groups; they cannot create top-level groups. Manager assignment → admin or manager-of-that-group (D3), enabling in-group delegation.
role / UserRole / is_curator and migrating the 3 residual user.role==ADMIN readers
(persona_sharing.py:53, build_session.py:638, search/api.py:104) — deferred cleanup release.get_acl_for_user) — unchanged; managers affect entity-level access
only.