Skip to content

Route requests to the nearest eligible reviewers - #670

Open
barborico wants to merge 10 commits into
mainfrom
brynna/request_reviewer_routing
Open

barborico wants to merge 10 commits into
mainfrom
brynna/request_reviewer_routing

Conversation

@barborico

@barborico barborico commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Who's responsible for reviewing a request (all kinds) is now defined once, in api/models/request_reviewers.py: the eligible reviewers at the nearest owner level that has any. Notifications (open and close), all three "Assigned to Me" views, and an updated reviewer panel on the request detail pages use this definition.

Urgency: 2 weeks?
Expected review effort: HIGH (~1h)

Motivation

I noticed that, as an Access admin, I'm assigned a number of "confused deputy" reviews. This led me to discover that the reviewer chain was implemented separately in seven places that disagreed:

  • An Access admin's "Assigned to Me" group requests matched "All", including requests for apps that have owners.
  • Access and role requests for unowned groups were assigned to nobody.
  • App-group creation requests for unowned apps notified nobody.
  • Close notifications went to every owner at every level plus every Access admin.
  • The request pages rebuilt the chain in the browser, inconsistently.

My intent here is one principle, reflected identically in "Assigned to Me", notification routing, and the UI. The one place that should probably be brought into alignment but was left out of scope for now is endpoint authZ.

The Routing Principle

The owner levels, nearest first:

Request Owner levels
Access request, individual or role-based, for group G owners of G → owners of G's app (app groups only) → Access admins
Group request to create G owners of the requested app (app groups only) → Access admins

Who counts as an eligible reviewer at a level:

  • Not the requester.
  • Not deleted.
  • Unless an Access admin, not blocked by a disallow_self_add_* tag. A role member approving a role-based request would be adding themselves; this mirrors CheckForSelfAdd.execute_for_role.

Screenshots

The pending request details view includes a list of possible reviewers, e.g.:

image image

Description of Changes

  • api/models/request_reviewers.py
    • Each owner level is one SQL condition correlated to the request row.
    • The same conditions answer both directions: get_assigned_reviewers(request) ("who reviews this") and is_assigned_reviewer(Model, user_id) ("which requests does this user review").
    • get_eligible_reviewers_by_level lists the eligible reviewers per level, nearest first, each user once at their nearest level; the first level is the assigned one.
    • get_all_possible_request_approvers and api/models/access_request.py are removed.
  • Notifications.
    • The create operations notify the assigned reviewers.
    • Close notifications (approve, reject, expiry, direct grants that fulfil a request) go to the reviewers assigned before the closing change. ModifyGroupUsers, ModifyRoleGroups, DeleteGroup, and UnmanageGroup capture them before changing ownership.
    • RejectAccessRequest and RejectRoleRequest gain an optional assigned_reviewers keyword.
    • DeleteApp deletes the app's owners group last, so the other groups' close notifications still see the app owners.
  • "Assigned to Me". The access, role, and group request lists filter with is_assigned_reviewer. This replaces three divergent blocks, including a loop over every app.
  • Reviewer panel.
    • New routes GET /api/{requests,role-requests,group-requests}/{id}/reviewers return reviewers_by_level: the eligible reviewers by owner level, nearest first. The first level is the assigned one; an empty list means no one can review the request.
    • src/components/RequestReviewers.tsx renders them on all three request pages: one table per level, with an "Assigned" chip on the first. Only people who can act on the request are listed, so the requester and tag-blocked owners don't appear.
    • The access and role pages drop their four member queries each. The role page's admin note about tag-blocked owners is removed; it relied on blocked owners being listed, and the admins it targeted never saw it.
  • Performance.
    • The app-owner level is a single, flat EXISTS, so Postgres can use a semi-join.
    • A new migration indexes app_group.app_id.
  • Copy and docs.
    • The homepage guide gets a "Who reviews a request?" entry, and the request guides link to it.
    • "Assigned to Me" tooltips now say "assigned to you for review", since those views show every status.
    • NotificationPluginSpec, the README, and .claude/CLAUDE.md ("Approver routing") are updated.

Behavior changes operators will see

  • Admins are notified of app-group creation requests for apps with no owners.
  • The requester never appears in approvers, and tag-blocked app owners are no longer notified of role requests.
  • Close notifications go to the assigned reviewers only.
    • Access admins no longer get a completion notice for every closed request.
    • approvers in *_completed hooks is a list (it was a set).
  • Admins' "Assigned to Me" narrows to requests where admins are the nearest eligible level. Access and role request views now include requests for unowned groups.
  • New migration: a plain index on app_group.app_id, built non-concurrently on a small table.
  • Hook signatures, the approvers parameter name, and the group_owners audit-log field are unchanged. API changes are additive.

Validation of Changes

  • tests/test_request_reviewers.py

    • A scenario matrix covers the three request kinds: sole owner is the requester, unowned apps, admin requester, ended or deleted owners, tag-blocked group and app owners under both keys, disabled and app-inherited tags, nobody eligible.
    • An agreement test checks, for every scenario and user, that the user is assigned exactly when "Assigned to Me" lists the request. It also checks that the reviewers listed across all levels are exactly the users the resolve checks permit to approve (reproduced from the same primitives), and that the first level is the assigned one.
    • Close-timing tests cover role-ownership approval, batch adds, delete group, delete app, unmanage group, and a snapshot miss.
    • Route tests cover /reviewers: shape, 404, 401.
  • Every commit passes on its own: ruff, ty, pytest (1235 at the tip, rebased on main), vitest (206), tsc --noEmit, and prettier.

  • Postgres: reviewer tests pass on a local 15.6 with SAWarning as errors, as does the full suite; the request and reviewer test files also pass on Postgres 16. Migrations pass on SQLite and Postgres: upgrade, alembic check, then downgrade -1 and upgrade again.

  • Benchmark: an admin's "Assigned to Me" count queries on local Postgres. Dataset: 20k users, 234k memberships, 100k access and 20k role requests.

    Query Before After
    Access requests, pending 11.2 s 57 ms
    Access requests, all statuses 98.9 s 515 ms
    Role requests, pending 78.8 s 24 ms
    Role requests, all statuses over 300 s 218 ms

    Non-admin lists stay under 16 ms.

  • Manual: with seed data, the requester viewed an access, role, and group request page. Each panel listed only eligible reviewers, nearest level first and marked as assigned (see the screenshots).

  • I ran Matt Pocock's PR review skill and found no violations.

