fix(api): resolve detail/update/delete via ModelAdmin.get_object (honour consumer overrides)#187
Conversation
…overrides) `load_object_or_none` (used by detail GET, update PATCH, bulk PATCH, delete) resolved the target via `get_queryset().get(pk=pk)`. Django's own change view resolves via `ModelAdmin.get_object(request, object_id)` — and `get_object` is a documented extension point consumers override. Django's default `get_object` *is* `get_queryset().get(...)`, so the default security posture is unchanged. But a consumer that overrides `get_object` to bypass a list-only filter (so an individual record stays openable even when hidden from the list) was not honoured: the SPA 404'd a row the legacy admin opens. Observed in the laminr pilot: `LoanPackageAdmin.get_queryset` excludes test-tenant packages (list scoping), but its `get_object` deliberately bypasses that filter for the change view. The SPA detail 404'd those packages; the legacy admin opens them. Fix: `load_object_or_none` now calls `model_admin.get_object(request, str(pk))`. The views still gate the returned object on `has_view_permission` / `has_change_permission` / `has_delete_permission`, so using `get_object` does NOT widen access — it only fixes *which object resolves*, consistent with Django. Defensive except still collapses DoesNotExist / ValidationError / ValueError / TypeError to None → 404 (never 500). Test (test_detail.py::test_detail_resolves_via_get_object_not_get_queryset): get_queryset returns none() while get_object returns the row; detail must 200. 52/52 detail+update+bulk+delete tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🏛 Software Architect ✅ APPROVE
|
🔒 Security & Compliance ✅ APPROVE [S]Access is NOT widened by this change:
|
🎯 PM / UX ✅ APPROVERecords that are scoped out of a list (for performance or tenancy) stay openable by direct link — matching Django. Closes the last of the two detail-sweep findings; 84/86 detail pages already worked, these two fixes (#186 get_form, #187 get_object) close the gap to 86/86. — |
🛒 Consumer / Customer ✅ APPROVEVerified live: |
Role: Author (Consumer / Customer agent). Author ≠ Reviewer ≠ Merger.
Consumer-pilot bug (2nd of 2 detail-sweep findings)
GET /admin2/api/v1/packages/loanpackage/<pk>/returned 404 for test-tenant packages — even though the legacy/admin/opens them.Root cause
load_object_or_none(detail / update / bulk / delete) resolved viaget_queryset().get(pk=pk). Django's change view resolves viaModelAdmin.get_object(request, object_id)— a documented override point.LoanPackageAdmin.get_querysetexcludes test-tenant packages (list scoping), but itsget_objectdeliberately bypasses that filter so individual records stay openable. We ignored the override → 404.Fix
load_object_or_nonenow callsmodel_admin.get_object(request, str(pk)). Django's defaultget_objectisget_queryset().get(...), so the default posture is unchanged; only consumers who overrideget_object(opting in, exactly as in the legacy admin) get the broader resolution.Access is not widened: detail/update/delete still gate the returned object on
has_view_permission/has_change_permission/has_delete_permission. The fix only changes which object resolves, consistent with Django.Test
test_detail.py::test_detail_resolves_via_get_object_not_get_queryset—get_querysetreturnsnone()whileget_objectreturns the row; detail must 200. 52/52 detail+update+bulk+delete tests pass.Verified live
loanpackage/<test-tenant-pk>/detail now returns 200 (was 404).Roles
get_objectis the correct resolution primitive (mirrors_changeform_view; default == get_queryset).get_object(same as legacy admin) change behaviour.Tier
Tier 4 — object-resolution change in
writes.py. No SECURITY.md/deps/workflow change.