Repository navigation
Fix 504 on /admin/collections for non-system admins (PP-5445) - #3897
Conversation
|
Claude finished @dbernstein's task in 2m 23s —— View job Code review
SummaryNo actionable issues at Session lifetime: the loaded
Removing Tests: the two query-count tests are meaningful regression guards. Each pins the I reviewed by reading the code only and did not run the test suite. |
[Medium impact] The PR appears safe to merge; no actionable issues remain. SummaryPermission checks now read
No new actionable issues were found. Tests were not run during this review.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3897 +/- ##
=======================================
Coverage 93.87% 93.87%
=======================================
Files 512 512
Lines 46956 46926 -30
Branches 6373 6368 -5
=======================================
- Hits 44079 44051 -28
Misses 1857 1857
+ Partials 1020 1018 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Addressed both reviews in cf8ce61. The role-removal bug (claude, greptile P1) — real, and confirmed by a failing test before the fix.
I reproduced it before changing anything — Fixed by snapshotting with
Dead session cache on Confirmed there are no remaining While there I also dropped the Type hints (greptile P2) — done. Both Coverage. Verification: 1,198 tests passing across One thing worth flagging separately, since it is not caused by this PR but I ran into it: |
|
Both suggestions taken, in 33a41f7. Shared statement recorder. Extracted to with db.record_statements() as statements:
...
assert len(statements) <= 2
Worth noting for the follow-up N+1 work: #3898 is already open and branched from
I checked the one thing this turns on before making the change: the Verification: 1,198 tests passing across |
tdilauro
left a comment
There was a problem hiding this comment.
This looks good! 🥇
A couple of small issues for your review below.
| collection = db.collection( | ||
| name="Consortium Collection", protocol=OverdriveAPI, library=library | ||
| ) | ||
| collection.associated_libraries += [db.library() for _ in range(10)] |
There was a problem hiding this comment.
Minor - It looks like we wouldn't completely exercise can_see_collection here.
Roles are checked in two places:
can_see_collection: Can the admin see the collection at all?configured_service_library_info: For the collections the admin can see, check each library.
We currently have only a single collection. The librarian has a role for L1, and L1 is that collection's first library. So can_see_collection checks L1, finds a match, and stops. It woule never checks the other 10 libraries. So, only configured_service_library_info checks all 11.
Adding a collection with associations to the other libraries, but no association to the admin's library, would force all the libraries to be checked in both:
| collection.associated_libraries += [db.library() for _ in range(10)] | |
| collection.associated_libraries += [db.library() for _ in range(10)] | |
| # (probably warrants an explanatory comment here, however you want to phrase it) | |
| unassociated = db.collection(name="Unassociated Collection", protocol=OverdriveAPI) | |
| unassociated.associated_libraries += [db.library() for _ in range(10)] |
There was a problem hiding this comment.
good thinking. I'll modify.
There was a problem hiding this comment.
You're right — the loop was only ever running one iteration. db.collection(library=library) appends L1 first, so can_see_collection matched on the first element and the other ten libraries were never reached. Only configured_service_library_info was seeing all eleven.
Took the suggestion, plus an assertion that the response contains only the Consortium Collection — the new collection does its job only if it is actually walked and rejected, and without that assertion a future change that skipped it early would quietly return the test to its old coverage while still passing.
The second collection makes the loop run all ten iterations instead of matching on the first — against the pre-fix code the recorded role queries go from 86 to 157, which is the ten extra libraries being walked. Both versions of the test fail pre-fix, so this is not a better detector of the bug being fixed; it is coverage of the exhaustive no-match path, which the old test never reached.
One caveat — the new names assertion passes against the pre-fix code too. It guards the test's setup; it is not a second regression detector.
| # Detach the role as well as deleting the row. SQLAlchemy does not | ||
| # prune a loaded collection when a member is deleted, so a role left | ||
| # in it would keep granting access for the rest of the session. | ||
| self.roles.remove(admin_role) | ||
| Session.object_session(self).delete(admin_role) |
There was a problem hiding this comment.
Minor - I don't think the explicit delete is strictly necessary here. AdminRole has a delete orphan cascade, so that will take care of this. And we already have a test that should cover this, if someone were to remove the cascade.
It doesn't hurt to leave the delete in here, but at a minimum the comment is misleading.
| # Detach the role as well as deleting the row. SQLAlchemy does not | |
| # prune a loaded collection when a member is deleted, so a role left | |
| # in it would keep granting access for the rest of the session. | |
| self.roles.remove(admin_role) | |
| Session.object_session(self).delete(admin_role) | |
| self.roles.remove(admin_role) |
There was a problem hiding this comment.
I'll take a closer look.
There was a problem hiding this comment.
Agreed on both counts. Admin.roles is cascade="all, delete-orphan", so self.roles.remove() orphans the role and the cascade deletes the row — the explicit delete() was redundant. The comment was worse than redundant: it had the causality backwards, describing the removal as bookkeeping on top of the delete when the removal is what does the work.
Dropped the delete and rewrote the comment to say why the removal alone suffices. And you're right that it's covered: test_individual_admin_post_removes_all_roles_not_in_form flushes and queries AdminRole directly, asserting the rows are gone rather than merely detached, so dropping the cascade would fail a test.
`/admin/collections` returned a 504 for any admin who was not a system admin on a consortium circulation manager. The five `Admin.is_*` permission checks were built on `HasSessionCache`, which never caches a lookup that finds nothing: `_cache_lookup` only calls `_cache_insert` when the miss hook returns a row. Used as an existence check, every call therefore went back to the database. Asking whether an admin is a librarian for a library they hold no role in cost about five uncached queries, and `/admin/collections` asks that once per collection/library pair -- both in `can_see_collection`, which loops over a collection's associated libraries, and again in `configured_service_library_info` for each library configuration. A system admin has a `system` role row, so the first lookup hits and is cached and every later check is a dict read; a library admin has no matching row for any library but their own and so caches nothing. That asymmetry is why only non-system-admins timed out. Answer the checks from the `roles` relationship instead, which SQLAlchemy loads in one query and keeps for the life of the session. Measured against a consortium with 14 libraries and 11 collections: 548 queries for a librarian before, 35 after -- the same as a system admin, and no longer growing with the number of libraries per collection. `remove_role` now also detaches the role from the loaded `roles` collection. SQLAlchemy does not prune a collection when one of its members is deleted, so a removed role would otherwise keep granting access for the rest of the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nches `handle_roles` assigned `old_roles = admin.roles` -- the live collection, not a copy -- and then called `admin.remove_role(...)` while iterating it. Now that `remove_role` also detaches the role from that collection, removing an element shifted the next one past the loop's position, so a request that dropped more than one role only dropped every other one. The request still returned 200 and the admin kept the access the form had taken away. Snapshot the list before iterating. This also makes `old_roles` genuinely "old": `add_role` appends to the same collection, so the removal loop previously walked roles the request had just added. `AdminRole` no longer uses its session cache -- the five `Admin.is_*` checks were its only callers -- so drop the `HasSessionCache` mixin and `cache_key()` rather than leave them suggesting the cache is still the way to answer these questions. `Admin` keeps its mixin; `authenticate` uses it. `remove_role` dropped its `if admin_role in self.roles` guard. `get_one` filters on `admin=self`, so a row it finds is always in the loaded collection, and the guard was an unreachable branch. Tests: a controller test that drops two roles in one request (fails without the snapshot, leaving the second role in place), plus coverage for the sitewide-only paths in `is_library_manager` / `is_librarian` and for removing a role the admin does not hold. Type hints on the new test callbacks, per CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review follow-ups. The `before_cursor_execute` recorder was copied verbatim into both regression tests. Move it to `DatabaseTransactionFixture` as `record_statements`, where CLAUDE.md puts shared test helpers, so the two tests say what they mean and the next query-count assertion has something to reuse. `remove_role` queried for the role with `get_one` and then called `self.roles.remove(...)`, which loads the collection anyway -- two queries for one removal, and the removal quietly depended on the row it found also being in the loaded list. Find the role in `self.roles` instead. The relationship is already the source of truth for the permission checks, `add_role` flushes through `create`, so `library_id` is always populated, and this drops a query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The admin's only library was the first one the collection was associated with, so can_see_collection matched immediately and never walked the other ten. Add a collection the admin has no role for, which is walked in full and withheld. Against the pre-fix code the test now records 157 role queries rather than 86. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roles relationship cascades delete-orphan, so removing the role from the collection deletes the row on its own. The old comment also had the causality backwards, presenting the removal as bookkeeping on top of the delete rather than the thing doing the work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a08e52a to
ed4a812
Compare
Description
Answer the five
Admin.is_*permission checks from therolesrelationship instead ofHasSessionCache.HasSessionCache._cache_lookuponly caches a lookup that returns a row — the insert is guarded byif obj is not None. Used as an existence check, a miss therefore re-queries the database on every single call.Asking whether an admin is a librarian for a library in which they have no associated roles cost ~5 uncached queries (
is_system_admin,is_sitewide_library_manager,is_library_manager,is_sitewide_librarian,is_librarian, each a freshget_one)./admin/collectionsasks that question once per collection/library pair — incan_see_collection, which loops over a collection's associated libraries, and again inconfigured_service_library_infofor every library configuration. On a consortium CM where each collection is associated with every member library, that is libraries × collections × 5 queries.remove_rolenow also detaches the role from the loadedrolescollection. SQLAlchemy does not prune a collection when one of its members is deleted, so a removed role would otherwise keep granting access for the rest of the session. Three existing tests cover this.Motivation and Context
Library staff (any admin who is not a system admin) could not load the Lists page on a CM with a large number of libraries (ie over 1000). The page showed a loading bar and an error; the underlying cause was
/admin/collectionsreturning 504.This only affected non-system-admins, and the cache is why. A system admin has a
systemrole row, so the very first lookup hits, gets cached, and every later check is a dict read. A library admin has no matching row for any library but their own, so nothing ever caches. The asymmetry belongs to the cache, not to the permission model.Measured on a consortium with 14 libraries and 11 collections:
After this change the librarian issues 35 queries — identical to the system admin — and the per-library factor is gone.
Note that this is not a recent regression. The N+1 predates the current release; a consortium simply has to grow past the point where libraries × collections × 5 exceeds the gateway timeout.
How Has This Been Tested?
Two new regression tests, both confirmed to fail without the fix:
test_role_checks_read_roles_once(tests/manager/sqlalchemy/model/test_admin.py) — asserts a permission check reads the admin's roles once however many libraries it is asked about. Fails onmainwithassert 71 <= 2.test_collections_get_checks_roles_once_for_non_system_admins(tests/manager/api/admin/controller/test_collections.py) — asserts the collections listing issues at most oneadminrolesquery. Fails onmainwithassert 86 <= 1.Full runs:
tests/manager/apiandtests/manager/sqlalchemyuv run mypyclean on 1,174 source filespre-commit runcleanVerified end-to-end against a local CM restored from consortium data: signed in as a library admin, loaded the Lists page, and confirmed it renders fully and auto-redirects into list editing — the redirect that the fetch error was previously blocking. All admin endpoints reachable by a librarian returned 200.
Follow-ups (not in this PR)
An audit of the rest of the admin API turned up no other misuse of the session cache as an existence check, but did find several ordinary lazy-load N+1s that grow with the number of libraries (1–2 queries per library, versus the ~37 per library fixed here), and
/admin/statsis slow for an unrelated reason — fiveGROUP BYscans overlicensepools, 2.2 s on a small dev dataset. Both are being handled separately.Checklist
🤖 Generated with Claude Code