Guidance for Reviewers

  • Review commit by commit. Each commit is one logical step and passes the test suite on its own, and the commit description explains the intent.
  • Who may approve or reject is intentionally unchanged; assignment is not authorization. A follow-up can build the resolve checks on the same owner levels. One reason to do that is so a tag-blocked role-request approval becomes an explicit 403 instead of a silent no-op.

🤖 Generated with Claude Code

@barborico
barborico force-pushed the brynna/request_reviewer_routing branch 3 times, most recently from ebf73a0 to 8ad6999 Compare October 8, 2026 23:23
@barborico
barborico marked this pull request as ready for review October 9, 2026 03:38
@barborico
barborico force-pushed the brynna/request_reviewer_routing branch from 8ad6999 to a0fb96b Compare October 16, 2026 07:06
barborico and others added 8 commits October 16, 2026 15:25
Add one definition of a request's reviewers. Each kind of request has
owner levels, nearest first: the requested group's owners, then its app's
owners for an app group, then Access admins; a group request starts at the
requested app's owners. An eligible reviewer owns at a level and is not
the requester, not deleted, and not blocked by a disallow_self_add tag the
way CheckForSelfAdd would block their approval. The assigned reviewers are
the eligible ones at the nearest level that has any.

Each level is a SQL condition on the request row, so the same rule answers
both "who reviews this request" and "which requests does this user
review". Nothing calls it yet; the tests pin the rule down with a scenario
per routing case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating an access, role, or group request now notifies the request's
assigned reviewers. Each create operation had its own fallback chain, and
they disagreed: an access or role request still listed its requester when
the group had other owners, and only a role request skipped owners that a
disallow_self_add tag would stop from approving. All three now follow the
one rule.

Close notifications are unchanged here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A close notification went to every possible approver at every level,
including Access admins and sometimes the requester. It now goes to the
request's assigned reviewers, so the people told a request is done are the
people who were asked to review it.

Some operations close requests as a side effect of changing ownership:
adding users or roles to a group, and deleting or unmanaging a group or
app. That change can move who is assigned, so these operations snapshot
each request's assigned reviewers before making it and notify from the
snapshot; a request the snapshot misses has its reviewers computed when it
closes. Deleting an app deletes its owners group last, so its other
groups' requests still resolve to the app owners. The notification plugin
docs describe what approvers holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Assigned to Me" lists now show exactly the requests whose assigned
reviewers include the user, as one SQL condition per request row. Each
list had its own approximation: the access list showed every request for
a group or app the user owned, whatever the level; the group list showed
admins every pending request; and the role list loaded pending requests
into Python to work out the disallow_self_add cases.

A test checks that the list, the notified reviewers, and the resolve
routes' permission checks agree for every routing scenario. A
disallow_self_add tag that reaches a role only by propagation no longer
hides a request, since it does not block the approval either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app owners level looks up an app's owners group by app_group.app_id
for every request row it checks. Postgres does not index foreign key
columns, so each check scanned app_group; the new index removes that scan.

On Postgres with 100k access requests and 20k role requests, an admin's
"Assigned to Me" went from 11 s to 57 ms (access, pending), 99 s to
515 ms (access, all), 79 s to 24 ms (role, pending), and over 300 s to
218 ms (role, all). Most of the gain comes from the reviewer rule keeping
each owner level a single flat EXISTS, which Postgres runs as a
semi-join; the index removes the per-row scan of app_group that remained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each kind of request gets a GET /reviewers route that returns its eligible
reviewers grouped by owner level, nearest first, with each user at their
nearest level. The first level is the one the request is assigned to; the
rest are who a requester can contact to escalate. Like the other request
reads, the routes need authentication but no further permission.

The generated API client is regenerated to include them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pending request's page lists its eligible reviewers from the new route,
one table per owner level, nearest first, with the assigned level marked.
The access and role request pages rebuilt the owner chain in the browser
from group and app ownership queries, so what they showed could differ
from who was notified; they now show the same reviewers the backend
assigns.

The list is shown to viewers who cannot resolve the request, and to the
requester beside their own request. Someone who can approve gets the
resolve form instead, and the page skips fetching the reviewers for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user guide gains a "Who reviews a request?" entry describing the owner
levels, the requester and disallow_self_add exclusions, and the reviewer
list on each request page; the entries on creating and responding to
requests link to it. The "Assigned to Me" tooltips say the requests are
assigned to you for review rather than awaiting your approval.

The contributor guide points the approver routing section at the one
definition of reviewers and the snapshot taken before ownership changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@barborico
barborico force-pushed the brynna/request_reviewer_routing branch from a0fb96b to 3054a29 Compare October 16, 2026 22:36
…er_routing

# Conflicts:
#	src/pages/role_requests/Read.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant