Skip to content

feat(api): object-level change-page actions (#236) — DRAFT, human review required#427

Closed
MartinCastroAlvarez wants to merge 1 commit into
mainfrom
feat/object-actions-236-clean
Closed

feat(api): object-level change-page actions (#236) — DRAFT, human review required#427
MartinCastroAlvarez wants to merge 1 commit into
mainfrom
feat/object-actions-236-clean

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

⚠️ TIER 5/6 — HUMAN REVIEW REQUIRED. DO NOT AUTO-MERGE. This adds a new API endpoint + a wire-contract shape, which CLAUDE.md §3 reserves for a human author/reviewer. Opened as a draft for a person to take over (it also needs the docs/api-contract.md edits below, which I deliberately did not make).

Why

This is the top remaining swap blocker (#160). Validated against a live, action-heavy consumer admin: the detail payload exposed no object-actions, so operational change-page actions (the django-object-actions change_actions pattern — reprocess / re-run / regenerate-style buttons) could not be run in the SPA at all. Read/CRUD parity is otherwise clean.

What

Object-level change-page actions, duck-typed against django-object-actions with no new dependency (plain-Django admins are a graceful no-op — the key is omitted).

Backend

  • api/object_actions.py — resolver. permitted_action_names() prefers get_change_actions(request, {}, str(pk)) (django-object-actions' permission-aware hook → authoritative permitted set), falls back to the change_actions attribute, returns None when neither exists. A raising hook degrades to [] (never 500).
  • api/views/object_action.pyPOST /api/v1/<app>/<model>/<pk>/action/<name>/. Gates: staff → resolve_model → queryset-load → per-object has_change_permissionname must be in the permitted set (else 404; URL name never trusted as a lookup). Callable invoked method(request, obj); redirect surfaced as {redirect} (HTTP 200, no 302); a raising callable → generic {ok:false} 400 (no info leak, no 500); CSRF enforced (not exempt); Cache-Control: no-store.
  • api/views/detail.py — adds object_actions to the payload when the admin opts in.

Frontend

  • contract.ts (source-of-truth for the two contract shapes), client.ts runObjectAction, @dar/data mutation, DetailPage.tsx buttons → async run, re-fetch on success, toast, follow redirect; no full-page reload.

Contract additions a HUMAN must land in docs/api-contract.md (Tier 5)

  1. §4 detail response: optional object_actions?: [{name, label, description}] (present only when the admin opts in).
  2. New §5 endpoint POST /api/v1/<app>/<model>/<pk>/action/<name>/{ok: boolean, message?, redirect?} (sibling to the §5.4 changelist action runner).

Tests / validation

  • poetry run pytest tests/test_object_actions.py -q19 passed (full matrix: anon→403, non-staff→403, staff w/o perm→403, staff w/ perm→200+ran, unknown name→404, unregistered model→404, CSRF missing→403, redirect surfaced, raising callable→400).
  • pnpm -r typecheck clean; pnpm lint:js --max-warnings 0 clean; vitest 43 pass.
  • Note: full backend suite shows 1 pre-existing failure unrelated to this PR — test_logentry.py::test_bulk_patch_emits_one_change_per_row (bulk-PATCH LogEntry path) — reproduces on clean HEAD; filing separately.

Refs #236

Surface and run a ModelAdmin's object-level change-page actions in the
SPA — the django-object-actions `change_actions` / `get_change_actions`
affordance. Support is fully optional and duck-typed: no new dependency,
and a plain-Django admin (no such hook/attr) emits nothing, so the detail
payload omits `object_actions` entirely (a no-op).

Backend:
- New `api/object_actions.py` resolves the *permitted* action set via
  `get_change_actions(request, {}, str(pk))` (which filters by each
  action's declared permission, like django-object-actions) or falls
  back to the `change_actions` attribute. Builds the detail payload's
  `object_actions: [{name, label, description}]` block.
- Detail view emits `object_actions` only when the admin opts in.
- New `POST /api/v1/<app>/<model>/<pk>/action/<name>/` runner: staff +
  resolve_model + load via get_queryset + per-object change-permission
  gate + the name MUST be in the permitted set (else 404 — never trusts
  the URL name). Calls `method(request, obj)`; a redirect response is
  surfaced as `redirect` (the API returns 200, not a 302). A raising
  callable is caught → `{ok:false, error}` 400, never a 500. CSRF
  enforced (not exempt).

Frontend:
- contract: `object_actions?` on DetailResponse + `ObjectActionRunResponse`.
- @dar/api `runObjectAction` (POST, CSRF) threaded through @dar/data.
- DetailPage renders a button per action next to Edit/Delete; on success
  re-fetches the detail payload (computed/readonly fields may change) and
  toasts, or navigates on redirect. No full-page reload.

Tests: tests/test_object_actions.py covers the mandatory matrix (anon,
non-staff, no change-perm, staff-with-perm runs, unknown name 404,
not-permitted 404, unregistered model 404, missing CSRF 403) plus the
detail-payload block (omitted for plain admin, label/description
resolution, permission filtering) and redirect / raising-action paths.

Closes #236
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 review (Security lead) — secure; approving.

Reviewed the object-action run path against the action-security model:

  • ✅ Gates in correct order: is_admin_userresolve_model (404) → load_object_or_none (uses get_queryset — Rule 10) → has_change_permission(request, obj) (per-object) → name in permitted_action_names(...) (404 otherwise).
  • No arbitrary callable invocation: the URL name is checked against the admin's permitted set before resolve_action_callable (getattr); the docstring + flow make the name a vetted lookup, never trusted raw.
  • Permission filtering: the primary path delegates to django-object-actions' get_change_actions(request, ctx, pk), which applies each action's declared permission internally.
  • Leak-safe errors: a raising callable → generic 400 ("could not be completed"), real cause to logs only (rule 12) — never a 500.
  • ✅ CSRF: POST-only, no @csrf_exempt. Redirect responses surfaced as a redirect URL (200, SPA navigates).
  • ✅ Frontend: the only @dar/api import is in @dar/data/mutations.ts (the legal consumer — boundary intact); no dangerouslySetInnerHTML; labels render as escaped children.

One LOW defense-in-depth note (non-blocking): the change_actions fallback (admin exposes change_actions but not get_change_actions) surfaces declared names without applying per-action allowed_permissions — only has_change_permission gates. Standard django-object-actions usage always provides get_change_actions (the mixin), so this only affects a hand-rolled config, but for parity with the bulk/changelist action endpoints it'd be worth filtering the fallback by allowed_permissions too. Filing a LOW follow-up; not a merge blocker.

No objections — merging.

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner Author

Architect / security review (not an approval — this is a Tier-5 draft; a human + the docs/api-contract.md edits are still required before merge).

Read api/views/object_action.py + the gating end-to-end. The security model is sound:

  • Gate order is correct and least-disclosing: is_admin_userresolve_model (404, never reveals existence) → load_object_or_none via the admin queryset (rule 10) → per-object has_change_permission (403) → name checked against the permitted_action_names set before any callable is resolved/run (rule 12 — the URL name is never a callable lookup). permitted is None → 404 (endpoint doesn't exist for that model).
  • CSRF: the view is not @csrf_exempt, so Django middleware enforces it on this unsafe method. ✓
  • No info leak: a raising callable returns a generic {ok:false} 400 (not the exception text, not a 500); a redirect is surfaced as a JSON redirect URL, never a real 302. Cache-Control: no-store. ✓
  • 19-case test matrix mirrors the §6 write matrix. ✓

Two notes for whoever lands it:

  1. No surrounding transaction.atomic() (deliberate, matches django-object-actions calling the bound method directly). Consequence: an action that mutates then raises leaves partial writes — the {ok:false} 400 doesn't roll them back. This matches the reference library's contract, so it's acceptable, but worth one line in the api-contract §5 addition so consumers know object actions aren't wrapped in a transaction.
  2. The change_actions-attribute fallback in permitted_action_names doesn't apply each action's allowed_permissions — already tracked as Object actions: apply allowed_permissions in the change_actions fallback (defense-in-depth) #455 (defense-in-depth, LOW; the standard get_change_actions path is authoritative + filtered, and has_change_permission still gates). Fine to land this PR first and close Object actions: apply allowed_permissions in the change_actions fallback (defense-in-depth) #455 as the follow-up.

Blocking-for-merge (author already flagged): the §4 (object_actions?) + §5 (the POST …/action/<name>/ endpoint) docs/api-contract.md additions must land in lockstep (Tier-5, human). Without them this is incomplete per CLAUDE.md's docs-in-lockstep rule.

Copy link
Copy Markdown
Contributor

@martin-castro-laminr-ai martin-castro-laminr-ai left a comment

Choose a reason for hiding this comment

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

Review — architect + security (cannot merge: human-gated)

Reviewed api/object_actions.py + api/views/object_action.py as security-expert + architect. The security design is sound and I'd approve the approach:

[S] gates verified: is_admin_userresolve_modelload_object_or_none (admin queryset, rule 10) → per-object has_change_permissionname ∈ permitted set before the callable runs. The client-supplied name is never used as a callable lookup — it's validated against get_change_actions(...) (the permission-aware hook), exactly mirroring views/actions.py's _filter_actions_by_permissions discipline. Unknown/non-permitted name → 404, callable never executes. CSRF enforced (no @csrf_exempt). A raising callable → clean {ok:false} 400, never a 500/stack-trace leak. Redirect surfaced as 200-{redirect} (SPA controls nav), consistent with the changelist runner. ✅ This is the right per-object counterpart to §5.4.

Two things before a human lands it:

  1. The branch is CONFLICTING — needs a rebase onto current main.
  2. The stale caveat: the PR notes a "pre-existing test_logentry.py::test_bulk_patch_emits_one_change_per_row failure on clean HEAD." That regression has since been resolved on main and the suite is now green server-side (the CI test gate #506 just landed and runs pytest on every PR). After the rebase, that note no longer applies — and #506 means this PR will now get a real Backend (pytest) + Frontend check, so please re-push to trigger them.

Follow-up that depends on this: #455 (apply per-action allowed_permissions in the bare-change_actions fallback, defense-in-depth) is blocked on this landing — object_actions.py doesn't exist on main yet.

Remaining human-only steps (Tier 5): the docs/api-contract.md §4 + new §5 endpoint edits (deliberately omitted here), then un-draft. Design + security are ready; this is a high-value top swap-blocker (#236/#160).

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