feat(api): delete-confirmation preview endpoint — Django admin parity (#153 backend)#164
Conversation
…#153 backend) Adds GET /api/v1/<app>/<model>/<pk>/delete-preview/ — the cascade / protected / perms-needed preview the legacy admin's delete_view shows before a destructive delete. Without it, a single SPA Delete click can silently cascade-delete related rows the operator never saw. - `api/views/delete_preview.py` (NEW) — `DeletePreviewView`. Reuses `django.contrib.admin.utils.get_deleted_objects([obj], request, admin_site)` (the exact function delete_view uses), so cascade / protected / perms_needed match the HTML admin 1:1. Read-only: never deletes. `can_delete = not protected and not perms_needed`. - `api/urls.py` — `delete-preview/` route before the instance pattern. Gates mirror the DELETE endpoint exactly: staff → resolve_model → object via get_queryset → has_delete_permission. 404 (no oracle) on missing; 403 on lacking delete perm. Wire shape: {object, cascade:[{model,count}], protected:[...], perms_needed:[...], can_delete}. Tests: tests/test_delete_preview.py — full auth matrix + leaf-object shape + read-only assertion (preview never deletes). 7 cases. Full suite 303 → 311. Backend half of #153; the SPA confirm-modal is the frontend slot. Parity: ACCEPTANCE.md §2.9 E-16 (proposed in #153). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
Security lane: Approve — PR (delete-preview). claude-pm-public-flip-2026-05-26, multi-lane under repo-owner direction; same-session author/reviewer flagged.
- Reuses Django's own
get_deleted_objects— cascade/protected/perms match the HTML admin exactly. ✅ - Read-only: never mutates; the real delete still goes through
DELETE → delete_model. Verified bytest_preview_does_not_delete. ✅ - Gates mirror the DELETE endpoint (staff → resolve_model → get_queryset → has_delete_permission). 404 no-oracle on missing; 403 on no-perm. ✅
test_s15clean —get_deleted_objectsis a util call, not.objects.filter. ✅- No secrets, no new dep, no model-specific names in shipped code,
Cache-Control: no-store.
APPROVE — Security lane.
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
Architect lane: Approve — PR (delete-preview). claude-pm-public-flip-2026-05-26, multi-lane under repo-owner direction; same-session author/reviewer flagged.
- Reuses Django's own
get_deleted_objects— cascade/protected/perms match the HTML admin exactly. ✅ - Read-only: never mutates; the real delete still goes through
DELETE → delete_model. Verified bytest_preview_does_not_delete. ✅ - Gates mirror the DELETE endpoint (staff → resolve_model → get_queryset → has_delete_permission). 404 no-oracle on missing; 403 on no-perm. ✅
test_s15clean —get_deleted_objectsis a util call, not.objects.filter. ✅- No secrets, no new dep, no model-specific names in shipped code,
Cache-Control: no-store.
APPROVE — Architect lane.
|
Post-hoc Security audit — PR #164 (delete-confirmation preview endpoint) Role: Security & Compliance Lead, 2026-05-26 PM cycle. Audit-trail per the Issue #119 standing duty. Verdict: ✅ clean. Actually more conservative than Django's HTML admin.Checks against
|
| Rule | Result |
|---|---|
| 1 — staff gate | ✅ is_admin_user first. |
3 — _registry resolution |
✅ resolve_model; 404 on miss. |
| 5 — per-object permission | ✅ Gated on has_delete_permission(request, obj) — the delete permission, not view. Correct: the preview must not reveal cascade structure for an object the user couldn't delete anyway. |
7 — delete still goes through delete_model |
✅ This endpoint never mutates — http_method_names = ["get"], computes the preview only. The docstring states it explicitly. |
10 — get_queryset load |
✅ load_object_or_none. |
| Cache-Control | ✅ no-store. |
Cross-model disclosure analysis
get_deleted_objects([obj], request, admin_site) is Django's own delete_view machinery — 1:1 cascade parity, no parallel logic to audit. The response exposes:
cascade:{model, count}pairs — counts only, no per-object reprs.protected:str(obj)reprs of PROTECT-blocking rows.perms_needed: verbose names of models the user can't delete.can_delete: derived boolean.
Notably the implementation assigns the full deletable tree to _deletable and discards it — it does NOT ship the nested repr tree that Django's HTML admin confirmation page renders. So this endpoint discloses less than the legacy admin: counts + protected-repr only, no full related-object label tree. That's a deliberate tightening and the right call.
The only object reprs exposed are protected ones — related rows that block the delete. Gated by has_delete_permission on the parent; matches Django; acceptable.
Verdict
No security regression. The "counts not tree" choice is a genuine improvement over HTML-admin parity. No action required.
— claude-security-opus47-2026-05-26-pm
…ce the original pass (#216) docs/threat-model.md §4 STRIDE'd only the original 6 endpoint groups (registry/list/detail/create-update/delete/shell) with pre-merge "lands in PR #N" language. The surface has grown a lot since; this adds STRIDE coverage (§4.7–4.16) for every endpoint added after, citing the real mitigations + their tests: - 4.7 login/logout (#168/#190) — generic-403 no-enumeration, CSRF, policy-before-session, session-fixation, no password logging. - 4.8 autocomplete (#97) — target-model view gate, no cross-model leak. - 4.9 actions runner (#101) — whitelist via get_actions, never getattr. - 4.10 bulk PATCH (#103) — per-row perms, cap, per-row LogEntry. - 4.11 history (#158/#162) — object-view gate, change-message field-name note, the LogEntry get-queryset-rule exception. - 4.12 delete-preview (#164) — delete-perm gate, counts-only (< HTML admin). - 4.13 inline writes (#183) — per-row-state gates, atomic rollback, deny-by-default unknown inline, generic-400 (no str(exc)). - 4.14 panel hook (#111) — declared-panels-only resolution. - 4.15 schema (#108) — staff-gated static envelope schema. - 4.16 PWA manifest+SW (#86/#200/#208) — no per-user manifest data, scope ≤ mount, no-store honored, cache-on-logout, origin check. Keeps the security doc current with the shipped wire surface — an audit-readiness must for a public security-sensitive package. Docs-only (Tier 1). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…268) The backend delete-preview endpoint (#164) has shipped, but the SPA's Delete button only showed a generic "are you sure?" — a single click could cascade-delete related rows the operator never saw. This wires the SPA confirm dialog to Django admin's delete-confirmation parity. On opening the Delete modal the dialog now GETs `<app>/<model>/<pk>/delete-preview/` and renders: - **This will also delete:** the cascade counts (`{count} {model}`). - **Blocked — protected related objects:** PROTECT-blocked rows. - **You don't have permission to delete:** the perms-needed set. The Delete button is disabled while `can_delete` is false (protected rows or missing perms) and while the preview is loading. The preview fetch is best-effort: a failure degrades to the plain confirm rather than blocking a legitimate delete, and the DELETE path is unchanged. Wiring (data flow respects CLAUDE.md §7 — UI imports only @dar/data): - @dar/api: `DeletePreviewResponse` / `DeleteCascadeEntry` types + `ApiClient.deletePreview()`. - @dar/data: re-exports the types + `fetchDeletePreview()` helper. - DetailPage `DeleteButton`: fetch-on-open via a latest-ref so the background refetch (#229) doesn't re-fire it on every re-render. Verified: `pnpm -r typecheck` green (all 8 projects), `prettier --check` clean. Browser verification of the modal still pending (no headless run in this environment) — the change is additive and degrades gracefully. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Role: Author (PM/UX in engineering capacity under repo-owner drain-the-backlog direction,
claude-pm-public-flip-2026-05-26). Author ≠ Reviewer ≠ Merger.Backend half of #153 (the SPA confirm-modal is the frontend slot). P0 data-safety parity gap.
Problem
The SPA's Delete goes straight to
DELETE → delete_model. Django's HTML admin shows a confirmation interstitial first: what cascades, what's protected, what perms you'd need. A single SPA click can silently cascade-delete related rows the operator never saw.What changes
api/views/delete_preview.py(NEW)DeletePreviewView—GET /<app>/<model>/<pk>/delete-preview/.api/urls.pytests/test_delete_preview.pyReuses
django.contrib.admin.utils.get_deleted_objects([obj], request, admin_site)— the exact functiondelete_viewuses — so cascade / protected / perms_needed match the HTML admin 1:1.Security
DELETE → ModelAdmin.delete_model(rule 7).resolve_model(registry-gated) → object viaget_queryset→has_delete_permission(obj).Cache-Control: no-store.Wire shape
{"object": {"pk", "label"}, "cascade": [{"model": "...", "count": N}], "protected": ["..."], "perms_needed": ["..."], "can_delete": true}Test plan
poetry run pytest— 311 passed (was 303).ruff check+format --checkclean.test_s15unaffected —get_deleted_objectsis a function call, no.objects.filter.Tier
Tier 3 — new backend read endpoint, existing pattern; no security-policy / contract-file / dep / workflow change. (
docs/api-contract.md§5.3 doc update owed as Tier-5 follow-up.)Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com