test(security): access-control regression gate for the leptos example

test(security): access-control regression gate for the leptos example

#10 in tonybierman/arium — merged 2026-05-25

What

Brings the Leptos adapter to parity with the Dioxus access-control gate (#8). Adds examples/leptos-fullstack-example/access-control-probe.sh and an access-control-leptos CI job that boots the example and asserts the same three properties against arium-leptos's server fns.

Phase Property
1 Every protected/admin endpoint denies an anonymous caller; public endpoints reachable; profile/logout/mfa-status return a benign default to anon, never real state.
2 Every /api/admin/* refuses a logged-in non-admin (vertical escalation).
3 B cannot revoke A's API token (horizontal / IDOR), with a control.

Leptos-specific adaptations (all verified against the example)

The Dioxus probe couldn't be reused as-is — Leptos differs at the wire level:

  • POST-only: server fns mount at POST /api/{endpoint} (handle_server_fns), so the probe POSTs everything (no GET).
  • Form-encoded, not JSON: server_fn's default codec is PostUrl (application/x-www-form-urlencoded). A JSON body fails with Args|missing field. Bodies are form fields; Vec/struct args use serde_qs bracket encoding (role_ids[0]=1, query[event_type]=&query[limit]=10&…) so the request reaches require_admin_perm instead of dying in arg deserialization — otherwise a deser error would mask the gate and hide its removal in a future refactor.
  • Error shape: wire-500 with a Type|message body (ServerError|Not signed in.) rather than JSON; the message text still matches the denial markers.

Same arium maybe_grant_first_admin first-user-wins bootstrap as Dioxus, so the sacrificial-admin preamble carries over unchanged.

Verified end-to-end against a fresh-DB instance: 37/37 PASS, with all 9 admin endpoints gate-confirmed under both anonymous and logged-in-non-admin. (37 vs Dioxus's 38 — the Dioxus example had an extra app-specific get_permissions fn.)

CI

access-control-leptos job: builds the example ssr-only, boots it headless (LEPTOS_SITE_ADDR + an empty site root, since a plain cargo build — unlike cargo leptos — doesn't populate one), waits for readiness, runs the probe with a seeded non-admin. As with #8, watch it go green here before merge.

Independent of #9 (the cache-key fix) — touches a different part of ci.yml.

🤖 Generated with Claude Code

Last updated 2026-05-26