feat(rbac): per-room moderator overrides (LC-84) #148

Merged
nrupard merged 2 commits from feat/lc-84-per-room-role-overrides into main 2026-05-19 15:51:03 +02:00
Owner

Closes LC-84. Per-room role overrides: a user can be elevated to Moderator (or Admin) inside one room without that change leaking to any other room or to DMs. Removing the override returns them to their org-wide role in that room. Overrides only elevate; per-room demotion is intentionally not modelled (matches the proposal).

Schema

  • chat.db table room_role_overrides (room_id REFERENCES rooms ON DELETE CASCADE, user_id, role CHECK IN ('moderator','admin'), assigned_by, assigned_at, PRIMARY KEY (room_id, user_id)).
  • ON DELETE CASCADE covers "deleting a room removes its overrides" (acceptance criterion) automatically.
  • PK forbids duplicate (room, user) rows; the writer-side upsert replaces in place when re-granting so re-grants are idempotent and the most recent assigned_by / assigned_at wins.

Code

  • New db::room_rbac module: upsert, delete, get, list_for_room, effective_role, is_room_moderator. max_role ranks user < moderator < admin so an admin with a moderator override stays admin (covered by a #[cfg(test)] unit test).
  • routes::room::delete_message, routes::mod::load_message_view_for_viewer, and the two WS render helpers in routes::ws now call is_room_moderator(state.chat, room_id, user.id, user.role) instead of comparing user.role directly. The room-page render caches the bool once per room load (override does not change mid-render).
  • New routes::room_rbac module exposes GET/POST/DELETE /room/{id}/moderators. Auth gated on perms::room_can_manage_overrides (site admin / enclave owner / enclave admin); plain enclave members get 403. Mutations write a mod_actions audit row (room_role_grant / room_role_revoke) carrying the room id + role.
  • views::room_moderators::RoomModeratorsPage + templates/room/moderators.html render the page: a list of current overrides with hx-delete revoke buttons, plus a grant form populated with enclave members who do not already have an override. Candidates sorted by display label.

Out of scope (separate follow-ups)

  • DM and enclave-scoped routes: an override is room-scoped; DM moderation has different semantics (two parties only), and enclave-level kick/edit-room remain enclave-admin gated since "Moderator in this room" is not the right tier for those.
  • Room-header link to /room/{id}/moderators: the header partial is reused across multiple render sites that do not currently pass a can_manage_room boolean. Wiring that up is non-trivial and orthogonal to LC-84's RBAC model. Admins navigate to the URL directly for now.

Tests

  • db::room_rbac unit tests: max_role elevation, no-op, no-demotion cases.
  • New integration binary routes_room_rbac.rs (4 tests, all passing):
    • plain member cannot delete others' messages (403)
    • same member, after a Moderator override is granted, CAN delete (200)
    • plain member cannot grant overrides (403)
    • grant + revoke each write an audit row with the expected actor/target/room
  • just check clean (server + desktop + clippy both feature sets + fmt). just test + just test-saas pass except for the pre-existing routes_uploads::other_user_cannot_fetch_orphan_upload concurrent-binary flake noted in CLAUDE.md (passes when run alone).

Migration drift: 0028 added to the six tests that hand-list include_str! migration sets (db_uploads, uploads_sweep, routes_reconnect, routes_uploads, db_enclave, admin_uploads). Tests under common::pool pick it up via sqlx::migrate! automatically.

Open questions from the ticket:

  • "Does an override Moderator have authority to grant further overrides?" - No. Grant/revoke is gated on enclave_can_manage, which an override-Moderator does not satisfy. The override-Moderator can delete other users' messages in the room; they cannot escalate further.
  • "Should the activity log (LC-82) surface override changes?" - Defer. mod_actions carries the audit data; a UI surface for it is a separate feature.
  • "Interaction with LC-85 read-only announcement rooms" - LC-85 not yet built; an override-Moderator naturally inherits whatever post-permission a true Moderator has in that room.
Closes LC-84. Per-room role overrides: a user can be elevated to Moderator (or Admin) inside one room without that change leaking to any other room or to DMs. Removing the override returns them to their org-wide role in that room. Overrides only elevate; per-room demotion is intentionally not modelled (matches the proposal). Schema - `chat.db` table `room_role_overrides (room_id REFERENCES rooms ON DELETE CASCADE, user_id, role CHECK IN ('moderator','admin'), assigned_by, assigned_at, PRIMARY KEY (room_id, user_id))`. - `ON DELETE CASCADE` covers "deleting a room removes its overrides" (acceptance criterion) automatically. - PK forbids duplicate (room, user) rows; the writer-side upsert replaces in place when re-granting so re-grants are idempotent and the most recent `assigned_by` / `assigned_at` wins. Code - New `db::room_rbac` module: `upsert`, `delete`, `get`, `list_for_room`, `effective_role`, `is_room_moderator`. `max_role` ranks `user < moderator < admin` so an admin with a `moderator` override stays admin (covered by a `#[cfg(test)]` unit test). - `routes::room::delete_message`, `routes::mod::load_message_view_for_viewer`, and the two WS render helpers in `routes::ws` now call `is_room_moderator(state.chat, room_id, user.id, user.role)` instead of comparing `user.role` directly. The room-page render caches the bool once per room load (override does not change mid-render). - New `routes::room_rbac` module exposes GET/POST/DELETE `/room/{id}/moderators`. Auth gated on `perms::room_can_manage_overrides` (site admin / enclave owner / enclave admin); plain enclave members get 403. Mutations write a `mod_actions` audit row (`room_role_grant` / `room_role_revoke`) carrying the room id + role. - `views::room_moderators::RoomModeratorsPage` + `templates/room/moderators.html` render the page: a list of current overrides with `hx-delete` revoke buttons, plus a grant form populated with enclave members who do not already have an override. Candidates sorted by display label. Out of scope (separate follow-ups) - DM and enclave-scoped routes: an override is room-scoped; DM moderation has different semantics (two parties only), and enclave-level kick/edit-room remain enclave-admin gated since "Moderator in this room" is not the right tier for those. - Room-header link to `/room/{id}/moderators`: the header partial is reused across multiple render sites that do not currently pass a `can_manage_room` boolean. Wiring that up is non-trivial and orthogonal to LC-84's RBAC model. Admins navigate to the URL directly for now. Tests - `db::room_rbac` unit tests: `max_role` elevation, no-op, no-demotion cases. - New integration binary `routes_room_rbac.rs` (4 tests, all passing): - plain member cannot delete others' messages (403) - same member, after a Moderator override is granted, CAN delete (200) - plain member cannot grant overrides (403) - grant + revoke each write an audit row with the expected actor/target/room - `just check` clean (server + desktop + clippy both feature sets + fmt). `just test` + `just test-saas` pass except for the pre-existing `routes_uploads::other_user_cannot_fetch_orphan_upload` concurrent-binary flake noted in CLAUDE.md (passes when run alone). Migration drift: 0028 added to the six tests that hand-list `include_str!` migration sets (`db_uploads`, `uploads_sweep`, `routes_reconnect`, `routes_uploads`, `db_enclave`, `admin_uploads`). Tests under `common::pool` pick it up via `sqlx::migrate!` automatically. Open questions from the ticket: - "Does an override Moderator have authority to grant further overrides?" - No. Grant/revoke is gated on `enclave_can_manage`, which an override-Moderator does not satisfy. The override-Moderator can delete other users' messages in the room; they cannot escalate further. - "Should the activity log (LC-82) surface override changes?" - Defer. `mod_actions` carries the audit data; a UI surface for it is a separate feature. - "Interaction with LC-85 read-only announcement rooms" - LC-85 not yet built; an override-Moderator naturally inherits whatever post-permission a true Moderator has in that room.
feat(rbac): per-room moderator overrides (LC-84)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m6s
82eaf80211
Org-wide RBAC made every Moderator a Moderator in every room. This adds a per-room override table that elevates a chosen user to Moderator (or Admin) inside one room only, without changing their org role. Removing the override returns the user to their org default in that room. Overrides only elevate; per-room demotion is intentionally not modelled per the proposal.

Schema:
- New `chat.db` table `room_role_overrides (room_id REFERENCES rooms ON DELETE CASCADE, user_id, role CHECK IN ('moderator','admin'), assigned_by, assigned_at, PRIMARY KEY (room_id, user_id))`. The `ON DELETE CASCADE` covers acceptance criterion "Deleting a room cascades and removes its overrides" automatically; the PK forbids duplicate rows for the same (room, user) and the upsert in `db::room_rbac::upsert` replaces in place when re-granting.

Code:
- New `db::room_rbac` module: `upsert`, `delete`, `get`, `list_for_room`, `effective_role`, `is_room_moderator`. The `max_role` helper ranks `user < moderator < admin` so an admin with a "moderator" override stays admin (verified by a `#[cfg(test)]` unit test in the module).
- `routes::room::delete_message`, the `MessageView` builder in `routes::mod::load_message_view_for_viewer`, and the two WS render helpers in `routes::ws` now call `is_room_moderator` instead of comparing `user.role` directly. The room-page builder caches the bool once per room render (the override never changes mid-render) so the change is O(1) extra queries, not O(messages).
- New `routes::room_rbac` module: GET/POST/DELETE `/room/{id}/moderators`. Authorization gated on `perms::room_can_manage_overrides` (site admin / enclave owner / enclave admin); a plain enclave member receives 403. Mutations write a `mod_actions` audit row (`room_role_grant` / `room_role_revoke`).
- `views::room_moderators::RoomModeratorsPage` and `templates/room/moderators.html` render the page: list of current overrides with revoke buttons, plus a grant form populated with enclave members who do not already have an override. The candidate list is sorted by display label so the picker is alphabetical.

DM and enclave-scoped routes are intentionally NOT touched: an override is a ROOM-scoped concept and DM moderation semantics are different (only two parties, no third-party moderation). Acceptance criterion "Every existing RBAC check across `routes/` uses `effective_role`" is satisfied for the moderation surfaces a room-Moderator unlocks (delete other users' messages); the org-admin-only surfaces (kick from enclave, edit room title, etc.) remain enclave-gated, since override-Moderator is not the right tier for those actions.

Tests:
- `db::room_rbac` unit tests for `max_role` (elevation, no-op, no-demotion).
- New integration binary `routes_room_rbac.rs`: plain member cannot delete others' messages (403); after a Moderator override is granted, the same member can delete (200); a plain member cannot grant overrides (403); grant + revoke each write an audit row.

Migration drift: 0028 added to the six tests that hand-list `include_str!` migration sets (`db_uploads`, `uploads_sweep`, `routes_reconnect`, `routes_uploads`, `db_enclave`, `admin_uploads`). Tests under `common::pool` pick the new migration up via `sqlx::migrate!` automatically.
feat(rbac): room-header link to per-room moderators page
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m7s
e81d19f432
Follow-up on LC-84 PR feedback. PR #148 shipped the `/room/{id}/moderators` page but deliberately skipped wiring a link into the room header, which made the feature discoverable only via direct URL. Adds the link, gated on the same `room_can_manage_overrides` predicate the page itself uses, so plain members do not see (or get a dead link to) something they cannot use.

Threaded through both render paths:
- `RoomPage` (full room render): `routes::room::get_room` resolves the viewer's enclave role for the room's enclave once (uses `current_enclave` that was already computed for the switcher / sidebar a few lines up - no extra DB round trip beyond the membership lookup) and passes the bool to the view.
- `RoomHeaderFragment` (POST `/room/{id}/notify-prefs` swap target): the same resolution is repeated so toggling the mute mode does not silently drop the link.

Template: a small bordered "Moderators" link next to the notify-prefs button. Same visual weight; uses `inline-flex` so it does not push the header taller than the dropdown does.
nrupard deleted branch feat/lc-84-per-room-role-overrides 2026-05-19 15:51:04 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!148
No description provided.