Per-resource authorization and membership lifecycle

Per-resource authorization and membership lifecycle

#14 in tonybierman/arium — merged 2026-05-26

Adds arium's per-resource authorization layer: a ResourceRole lattice (Viewer < Editor < Admin < Owner), a ResourceAuthority enforcement gate (require_resource), and a MembershipStore lifecycle layer whose composites own the invariants apps otherwise reinvent — grant, revoke, transfer, and the reverse "what can this user see?" enumeration. The app supplies storage-shaped primitives; arium sequences them inside one transaction so count-then-write checks can't be raced. Ships both a batteries-included SqlMembershipStore and the trait for apps that own their own table.

Orphan-resource guards

The lifecycle composites refuse to leave a resource with zero owners:

  • revoke_membership — refuses to remove the sole Owner (LastOwner).
  • transfer_ownership — promotes the new owner and demotes the old one atomically (never a window with two owners or none), and is a no-op when from == to (which would otherwise collapse the upsert pair onto one row and net-demote the only owner).
  • grant_membership — besides bounding the granted role by the actor's tier, now reads the target's current role in-tx: an actor can't modify a member who outranks them (an Admin can't demote the Owner), and demoting the sole Owner is refused (LastOwner), mirroring the revoke guard. Without this, a grant/upsert was a back door to the same ownerless state revoke and transfer already prevented.

Tests

crates/arium/tests/membership.rs covers the full matrix: last-owner refusal, non-last-owner removal, atomic transfer, actor-authority limits, the new self-demote / admin-can't-demote-owner / non-last-owner-demotable / self-transfer-no-op cases, and the SqlMembershipStore round-trip.

🤖 Generated with Claude Code

Last updated 2026-05-27