Skip to content

feat(api): delete-confirmation preview endpoint — Django admin parity (#153 backend)#164

Merged
MartinCastroAlvarez merged 1 commit into
mainfrom
feat/delete-preview-endpoint
May 26, 2026
Merged

feat(api): delete-confirmation preview endpoint — Django admin parity (#153 backend)#164
MartinCastroAlvarez merged 1 commit into
mainfrom
feat/delete-preview-endpoint

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

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

File Change
api/views/delete_preview.py (NEW) DeletePreviewViewGET /<app>/<model>/<pk>/delete-preview/.
api/urls.py route before the instance pattern.
tests/test_delete_preview.py 7 cases.

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.

Security

  • Read-only. Never deletes; only previews. The actual delete still goes through DELETE → ModelAdmin.delete_model (rule 7).
  • Gates mirror DELETE exactly: staff → resolve_model (registry-gated) → object via get_querysethas_delete_permission(obj).
  • 404 (no oracle) on missing; 403 on lacking delete perm.
  • Cache-Control: no-store.

Wire shape

{"object": {"pk", "label"},
 "cascade": [{"model": "...", "count": N}],
 "protected": ["..."],
 "perms_needed": ["..."],
 "can_delete": true}

Test plan

  • poetry run pytest311 passed (was 303).
  • Matrix: anon→302/403, non-staff→403, unregistered→404, bogus-pk→404, no-delete-perm→403, leaf-object shape, read-only (object survives a preview).
  • ruff check + format --check clean.
  • test_s15 unaffected — get_deleted_objects is 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

…#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>
Copy link
Copy Markdown
Owner Author

@MartinCastroAlvarez MartinCastroAlvarez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by test_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_s15 clean — get_deleted_objects is 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.

Copy link
Copy Markdown
Owner Author

@MartinCastroAlvarez MartinCastroAlvarez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by test_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_s15 clean — get_deleted_objects is 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.

@MartinCastroAlvarez MartinCastroAlvarez merged commit 1ed1ecf into main May 26, 2026
@MartinCastroAlvarez MartinCastroAlvarez deleted the feat/delete-preview-endpoint branch May 26, 2026 19:50
@MartinCastroAlvarez
Copy link
Copy Markdown
Owner Author

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 SECURITY.md §3

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 mutateshttp_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

MartinCastroAlvarez added a commit that referenced this pull request May 26, 2026
…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>
MartinCastroAlvarez added a commit that referenced this pull request May 26, 2026
…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>
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.

2 participants