Add per-object authorization for pages (security audit M2)

Add per-object authorization for pages (security audit M2)

#52 in Riparion/riparion-cms — merged 2026-06-03

What

Closes the M2 finding from the full-app security audit: page mutations gated only on the global pages:write capability, with no per-object ownership check. Any pages:write holder could edit any page, and pages got no resource-membership row on create — unlike posts, which enforce Editor-on-the-resource and bootstrap the creator as Owner.

This is latent today: seeded authors get only [posts:write, media:upload], and the only holder of pages:write is the admin role, which also holds pages:write_any. The gap bites the moment a pages:write-only role is delegated. This change closes it proactively by mirroring the posts pattern.

Changes

  • can_edit_page helper (src/server/admin/mod.rs) — Editor+ on the ("page", id) resource OR the global pages:write_any admin override; the page twin of can_edit_post.
  • create_page bootstraps the author as the page resource Owner (arium_resource_members, kind='page'). No DB migration — kind is a free string on the existing table.
  • update_page and get_page_edit take the authority extractor and gate on can_edit_page instead of require_perm(PAGES_WRITE).
  • delete_page stays admin-only (pages:write_any) — unchanged.

Deliberately out of scope

  • The admin page list stays unfiltered (full hierarchy tree visible to any pages:write holder) — pages are shared, hierarchical content and the tree needs ancestor context. Edit denial surfaces on the edit-form/update call.
  • The unpublished-page preview path (src/server/pages.rs) — any pages:write holder may preview drafts; minor read-only concern, left for parity-simplicity.

Verification

  • cargo fmt --check
  • clippy -D warnings (CI flags) on server,sqlite + server,postgres + wasm
  • cargo test --features server,sqlite — 42 passed ✅
  • Manual end-to-end steps recorded in TODO_VERIFICATION.md (admin CRUD, owner-row check, the delegated pages:write-only denial that proves the gap is closed, and the pre-existing-page regression note).

🤖 Generated with Claude Code

Last updated 2026-06-04